- This example demonstrates how to test any
SwiftUI.View
without hacks or third-party libraries, accomplished in just a few minutes of coding. - We compare SwiftUI and MVVM and show that the
Observable
view-model approach may not be the best fit for SwiftUI, as it doesn't align well with its paradigm.
Short overview of the code:
SwiftUI.View
is just a protocol that only value types can conform to, its centerpiece being the body
property, which produces another SwiftUI.View
.
It lacks typical view properties like frame or color. This implies that SwiftUI.View
isn't a traditional view.
SwiftUI.View
looks and acts more like a view-model. Understanding this is key to grasping the essence of SwiftUI.View
.
Anyone claiming how Apple coupled view and business logic is wrong. Apple just used View conformance on top of the model. That is not coupling. That is POP.
In SwiftUI it is up to you what goes to model and what goes to View conformance extension. Don't blame Apple if your code is entangled.
MVVM uses HARD decoupling which is more suitable for Java and other old-school languages.
We start with two similar implementations of our business logic:
struct ContentModel {
@State var sheetShown = false
@State var counter = 0
func increase() { counter += 1 }
func showSheet() { sheetShown.toggle() }
}
@Observable final class ContentViewModel {
var sheetShown = false
var counter = 0
func increase() { counter += 1 }
func showSheet() { sheetShown.toggle() }
}
We can make two view variants, one is SwiftUI and the other is MVVM. One uses conformance and the other uses composition.
extension ContentModel: View {
var body: some View {
let _ = assert(bodyAssertion) // this is the only line in the view for testing support
VStack {
Text("The counter value is \(counter)")
Button("Increase", action: increase)
Button("Show sheet", action: showSheet)
}
.sheet(isPresented: $sheetShown) {
Sheet()
}
}
}
struct ContentView: View {
@State var vm = ContentViewModel()
var body: some View {
let _ = assert(bodyAssertion) // this is the only line in the view for testing support
VStack {
Text("The counter value is \(vm.counter)")
Button("Increase", action: vm.increase)
Button("Show sheet", action: vm.showSheet)
}
.sheet(isPresented: $vm.sheetShown) {
Sheet()
}
}
}
The key for view testing is to host the View in some App.
We need to share the state, that is the hosted view, between the main-target and the test-target:
struct TestApp: App {
static var shared: Self!
@State var view: any View = EmptyView()
var body: some Scene {
let _ = Self.shared = self
WindowGroup {
AnyView(view)
}
}
}
We need to notify the test function that body evaluation happened. To achieve this is we add let _ = assert(bodyAssertion)
as the first line of the body.
NOTE: Assertion does not evaluate in release! We dont need #if DEBUG ...
We can test both SwiftUI and MVVM versions in the same way, no hacking, no third party libs.
We receive body-evaluation index and the view itself as an async sequence from our 30-lines of code "framework" so we can test if the evaluations behave like we intended:
func testContenModel() async throws {
TestApp.shared.view = ContentModel()
for await (index, view) in ContentModel.bodyEvaluations().prefix(2) {
switch index {
case 0:
XCTAssertEqual(view.counter, 0)
view.increase()
case 1:
XCTAssertEqual(view.counter, 1)
view.showSheet()
default: break
}
}
}
func testContentView() async throws {
TestApp.shared.view = ContentView()
for await (index, view) in ContentView.bodyEvaluations().prefix(2) {
switch index {
case 0:
XCTAssertEqual(view.vm.counter, 0)
view.vm.increase()
case 1:
XCTAssertEqual(view.vm.counter, 1)
view.vm.showSheet()
default: break
}
}
}
Testing the body function using tools like ViewInspector, in conjunction with our native testing approach, allows us to interact with SwiftUI elements and to verify their values with each interaction.
Tests are identical for both SwiftUI and MVVM:
switch index {
case 0:
_ = try view.inspect().find(text: "The counter value is 0")
try view.inspect().find(button: "Increase").tap()
case 1:
_ = try view.inspect().find(text: "The counter value is 1")
try view.inspect().find(button: "Show sheet").tap()
default: break
}
Test findings spotlight a disparity in number of body evaluations.
MVVM approach necessitates more evaluations of the view’s body, underscoring a potential inefficiency in how MVVM patterns integrate with SwiftUI’s rendering cycle.
ContentModel: @self, @identity, _sheetShown, _counter changed.
ContentModel: _counter changed.
ContentView: @self, @identity, _vm changed.
ContentView: @dependencies changed.
ContentView: @dependencies changed.
- My biggest issue with MVVM is inability to use native property wrappers like @Environment, @AppStorage, @Query and others.
- View-models are not composable, while SwiftUI models(views) are very easy to split and reuse. MVVM just leads us to massive views and massive view-models. Its harder to split to smaller components. You need double amount of work to split them. You need to split VM and the View each on its own.
- Another problem with MVVM is usage of reference types. Using
[weak self]
everywhere is so annoying and misuse can lead to reference cycles.
Now that we know how to test "views" there is really no need to use MVVM.