Skip to content

jonreid/TestableView

Repository files navigation

TestableView

Sample app

TestableView improves SwiftUI unit testing by cutting through the clutter of boilerplate code, letting you zero in on what matters: your test's intent.

Contents

Boilerplate example

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

snippet source | anchor

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.

Adding it to your project

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.

Production code

  1. Copy TestableView.swift into your production code.
  2. Redefine your View as a TestableView. Xcode will tell you how to define your hook property.
  3. Make sure to call the hook at the end of your view:

.onAppear { self.viewInspectorHook?(self) }

snippet source | anchor

Test code

  1. Copy UpdateTestableView.swift into your test code.
  2. Change the YourModule placeholder so it does an @testable import from the module that defines TestableView.

Use it in your test

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

snippet source | anchor

That's much simpler, hiding the boilerplate that isn't part of the test-specific intent.

Improvements for safety and scannability

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

snippet source | anchor

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!

Acknowledgements

  • Alexey Naumov for creating ViewInspector
  • “The regulars” on my Twitch stream for refactoring with me
  • Joe Cursio for suggesting a better name, inspectChangingView

About the Author

Jon Reid is the author of iOS Unit Testing by Example.
Find more at Quality Coding.

Bluesky Mastodon YouTube

About

To hide ViewInspector boilerplate for SwiftUI unit tests

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Contributors 2

  •  
  •