Starting with UI Tests in Swift: Increase your iOS app stability

An introduction to how you can start adding UI Tests to your project

Bruno Lorenzo
6 min readFeb 20, 2024

Imagine publishing a small change or hotfix to production in a hurry (we’ve all been there).

You don’t think it would break anything… until it does.

Image by Author

Picture this: in your app, you have a button that triggers a specific user's action, sound familiar? But, If the button doesn’t show, the user is blocked and can’t continue. That doesn’t seem like a good UX 😬.

This is a very avoidable scenario if you have a QA team. The thing is, we do not always have it to back us up.

These situations can be prevented using UI Tests.

So, let’s dive in on how to include them in our projects.

But first, what should we test?

When we write UI Tests, we interact with our application simulating a user.

What to test will depend on your app’s context. However, we can divide it into two categories:

#1 User’s flow

Think about the most common flows that your users do in your app. That should be the first candidates to write some UI tests.

Then, think about how you know if the flow was successful or not. It could be as simple as showing a final success message or navigating to a specific view.

#2 UI Components

In some views, we want to be sure that certain elements are shown.

It could be any component like buttons or even specific texts. Think about which elements must be present.

Setting up Xcode for UI Tests

As I mentioned in Unit Tests in Swift: A Starting Guide, we need to include a specific UI Test Bundle in our Test Plan.

Image by Author

I’ll be using the same Test Plan that I created for Unit Tests.

Image by Author

If you want to learn more about Test Plans, Test Bundles, Test Classes, and Test Methods, I recommend you to check the Unit Tests starting guide article.

Interacting with the application

I’ll be using my Coffee Shop app for the demo. If you’re interested, here’s the code

All UI elements are subclasses of XCUIElement, this class gives us the functionality to interact with the application.

XCTest framework also provides a specific XCUIElement to launch, monitor, and terminate the application when running UI Tests: XCUIApplication.

UI Test’s life cycle is the same as Unit Tests:

  • setupWithErrorrs executes at the beginning of every test.
  • tearDownWithErrors executes at the end of every test.
final class CoffeeShopAppUITests: XCTestCase {
var app: XCUIApplication!

override func setUpWithError() throws {
continueAfterFailure = false
app = XCUIApplication()
app.launch()
}

override func tearDownWithError() throws {
app = nil
}
}

Let’s create our first UI Tests by checking that all the tab bar components exist.

XCUIElement (including XCUIApplication) implements XCUIElementTypeQueryProvider, which gives us access to ready-to-use queries to access the app's elements.

Here is the full list with all the queries available

Once we have our elements (for example buttons) using a query, we can access a specific one by using a subscript syntax with its accessibility identifier.

To check if the elements exist, we can use XCUIElement's exists property.

func testTabBarComponents() {
let tabBar = app.tabBars["Tab Bar"]
XCTAssert(tabBar.buttons["Home"].exists)
XCTAssert(tabBar.buttons["Data"].exists)
XCTAssert(tabBar.buttons["Settings"].exists)
}

Labels, Buttons, and Switches use titles as accessibility identifiers out-of-the-box

Now let’s create a test for the flow of placing a new order.

We’ll be using custom accessibility identifiers, so let’s create a utility Struct for this first.

struct Identifiers {
struct MenuItem {
static let LATTE = "Latte"
static let CROISSANT = "Croissant"
static let MUFFIN = "Muffin"
}

struct Buttons {
static let ADD_ITEM = "Add Item"
static let CHECKOUT = "Checkout"
static let PLACE_ORDER = "Place Order"
}

struct Steppers {
static let QUANTITY = "Quantity"
static let INCREMENT = "Quantity-Increment"
}
}

Now, we need to assign these identifiers in our views using accessibilityIdentifier modifier. For example:

Button("View my order & checkout") {
router.navigateTo(.confirmation(order))
}
.accessibilityIdentifier(Identifiers.Buttons.CHECKOUT)

Finally, we create our test:

func testPlaceOrder() {
// Step 1
let scrollViewsQuery = app.scrollViews
scrollViewsQuery.images[Identifiers.MenuItem.LATTE].tap()
app.buttons[Identifiers.Buttons.ADD_ITEM].tap()
scrollViewsQuery.images[Identifiers.MenuItem.CROISSANT].tap()
app.steppers[Identifiers.Steppers.QUANTITY].buttons[Identifiers.Steppers.INCREMENT].tap()
app.buttons[Identifiers.Buttons.ADD_ITEM].tap()

// Step 2
swipes = 0
while !app.buttons[Identifiers.Buttons.CHECKOUT].isHittable && swipes < maxNumberOfSwipes {
app.swipeUp()
swipes += 1
}
app.buttons[Identifiers.Buttons.CHECKOUT].tap()

// Step 3
app.buttons[Identifiers.Buttons.PLACE_ORDER].tap()

// Step 4
let alertButton = app.alerts.buttons["Ok"]
if alertButton.waitForExistence(timeout: 2) {
alertButton.tap()
}

// Step 5
XCTAssert(app.buttons["MapButton"].exists)
}

Here is what happens:

  • Step 1 → Access the list and tap on the image that matches Latte, this will open the bottom sheet. Then we tap on the Add Item button. We repeat this for Croissant.
  • Step 2 → We perform a swipe gesture until Checkout button is hittable or until we’ve swiped five times (otherwise the test will fail). Then we tap on the button.
  • Step 3 → We tap on Place Order button.
  • Step 4 → We wait for 2” and check if the alert’s Ok button exists. If yes, then we tap it.
  • Step 5 → Finally, we check the existence of some UI element that is present in the home view. This way, we know that the navigation was successful from the checkout view to the home view.

LunchArguments & LunchEnvironments

Sometimes we’ll need to access some information related to the tests from our app’s code to perform certain actions. XCUIApplication has two properties for this:

  • LunchArguments: an array of Strings
  • LunchEnvironments: a key-value dictionary.

Let’s see a couple of examples.

You might want to reset your app’s state every time a test runs. You could add a launch argument indicating that UI Tests are running, and then read that value from your app’s delegate using CommandLine enum.

final class CoffeeShopAppUITests: XCTestCase {

var app: XCUIApplication!
let maxNumberOfSwipes = 5

override func setUpWithError() throws {
continueAfterFailure = false
app = XCUIApplication()
app.launchArguments.append["-ui-test"]
app.launch()
}
}

class AppDelegate: NSObject, UIApplicationDelegate {

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
#if DEBUG
if CommandLine.arguments.contains("-ui-test") {
resetToDefaultState()
}
#endif
return true
}

func resetToDefaultState() {...}
}

If your app uses a backend API, maybe it would be a good idea to set an alternative API URL for UI Tests purposes.

We read launchEnvironments using ProcessInfo.

final class CoffeeShopAppUITests: XCTestCase {

var app: XCUIApplication!
let maxNumberOfSwipes = 5

override func setUpWithError() throws {
continueAfterFailure = false
app = XCUIApplication()
app.launchArguments.append["-ui-test"]
app.launchEnvironment["-ui-test-apiURL"] = "https://myapi.uitest.com"
app.launch()
}
}

let apiURL = ProcessInfo.processInfo.environment["-ui-test-apiURL"]

Recording

UI Tests could be tricky at first. If you don’t know how to start, Xcode has a recording feature that will auto-generate the code for you. You just need to use that app and make the steps you want to test.

It will give you a starting point, but you might need to adjust the auto-generated code.

Image by Author

When is it a good idea to adopt UI Tests?

Although you might be tempted to start adding UI Tests to your apps, I don’t think it’s always a good fit.

How do you know if UI Tests will be beneficial?

  • Your application is stable enough with a well-defined UI. This means that the flows that you’re going to test will rarely change. Testing an unstable UI will lead to constant maintenance, which defeats the purpose of automation.
  • Your application has critical user flows, such as login or checkout processes. UI Tests will help to ensure that those flows remain intact despite changes or updates.
  • You have a set of regression tests that run on every build.
  • Your app targets multiple platforms.

UI Tests are expensive to run, they take time. So, try to test most of your code logic with Unit Tests, and leave your most critical user’s flow for UI Tests.

Have any questions? Feel free to drop me a message! 🙂

  • 🤓 Join me on X for regular content on iOS development tips and insights
  • 🚀 Check out my GitHub where I share all my example projects

--

--

Bruno Lorenzo

Software Engineer | Innovation Manager at www.houlak.com | Former iOS Tech Lead | I write about iOS, tech, and producitivy