TestableView improves SwiftUI unit testing by cutting through the clutter of boilerplate code, letting you zero in on what matters: your test's intent.
- Boilerplate example
- Adding it to your project
- Use it in your test
- Testing with Environment
- Acknowledgements
- About the Author
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. Now we have an expressive SwiftUI unit test!
The previous example was one of @State. What about @Environment? For that, there's another helper inspectView(_:environment:value:):
@MainActor
func test_greets_with_injected_username() throws {
var sut = GreetingView()
inspectView(&sut, environment: \.username, value: "Alice") { view in
let greeting = try view.find(viewWithAccessibilityIdentifier: "greeting").text().string()
XCTAssertEqual(greeting, "Hello, Alice!")
}
}- 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.