In my previous article, I discussed SwiftUI and how to integrate SwiftUI components into a UIKit application. SwiftUI is a (relatively) new framework for creating user interfaces in Apple ecosystem applications. User interfaces built using SwiftUI are configured completely in code with Swift. In this article, I discuss how to UI test SwiftUI views in an iOS application.
UI tests are a form of integration tests and end to end tests where a user interface is tested in a manner similar to how a user interacts with it. For example, a UI test may click a button in a user interface and assert that something appears on the screen in response. I've discussed UI testing frameworks such as Cypress and AWS Synthetics Canary Functions (which utilize Selenium and Puppeteer) in prior articles, both of which are designed for testing web applications.
If you are looking for a step by step guide to start building UI tests in the XCode IDE, this article is a good place to start1. If you want to view the UI test code discussed in this article, it is available in my saints-xctf-ios repository on GitHub.
The application under test is my SaintsXCTF iOS app, which allows users to log their running exercises. It was built for my college cross country and track & field teams. The page under test is a SwiftUI view that allows users to create exercise logs. This view is shown below.
In my codebase, there is a ExerciseLogUITests.swift file containing UI tests for the "create exercise log" view. UI tests are outlined with the following Swift code.
ExerciseLogUITests class contains all the UI tests for the "create exercise log" view. It also contains instance variables used in tests and functions that run before and after test functions. For example,
app is a variable of type
XCUIApplication, representing the iOS application.
app is used extensively in tests to interact with the UI and make assertions about its appearance. The function
setUpWithError() is invoked before each test function and
tearDownWithError() is invoked after each test function. I don't use these methods for much, but they can be utilized for elaborate testing configurations. Within
setUpWithError(), the line
app.launchArguments += ["UI_TESTING"] sets the environment for the application during UI tests. The line
continueAfterFailure = false configures test methods to fail fast after they encounter their first error.
The remainder of the
ExerciseLogUITests class contains UI test methods. Running these methods in XCode causes an iOS device simulator to run, opening the application and running test commands and assertions. Let's look at a basic test.
testShowsCreateLog() is a UI test method asserting certain input elements and buttons exist on a page. It begins with the method call
app.launch(), which launches the application in the device simulator. It then invokes a custom helper function
signIn(app: app), which signs a test user into the application.
tabBar.buttons["New Log"].tap() navigates to the "create exercise log" page by clicking on a button containing the text "New Log".
testShowsCreateLog() uses a
XCTAssert() function to make assertions about the UI. For example, the assertion
XCTAssert(elementsQuery.staticTexts["Distance"].exists) ensures that the text "Distance" exists on the page. Meanwhile,
XCTAssert(elementsQuery.buttons["Create"].exists) ensures that the page has a button with the text "Create". This test is very simple, but more complicated tests still use the same building blocks; all tests interact with the UI and use
XCTAssert() to make assertions.
Let's look at a slightly more complex UI test.
testSliderChangesFeel() tests a slider on the "create exercise log" page. This slider is used to indicate how an athlete felt while exercising.
testSliderChangesFeel() starts off the same as the previous test. It launches the application (
app.launch()), signs in a test user (
signIn(app: app)), and navigates to the "create exercise log" page (
testSliderChangesFeel() is a bit more complex than the previous test because it interacts with the page along with making assertions. Method invocations to
feelSlider.adjust(toNormalizedSliderPosition: _) move the slider to different positions. Then,
XCTAssert() ensures that text describing how the athlete felt is displayed on the screen.
Let's look at one more UI test.
testNameFormValidation() tests that form validation is functional for an input field on the "create exercise log" page. The test types text into a "Name Field" and then deletes it, asserting that a validation message appears when the text is gone.
Again, this test starts the same as the previous two. Later on in the test, two new assertion functions are used -
XCTAssertFalse(). Like any other testing library, XCTest provides many different assertion functions to make testing easier.
XCTAssertEqual(nameField.value as? String, "") checks that the "Name" field is empty when the page first loads, and
XCTAssertFalse(nameValidationText.exists) checks that no validation text is shown when the page first loads.
nameField.typeText("5th Ave Mile") types a value into the "Name" field. Assertions follow, ensuring the text is displayed on the screen. The text is then deleted by passing
String(repeating: XCUIKeyboardKey.delete.rawValue, count: 12) to
nameField.typeText(). The assertion
XCTAssert(nameValidationText.exists) ensures that the validation message is properly displayed.
Many applications use API calls to populate their user interfaces with data. The UI tests I've shown so far make API calls the same way as the production application. However, sometimes we want to manipulate API responses to help perform UI tests. For example, to test an error scenario, we can manipulate the API to return an error response instead of a typical successful response.
In E2E testing frameworks such as Cypress, manipulated API responses (mocked/stubbed responses) are achieved with fixtures. Fixtures are JSON files that represent API response objects. When writing iOS UI tests, a similar approach can be implemented.
The approach for mocking/stubbing API calls in UI tests is to start an API server while the tests run. From this API, custom API responses can be implemented. Classes for UI tests with stubbed API calls are configured slightly differently than classes with standard API calls. The following code, which comes from my ExerciseLogUIStubTests.swift file, shows a class outline for my "create exercise log" page UI tests with stubbed API calls.
Compared to the
ExerciseLogUITests class shown previously,
ExerciseLogUIStubTests adds another instance variable (
apiStubs) and additional lines of code to
setUpWithError(), the line
try! apiStubs.server.start(9080) starts an API server on port 9080. The UI sends requests to this API during tests. The line
app.launchArguments += ["UI_STUB_TESTING"] configures the environment of the application. My application is configured to use the local API server running on port 9080 when the environment is
tearDownWithError(), the line
apiStubs.server.stop() stops the API server after testing completes.
apiStubs instance variable is used for both starting and stopping the local API server.
apiStubs is an instance of
Stubs, a custom class containing an instance variable representing the API server and a factory function for stubbing API endpoints.
Stubs exists in a Stubs.swift file and is shown below.
Swifter library is used to create a server in Swift code. This server is represented in code by the
server instance variable, initialized with the line
let server = HttpServer().
stubRequest() is the factory function for stubbing API endpoints that I mentioned previously.
stubRequest() takes four arguments:
path is the path of an endpoint in an API to be stubbed.
verb is the HTTP verb of the endpoint and
jsonData is the JSON response body of the endpoint.
status is the HTTP status code returned by the API endpoint.
stubRequest() doesn't return any value, it configures a new API endpoint with the desired configuration on the
server object. Once execution of
stubRequest() is complete, this API endpoint can be invoked with HTTP requests. For UI tests that utilize stubbed API calls, every API endpoint that the application calls is configured with
Let's look at one of these tests. The following test, from the ExerciseLogUIStubTests.swift file, tests the successful creation of an exercise log.
The first line of the test,
apiStubs.stubRequest(path: "/v2/logs/", jsonData: LogStubs().createdData, verb: .post), stubs an HTTP POST request to a /v2/logs/ endpoint. This endpoint is used to create an exercise log. The remainder of the test fills values into the form displayed on the UI using the
typeText() method. Once all values are filled in, the "Create" button is clicked using the
Because the test uses a stubbed API call, no exercise log is created in the application database. However, from the UI's perspective, it appears that the log was successfully created. This is one of the great benefits of subbed API calls - they aren't dependent on and don't impact the backend system of an application.
Let's look at another example. The following test, also from the ExerciseLogUIStubTests.swift file, tests a scenario where an internal server error occurs while creating an exercise log.
Right off the bat, the stubbed API request for this test is configured to return an HTTP error code with the
status: .badRequest argument. Similar to the previous example, this test fills out the form shown on the UI and clicks the "Create" button to create a new exercise log. The line
let alert = app.alerts["An unexpected error occurred while creating an exercise log."] assigns a UI alert for an unexpected error to the variable
alert. The UI test checks to make sure this alert is shown on the UI when an API call fails unexpectedly. This is accomplished with the
XCTAssert(alert.waitForExistence(timeout: 2)) function call, which asserts that the alert is displayed on the UI within two seconds.
This test demonstrates another benefit of stubbed API calls - testing unexpected application states, such as when errors occur.
Writing UI tests is a great way to automate the testing of an application. With UI tests in place, applications have an additional safety net, ensuring they operate as users expect. Creating UI tests for SwiftUI views is relatively easy, so it's definitely worth the effort. All the UI test code shown in this article is available on GitHub.