DISCOVERY

March 12th, 2022

UI Testing SwiftUI Views

Swift

iOS

SwiftUI

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.swift import XCTest class ExerciseLogUITests: XCTestCase { lazy var app = XCUIApplication() override func setUpWithError() throws { app.launchArguments += ["UI_TESTING"] continueAfterFailure = false } override func tearDownWithError() throws {} ... }

The 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.

func testShowsCreateLog() throws { app.launch() signIn(app: app) let tabBar = app.tabBars["Tab Bar"] tabBar.buttons["New Log"].tap() let scrollViewsQuery = app.scrollViews let elementsQuery = scrollViewsQuery.otherElements XCTAssert(elementsQuery.staticTexts["Create Exercise Log"].exists) XCTAssert(elementsQuery.staticTexts["Average"].exists) XCTAssert(elementsQuery.staticTexts["Exercise Name*"].exists) XCTAssert(elementsQuery.staticTexts["Location"].exists) XCTAssert(elementsQuery.staticTexts["Date*"].exists) XCTAssert(elementsQuery.staticTexts["Exercise Type"].exists) XCTAssert(elementsQuery.staticTexts["Distance"].exists) XCTAssert(elementsQuery.staticTexts["Time"].exists) XCTAssert(elementsQuery.staticTexts["Feel"].exists) XCTAssert(elementsQuery.staticTexts["Description"].exists) XCTAssert(elementsQuery.buttons["Create"].exists) XCTAssert(elementsQuery.buttons["Cancel"].exists) }

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.

func testSliderChangesFeel() throws { app.launch() signIn(app: app) let tabBar = app.tabBars["Tab Bar"] tabBar.buttons["New Log"].tap() let scrollViewsQuery = app.scrollViews let elementsQuery = scrollViewsQuery.otherElements let feelSlider = elementsQuery.sliders["Feel"] XCTAssert(elementsQuery.staticTexts["Average"].exists) feelSlider.adjust(toNormalizedSliderPosition: 0.0) XCTAssert(elementsQuery.staticTexts["Terrible"].exists) feelSlider.adjust(toNormalizedSliderPosition: 0.1) XCTAssert(elementsQuery.staticTexts["Very Bad"].exists) feelSlider.adjust(toNormalizedSliderPosition: 0.2) XCTAssert(elementsQuery.staticTexts["Bad"].exists) feelSlider.adjust(toNormalizedSliderPosition: 0.3) XCTAssert(elementsQuery.staticTexts["Pretty Bad"].exists) feelSlider.adjust(toNormalizedSliderPosition: 0.4) XCTAssert(elementsQuery.staticTexts["Mediocre"].exists) feelSlider.adjust(toNormalizedSliderPosition: 0.6) XCTAssert(elementsQuery.staticTexts["Average"].exists) feelSlider.adjust(toNormalizedSliderPosition: 0.7) XCTAssert(elementsQuery.staticTexts["Fairly Good"].exists) feelSlider.adjust(toNormalizedSliderPosition: 0.8) XCTAssert(elementsQuery.staticTexts["Good"].exists) feelSlider.adjust(toNormalizedSliderPosition: 0.9) XCTAssert(elementsQuery.staticTexts["Great"].exists) feelSlider.adjust(toNormalizedSliderPosition: 1.0) XCTAssert(elementsQuery.staticTexts["Fantastic"].exists) }

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 (buttons["New Log"].tap()). 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.

func testNameFormValidation() throws { app.launch() signIn(app: app) let tabBar = app.tabBars["Tab Bar"] tabBar.buttons["New Log"].tap() let scrollViewsQuery = app.scrollViews let elementsQuery = scrollViewsQuery.otherElements let nameField = elementsQuery.textFields["Name Field"] let nameValidationText = elementsQuery.staticTexts["Name Validation Text"] XCTAssertEqual(nameField.value as? String, "") XCTAssertFalse(nameValidationText.exists) nameField.tap() nameField.typeText("5th Ave Mile") XCTAssertEqual(nameField.value as? String, "5th Ave Mile") XCTAssertFalse(nameValidationText.exists) nameField.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: 12)) XCTAssertEqual(nameField.value as? String, "") XCTAssert(nameValidationText.exists) nameField.typeText("A") XCTAssertEqual(nameField.value as? String, "A") XCTAssertFalse(nameValidationText.exists) nameField.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: 1)) XCTAssertEqual(nameField.value as? String, "") XCTAssert(nameValidationText.exists) }

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 - XCTAssertEqual() and 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.

import XCTest class ExerciseLogUIStubTests: XCTestCase { let apiStubs = Stubs() lazy var app = XCUIApplication() override func setUpWithError() throws { try! apiStubs.server.start(9080) app.launchArguments += ["UI_STUB_TESTING"] continueAfterFailure = false } override func tearDownWithError() throws { apiStubs.server.stop() } ... }

Compared to the ExerciseLogUITests class shown previously, ExerciseLogUIStubTests adds another instance variable (apiStubs) and additional lines of code to setUpWithError() and tearDownWithError(). Within 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 UI_STUB_TESTING. Within tearDownWithError(), the line apiStubs.server.stop() stops the API server after testing completes.

The 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.

import Foundation import Swifter enum StubHttpVerb: String, CaseIterable, Identifiable { case get case post case put case delete var id: String { self.rawValue } } enum StubHttpStatus: String, CaseIterable, Identifiable { case ok case badRequest var id: String { self.rawValue } } class Stubs { let server = HttpServer() func stubRequest(path: String, jsonData: Data, verb: StubHttpVerb = .get, status: StubHttpStatus = .ok) { guard let json = try? JSONSerialization.jsonObject(with: jsonData, options: .mutableContainers) else { return } let response: ((HttpRequest) -> HttpResponse) = { _ in if status == .badRequest { return HttpResponse.badRequest(.json(json as AnyObject)) } else { return HttpResponse.ok(.json(json as AnyObject)) } } if verb == .post { server.post[path] = response } else if verb == .put { server.put[path] = response } else if verb == .delete { server.delete[path] = response } else { server.get[path] = response } } }

The 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, jsonData, verb, and status. 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.

Although 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 stubRequest().

Let's look at one of these tests. The following test, from the ExerciseLogUIStubTests.swift file, tests the successful creation of an exercise log.

func testCreateLogSuccess() throws { apiStubs.stubRequest(path: "/v2/logs/", jsonData: LogStubs().createdData, verb: .post) app.launch() signIn(app: app) let tabBar = app.tabBars["Tab Bar"] tabBar.buttons["New Log"].tap() let scrollViewsQuery = app.scrollViews let elementsQuery = scrollViewsQuery.otherElements let nameField = elementsQuery.textFields["Name Field"] let locationField = elementsQuery.textFields["Location Field"] let distanceField = elementsQuery.textFields["Distance Field"] let timeField = elementsQuery.textFields["Time Field"] let descriptionField = elementsQuery.textFields["Description Field"] let feelSlider = elementsQuery.sliders["Feel"] nameField.tap() nameField.typeText("Central Park") locationField.tap() locationField.typeText("New York, NY") distanceField.tap() distanceField.typeText("6.3") timeField.tap() timeField.typeText("43") timeField.typeText("21") feelSlider.adjust(toNormalizedSliderPosition: 0.7) descriptionField.tap() descriptionField.typeText("Wednesday AM Run") let alert = app.alerts["Exercise log created!"] XCTAssertFalse(alert.exists) let createButton = elementsQuery.buttons["Create"] createButton.tap() XCTAssert(elementsQuery.staticTexts["Average"].waitForExistence(timeout: 2)) XCTAssert(alert.exists) alert.buttons["Continue"].tap() XCTAssertFalse(alert.exists) XCTAssertEqual(nameField.value as? String, "") XCTAssertEqual(locationField.value as? String, "") XCTAssertEqual(distanceField.value as? String, "") XCTAssertEqual(timeField.value as? String, "") XCTAssert(elementsQuery.staticTexts["Average"].exists) XCTAssertEqual(descriptionField.value as? String, "") }

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 createButton.tap() command.

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.

func testCreateLogFailure() throws { apiStubs.stubRequest(path: "/v2/logs/", jsonData: LogStubs().createdData, verb: .post, status: .badRequest) app.launch() signIn(app: app) let tabBar = app.tabBars["Tab Bar"] tabBar.buttons["New Log"].tap() let scrollViewsQuery = app.scrollViews let elementsQuery = scrollViewsQuery.otherElements let nameField = elementsQuery.textFields["Name Field"] let locationField = elementsQuery.textFields["Location Field"] let distanceField = elementsQuery.textFields["Distance Field"] let timeField = elementsQuery.textFields["Time Field"] let descriptionField = elementsQuery.textFields["Description Field"] let feelSlider = elementsQuery.sliders["Feel"] nameField.tap() nameField.typeText("Central Park Hills") locationField.tap() locationField.typeText("New York, NY") distanceField.tap() distanceField.typeText("10.75") timeField.tap() timeField.typeText("1") timeField.typeText("13") timeField.typeText("02") feelSlider.adjust(toNormalizedSliderPosition: 0.8) descriptionField.tap() descriptionField.typeText("Thursday Hill Workout") let alert = app.alerts["An unexpected error occurred while creating an exercise log."] XCTAssertFalse(alert.exists) let createButton = elementsQuery.buttons["Create"] createButton.tap() XCTAssert(alert.waitForExistence(timeout: 2)) alert.buttons["Try Again"].tap() XCTAssert(alert.waitForExistence(timeout: 2)) alert.buttons["Cancel"].tap() XCTAssertFalse(alert.exists) XCTAssertEqual(nameField.value as? String, "Central Park Hills") XCTAssertEqual(locationField.value as? String, "New York, NY") XCTAssertEqual(distanceField.value as? String, "10.75") XCTAssertEqual(timeField.value as? String, "1:13:02") XCTAssert(elementsQuery.staticTexts["Good"].exists) XCTAssertEqual(descriptionField.value as? String, "Thursday Hill Workout") }

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.