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(viewWithAccessibilityIdentifier: "increment").button().tap()
let count = try view.find(viewWithAccessibilityIdentifier: "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.
XCTestCase+InspectChangingView.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. You can also subscribe to my blog, install the custom code snippets, and expand “testableview” into a new file.
- 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 XCTestCase+InspectChangingView.swift into your test code.
- Change the
YourModuleplaceholder so it does an@testable importfrom 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(viewWithAccessibilityIdentifier: "increment").button().tap()
let count = try view.find(viewWithAccessibilityIdentifier: "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(viewWithAccessibilityIdentifier: "increment").button().tap()
count = try view.find(viewWithAccessibilityIdentifier: "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.