Removing State properties for more testable SwiftUI
Luke, 28 Feb 2021
@State
properties are SwiftUI properties that can update the view, when they themselves change. Its one of the first things you learn with SwiftUI – the reactive nature of these properties, that on changing, cause a redraw of the view they relate to. Its fun playing with them, and before you know it your views can be absolutely full of @State
properties that govern allsorts of appearance related and animatable things.
Normally speaking, putting a View through unit testing might not be the normal way to do things. UIUnitTesting is a better way to go, and it means that SwiftUI views will work as expected, including @State
properties. However, a problem Ive encountered several times before, is that providing lots of UIUnitTests can be cumbersome – each test requires a restart of the simulator, the app must play through to get to the point you need to test, etc. If you have lots of tests going on, these tests can take a long time to run through. Which is why a better way to go is to use normal unit testing absolutely wherever possible. The can be parallelized, which means they can all run simultaneously, which is super quick. Writing code that is easy to unit test in this traditional way is assisted by the following technique – taking your @State properties and turning them into @Published
ones. These can be unit tested in a normal way, where @State
properties do not play nicely in an traditional unit test.
So how to convert @State
properties into @Published
properties within a Class, using @ObservableObject
. There are a four steps to this, which I will outline here – but see the below code example for a clear demo of how its done.
First, you need a separate class that will contain your ex-@State
vars. These ViewModels, and are classes, rather than structs (this solves our lifecycle issue, and hence, testability). This class will be declared an @ObservableObject
. Within that, what were @State
should now be declared @Published
. Finally, when we declare an instance of this ViewModel in the view itself, it has to be marked as @ObservedObject
(note – NOT @Observable
).
// The original view with an @State property
struct ViewWithStates: View {
@State var showSheet = false
var body: some View {
//binding link to the above @State, linked using '$'
ChildViewWithBinding(showActionSheet: $showSheet)
}
}
// -----------------
// A similar view but this time, the @State has been replaced with an @ObservedObject
// containing @Published bool. ViewModel below.
struct ViewWithModelAndPublishers: View {
@ObservedObject var viewModel = ViewWithModelVM()
var body: some View {
//Same binding link, but this time the '$' is on the viewModel, not the bool property.
ChildViewWithBinding(showActionSheet: $viewModel.showActionSheet)
}
}
//class, not a struct - ObservableObjects cannot be structs.
class ViewWithModelVM: ObservableObject {
@Published var showActionSheet: Bool = false
}
From there on, the new @Published
property within that ViewModel can be treated as if it were an @State
. This means it can also be passed via @Binding
into another View which similarly reacts to changes in it. For all intents and purposes, it is still an @State
– but now we can test the property within the ViewModel with a unit test. Heres some examples:
class StateToPublishedTests: XCTestCase {
/**
This test fails because we are trying to test a bool within a SwiftUI Struct,
and it can be recreated at any time.
*/
func testStateBool() {
let viewWithStates = ViewWithStates()
viewWithStates.showSheet = true
XCTAssertTrue(viewWithStates.showSheet)
}
/**
This test for a similar boolean also controlling a SwiftUI state succeeds
because it is contained in a Class
*/
func testViewModelBool() {
let viewModel = ViewWithModelVM()
viewModel.showActionSheet = true
XCTAssertTrue(viewModel.showActionSheet)
}
}
So all weve tried to do here is to instantiate each kind of object, change the boolean property within each, and prove that we can test the change. In the first case, the test fails – simply changing an @State
bool to ‘true’, then checking if its true – does not work. But in the second case the test passes. The boolean that is an @Published
property has been changed to true, but the change has persisted and can be tested.
Ive put a full demo of this complete with unit tests on a public repo here :