Testable SwiftUI views using async/await

Lazar Otasevic
6 min readDec 24, 2023

--

All the “boilerplate” code you need for testing is in TestingSupport.swift and it has 30 lines of code! GitHub repo is here: https://github.com/sisoje/testable-view-swiftui

  • 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.

SwiftUI.View

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.

Since SwiftUI.View is just a protocol we can move it to extension. Then what we are left with is a pure struct looks and acts more like a view-model and has nothing to do with SwiftUI.View. Understanding this is key to grasping the essence of SwiftUI.View.

SwiftUI.View compared to view-model

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.

There is no pattern that will magically organize code for you.

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() }
}

Model conformance vs VM composition

We can make two view variants, one is pure SwiftUI and the other is MVVM.

One uses protocol conformance and the other uses composition.

extension ContentModel: View {
var body: some View {
let _ = assert(bodyAssertion) // this is the one and 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 one and 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()
}
}
}

MVVM practitioners

MVVM practitioners adapt the class-based view-model for SwiftUI.

What’s the justification for MVVM advocates to decouple as they do? It seems duplicative, given Apple’s use of protocol-oriented programming for innate decoupling.

The chosen path often fragments SwiftUI.View into a reference-type view-model, effectively diminishing the View to merely encapsulate a body function. This transformation not only hampers SwiftUI’s native state management but also renders native property wrappers like @Environment, @AppStorage, @Query, etc., unusable within the view-model class.

Additionally, MVVM proponents typically focus on extensively testing these view-models, yet they frequently overlook testing the body function, a critical piece in ensuring SwiftUI views behave as intended. Why test something that strays from the framework’s design?

Apple intentionally embraced value types for their safe and encapsulated nature. And so, the decision by MVVM enthusiasts to separate the view-model from the “view,” seems unnecessary.

Native async testing of the business logic

View hosting

The key for view testing is to host the View in some App, so that @State and other property wrappers will be properly initialised/installed by the framework. Even @Environtment and @Query and all others will work.

We need to share the state, that is the hosted view, between the main-target and the test-target. We have both main-app and test-app structs inside the project and when we detect running unit tests then we use the test-app.

struct TestApp: App {
static var shared: Self!
@State var view: any View = EmptyView()
var body: some Scene {
let _ = Self.shared = self
WindowGroup {
AnyView(view)
}
}
}

Body evaluation notifications

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. What you can also do is add UIView.setAnimationsEnabled(false)to make tests run faster.

NOTE: Assertion does not evaluate in release! We don’t need #if DEBUG …

Test functions using async/await

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 up-to-date version of the view as an async sequence, using our 30-lines of code “framework” so we can test if the evaluations at given index 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
}
}
}

All native, free of hacking, pure Swift in just a few minutes. Say farewell to the need for MVVM!

Testing UI interactions using ViewInspector

MVVM practitioners just ignore testing the body function!

We should indeed test the body function of SwiftUI.View, as it plays a crucial role in mapping the state to the resulting view. Verifying that the body function behaves correctly is essential for ensuring the desired behavior of our SwiftUI views.

To accomplish this, one approach is to utilize tools like ViewInspector in combination with our native testing methodology. ViewInspector enables us to inspect and interact with the SwiftUI views, allowing us to assert that the state is correctly reflected in the view’s output.

Using ViewInspector both of our approaches result in the same testing function.

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
}

But is it all the same under the hood? No, it is not.

Number of body evaluations

Test findings spotlight a disparity in body evaluations: the view-model approach necessitates more body evaluations of the view’s body, underscoring a potential inefficiency in how MVVM patterns integrate with SwiftUI’s rendering cycle.

2 body evaluations using SwiftUI

ContentModel: @self, @identity, _sheetShown, _counter changed.
ContentModel: _counter changed.

3 body evaluations using MVVM

ContentView: @self, @identity, _vm changed.
ContentView: @dependencies changed.
ContentView: @dependencies changed.

Design flaws of MVVM in SwiftUI

  • 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.
  • 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.

--

--