TestableView improves SwiftUI unit testing by cutting through the clutter of boilerplate code, letting you zero in on what matters: your test's intent.
When using ViewInspector to unit test a SwiftUI View that uses @State
or @Environment
, the simplest approach is to add a hook called on didAppear
. The test then:
- wraps a closure into that hook,
- creates an XCTestExpectation so the test can wait for the hook,
- mounts the View, and
- waits for the closure to run.
@MainActor
func test_incrementOnce_withBoilerplate() throws {
var sut = ContentView()
let expectation = sut.on(\.viewInspectorHook) { view in
try view.find(viewWithId: "increment").button().tap()
let count = try view.find(viewWithId: "count").text().string()
XCTAssertEqual(count, "1")
}
ViewHosting.host(view: sut)
defer { ViewHosting.expel() }
wait(for: [expectation], timeout: 0.4)
}
That's a lot of boilerplate, and it makes it harder to scan for the test intent.
UpdateTestableView.swift provides an XCTestCase extension to take care of that boilerplate.
The new XCTestCase method relies on a TestableView type to define the hook for ViewInspector. So you need to use one file in your production code, and one file in your test code.
- Copy TestableView.swift into your production code.
- Redefine your View as a
TestableView
. Xcode will tell you how to define your hook property. - Make sure to call the hook at the end of your view:
.onAppear { self.viewInspectorHook?(self) }
- Copy UpdateTestableView.swift into your test code.
- Change the
YourModule
placeholder so it does an@testable import
from the module that definesTestableView
.
Now our test can call inspectChangingView(_:action:)
like this:
@MainActor
func test_incrementOnce_withTestableView() throws {
var sut = ContentView()
inspectChangingView(&sut) { view in
try view.find(viewWithId: "increment").button().tap()
let count = try view.find(viewWithId: "count").text().string()
XCTAssertEqual(count, "1")
}
}
That's much simpler, hiding the boilerplate that isn't part of the test-specific intent.
I avoid assertions inside closures. If something goes wrong with the infrastructure and the closure doesn't run, will the test fail? Sometimes the infrastructure ensures this, sometimes it doesn't.
So I like to set up an optional variable, capture the value inside the closure, then check the result on the outside.
@MainActor
func test_incrementOnce_scannable() throws {
var sut = ContentView()
var count: String?
inspectChangingView(&sut) { view in
try view.find(viewWithId: "increment").button().tap()
count = try view.find(viewWithId: "count").text().string()
}
XCTAssertEqual(count, "1")
}
That lets us add blank lines to separate the Arrange/Act/Assert sections of the test.
Now we have a SwiftUI unit test that is safer, and easier to scan!
- Alexey Naumov for creating ViewInspector
- “The regulars” on my Twitch stream for refactoring with me
- Joe Cursio for suggesting a better name,
inspectChangingView
Jon Reid is the author of iOS Unit Testing by Example.
Find more at Quality Coding.