SwiftUI Testing With ViewInspector for iOS
Learn how to use the ViewInspector framework to write UI tests for SwiftUI apps. By Warren Burton.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
SwiftUI Testing With ViewInspector for iOS
30 mins
- Getting Started
- First Run
- Installing ViewInspector
- Adding a Test Target to Your Project
- Adding the ViewInspector Framework to Your Test Target
- Writing ViewInspector Tests
- Writing a Baseline Test
- Making Changes to the View
- Adding Tests for Changes
- Conform Your View to Inspectable
- Working With Runtime Properties
- Writing an Asynchronous Test
- Understanding how ViewInspector Works
- Testing With Style
- Testing Collections
- Configuring StepListView for Test
- Testing StepListView
- Traversing the View Tree
- Where to Go From Here?
Understanding how ViewInspector Works
Now that you’ve used ViewInspector, you may be curious about the inner workings of the framework.
ViewInspector is the product of some smart generic coding and a lot of hard work by its developer to create a proxy for each supported SwiftUI type. The framework uses the reflection API Mirror
to inspect a view while it’s being rendered in a window.
For every supported SwiftUI type, ViewInspector creates a corresponding proxy object by parsing the description tree generated by Mirror
.
For example, take a simplified look at the test testAddRecipeAddsRecipe
:
In this diagram:
-
RecipeListView
conforms toInspectable
and has anInspection
. Remember thatInspection
conforms toInspectionEmissary
in the test target. Therefore, you can callview.inspection.inspect
, and in the callback sent to that function, you can now callinspect.find(button: "Add Recipe")
onRecipeListView
. - ViewInspector uses
Mirror
to describe the content tree inRecipeListView
. - ViewInspector returns to you an
InspectableView
proxy object of typeViewType.Button
. - You call
tap()
on the proxy object to simulate a touch on the button. - ViewInspector uses
Mirror
to get a reference to the action closure attached to theButton
inRecipeListView
. ViewInspector then calls that closure.
The source code for ViewInspector is available in the project navigator in the Package Dependencies section. You can add breakpoints and step through them in the debugger if you want to get a deeper view of how the framework works.
Testing With Style
When you build your app, you’ll want to keep common styles in a ViewModifier
or a specialist type like ButtonStyle
, which allows you to encapsulate many modifiers and make changes to the style when you touch a button. In this section, you’ll explore testing a ButtonStyle
.
In the project navigator, open RecipeListView.swift. Display and refresh the preview canvas. You can see the Add Recipe button you tested in the previous section. You’ll add a ButtonStyle
to that button to make it look nice, and then you’ll test that style.
Your design specifies a button with a capsule-style green background that expands by 10% when pressed.
In the project navigator, in the folder StepByStep ▸ Views, open the file StyleBook.swift. At the bottom of the file, add this modifier:
struct AdditiveButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .padding(8) .background(Color.green) .foregroundColor(.white) .clipShape(Capsule()) .scaleEffect(configuration.isPressed ? 1.0 : 1.1) .animation(.easeOut(duration: 0.2), value: configuration.isPressed) } }
Open RecipeListView.swift again and locate Button("Add Recipe")
. Add this line at the comment // Add button style here
:
.buttonStyle(AdditiveButtonStyle())
Refresh the preview, and make it interactive:
You’ll see your button has a capsule style that changes when you touch it. Next, you’ll add a test to check that this style conforms to the requested design and doesn’t change.
In the project navigator, open StepByStepTests.swift. Add these two tests to StepByStepTests
:
func testAddRecipeButtonHasCorrectStyle() throws { let controller = RecipeController.previewController() let view = RecipeListView() let expectation = view.inspection.inspect { view in let button = try view.find(button: "Add Recipe") XCTAssertTrue(try button.buttonStyle() is AdditiveButtonStyle) } ViewHosting.host(view: view.environmentObject(controller)) self.wait(for: [expectation], timeout: 1.0) } func testAdditiveButtonStylePressedState() throws { let style = AdditiveButtonStyle() XCTAssertEqual(try style.inspect(isPressed: true).scaleEffect().width, 1.1) XCTAssertEqual(try style.inspect(isPressed: false).scaleEffect().width, 1.0) }
The first test checks that the button has AdditiveButtonStyle
applied to it.
The second test verifies that AdditiveButtonStyle
makes a button larger when pressed. ViewInspector provides a convenience initializer inspect(isPressed: Bool)
to allow you to select the pressed state for testing.
Build and test to see the results:
testAdditiveButtonStylePressedState
failed. It appears the ternary expression for the scaleFactor
value is back to front. This sort of bug is why you write tests. You challenge your own assumptions about a piece of code when you write a test for the code.
In the project navigator, open StyleBook.swift, and in AdditiveButtonStyle
, find the line:
.scaleEffect(configuration.isPressed ? 1.0 : 1.1)
And change it to:
.scaleEffect(configuration.isPressed ? 1.1 : 1.0)
Build and test. Your test navigator is all green again:
Next, you’ll learn how test collection views where you have many identical elements.
Testing Collections
When you create a List
, you usually have a collection of objects that you present in a vertical table. Each object uses an identical cell view. In this section, you’ll learn how to search List
or any other collection view that SwiftUI provides.
In the project navigator, in the folder StepByStep ▸ Views ▸ Steps, open StepListView.swift. Refresh the preview:
Configuring StepListView for Test
You have a button and a list. In StepListView
, add this line to the top of the struct body to enable inspection:
internal let inspection = Inspection<Self>()
Next, you’ll dress up your button. Add the button style you created to Button("Add Step")
at the comment // add style here
:
.buttonStyle(AdditiveButtonStyle())
And lastly, add an observer to the VStack
at the comment // add onReceive here
:
.onReceive(inspection.notice) { self.inspection.visit(self, $0) }
In the project navigator, open Inspectable+Model.swift and add the conformance to Inspectable
for all the views that make up the step list:
extension StepListView: Inspectable {} extension StepLineView: Inspectable {} extension StepEditorView: Inspectable {}
Testing StepListView
You’re now ready to write some tests. Open StepByStepTests.swift. Your first test confirms that the list has a cell for every step item. Add this code to the end of StepByStepTests
:
func testStepName(_ index: Int) -> String { "Step -\(index)" } func makeStepController(_ count: Int) -> StepController { let recipeController = RecipeController.previewController() let recipe = recipeController.createRecipe() for idx in 1...count { let step = recipeController.createStep(for: recipe) step.name = testStepName(idx) step.orderingIndex = Int16(idx) } let stepController = StepController( recipe: recipe, dataStack: recipeController.dataStack ) return stepController }
This code creates a database that you can test against and a recipe with count
steps. One of the secrets to a highly testable app is the ability to create a data model in your test environment:
Now, add this test function to StepByStepTests
:
func testStepListCellCountSmall() throws { let expectedCount = 20 let stepController = makeStepController(expectedCount) let view = StepListView(stepController: stepController) let expectation = view.inspection.inspect { view in let cells = view.findAll(StepLineView.self) XCTAssertEqual(cells.count, expectedCount) } ViewHosting.host(view: view) self.wait(for: [expectation], timeout: 1.0) }
In this test, you check that StepListView
has the same number of cells as steps in the recipe. You use the alternative search of findAll
to locate all instances of StepLineView
. findAll
returns an empty array if there are no matches, unlike find
, which throws when there’s no match.
Build and test to check whether your test assumption is true:
Next, add this test to prove that the cells are displaying the name
of the Step
:
func testStepListCellContent() throws { let expectedCount = 10 let stepController = makeStepController(expectedCount) let view = StepListView(stepController: stepController) let expectation = view.inspection.inspect { view in for idx in 1...expectedCount { _ = try view.find(StepLineView.self, containing: self.testStepName(idx)) } } ViewHosting.host(view: view) self.wait(for: [expectation], timeout: 1.0) }
This test uses find(_:containing:)
to locate a StepLineView
with the specified name. Build and test to check that all tests pass:
When you make unit tests, try to test one thing per test. Don’t try to squish multiple tests into one test just because the setup process is long.
Your test code should be as readable and maintainable as your production code.
Your next test checks that a cell has a NavigationLink
to StepEditorView
. Add this test to StepByStepTests
:
func testStepCellNavigationLink() throws { let expectedCount = 1 let stepController = makeStepController(expectedCount) let view = StepListView(stepController: stepController) let expectation = view.inspection.inspect { view in let navLink = try view.find(ViewType.NavigationLink.self) _ = try navLink.view(StepEditorView.self) } ViewHosting.host(view: view) self.wait(for: [expectation], timeout: 1.0) }
You verify the test case that a cell has a NavigationLink
that takes you to StepEditorView
. You don’t need a call to XCTAssert
, as the call to view
will throw if StepEditorView
can’t be found.
Build and test to check this works:
Very nice! You now have a comprehensive test suite to help you as you proceed with your recipe app.