Swift and SwiftUI tutorials for Swift Developers

How to test SwiftUI Views

In the UIKit era, testing the User Interface (UI) was a task many iOS developers avoided. UI Tests were slow, brittle, and hard to maintain. However, with the arrival of modern Swift programming and the declarative paradigm, the rules have changed.

Testing SwiftUI views is not just possible; it is essential to ensure the stability of applications on iOS, macOS, and watchOS. Unlike the monolithic ViewControllers of the past, SwiftUI views are lightweight and state-dependent. This opens two paths for us: testing logic (Unit Testing) and testing visual integration (UI Testing).

In this tutorial, you will learn to master both worlds using Xcode. We will transform your workflow so you can stop “testing with your finger” and start automating your software quality.


The SwiftUI Dilemma: What Are We Really Testing?

Before writing code, we must understand the architecture. In SwiftUI, the view is a function of state (`View = f(State)`). Since views are value types (`structs`) and not reference objects, you cannot simply instantiate a view in a unit test and check if a label has certain text, because the view does not “render” in traditional unit tests.

Therefore, the strategy for a senior iOS developer is divided into two parts:

  1. ViewModel Unit Testing: You verify that business logic changes the state correctly.
  2. UI Testing (XCUITest): You verify that, given a state, the screen shows the correct elements and interacts well.

Part 1: Preparing the View for Testing

The secret to robust UI tests in Swift lies not in the test code, but in the view code. We need a way to identify elements on the screen that is resistant to design or language changes. For this, we use accessibilityIdentifier.

Let’s imagine a simple Login screen. We will add identifiers that XCUITest can find.

import SwiftUI

struct LoginView: View {
    @StateObject private var viewModel = LoginViewModel()
    
    var body: some View {
        VStack(spacing: 20) {
            TextField("Username", text: $viewModel.username)
                .accessibilityIdentifier("usernameField") // Key for the test
                .textFieldStyle(.roundedBorder)
            
            SecureField("Password", text: $viewModel.password)
                .accessibilityIdentifier("passwordField")
                .textFieldStyle(.roundedBorder)
            
            if let error = viewModel.errorMessage {
                Text(error)
                    .foregroundColor(.red)
                    .accessibilityIdentifier("errorText")
            }
            
            Button("Login") {
                viewModel.login()
            }
            .accessibilityIdentifier("loginButton")
            .disabled(viewModel.isLoading)
        }
        .padding()
    }
}

By adding .accessibilityIdentifier("string"), we create a hook that is invisible to the user but visible to the Xcode testing robot.


Part 2: UI Testing with XCUITest

XCUITest is Apple’s native framework. It works like a “black box”: the application launches for real, and the test interacts with it as if it were a human user.

Test Case Setup

Create a new “UI Testing Bundle” target in Xcode if you haven’t already. Then, create a test file. The first step is to ensure the app launches clean.

import XCTest

final class LoginUITests: XCTestCase {
    
    var app: XCUIApplication!

    override func setUpWithError() throws {
        // In UI Tests, it is vital to handle failures immediately
        continueAfterFailure = false
        
        app = XCUIApplication()
        app.launchArguments = ["-isRunningUITests"] // Trick to mock data
        app.launch()
    }

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

Writing Your First Interaction Test

Now we are going to write a test that types into the fields and presses the button. This is where identifiers shine when testing SwiftUI views.

func testLoginSuccess() throws {
    // 1. Locate elements
    let usernameField = app.textFields["usernameField"]
    let passwordField = app.secureTextFields["passwordField"]
    let loginButton = app.buttons["loginButton"]
    
    // 2. Verify existence (Sanity Check)
    XCTAssertTrue(usernameField.exists, "The username field should exist")
    XCTAssertTrue(loginButton.exists, "The login button should exist")
    
    // 3. Interact
    usernameField.tap()
    usernameField.typeText("iOSDeveloper")
    
    passwordField.tap()
    passwordField.typeText("SwiftUI123")
    
    loginButton.tap()
    
    // 4. Assert the result (We expect to navigate Home)
    let welcomeMessage = app.staticTexts["welcomeText"]
    
    // IMPORTANT: In UI Tests, things take time to appear.
    // We use waitForExistence
    XCTAssertTrue(welcomeMessage.waitForExistence(timeout: 5.0), "We should see the welcome message after login")
}

Pro Tip: Using waitForExistence(timeout:) is crucial. SwiftUI animations take time. If you use XCTAssertTrue(welcomeMessage.exists) immediately after the tap, the test will fail because the animation hasn’t finished.


Part 3: The Page Object Pattern

If you write many tests like this, your code will become unmanageable. If you change one identifier, you will have to fix 20 tests. To avoid this, Swift programming experts use the Page Object pattern.

We create a helper class that represents the screen.

import XCTest

class LoginPage {
    let app: XCUIApplication
    
    init(app: XCUIApplication) {
        self.app = app
    }
    
    // Elements
    var usernameField: XCUIElement { app.textFields["usernameField"] }
    var passwordField: XCUIElement { app.secureTextFields["passwordField"] }
    var loginButton: XCUIElement { app.buttons["loginButton"] }
    
    // Actions
    @discardableResult
    func login(user: String, pass: String) -> Self {
        usernameField.tap()
        usernameField.typeText(user)
        passwordField.tap()
        passwordField.typeText(pass)
        loginButton.tap()
        return self
    }
}

Now your test is readable and elegant:

func testLoginWithPageObject() {
    let loginPage = LoginPage(app: app)
    
    loginPage.login(user: "User", pass: "Pass")
    
    XCTAssertTrue(app.staticTexts["welcomeText"].waitForExistence(timeout: 5))
}

Part 4: Logic Unit Testing (ViewModel)

Although the article focuses on views, you cannot ignore the engine. SwiftUI encourages MVVM. Testing the ViewModel is faster (milliseconds vs. seconds) than UI Tests.

Suppose your view shows an error if the password is too short. You don’t need to launch the simulator to test that.

import XCTest
@testable import MyAppSwiftUI

final class LoginViewModelTests: XCTestCase {
    
    func testPasswordValidationTooShort() {
        // Given
        let viewModel = LoginViewModel()
        
        // When
        viewModel.username = "Dev"
        viewModel.password = "123" // Too short
        viewModel.login()
        
        // Then
        XCTAssertNotNil(viewModel.errorMessage)
        XCTAssertEqual(viewModel.errorMessage, "Password is too short")
    }
}

If this test passes, and you have a simple UI Test verifying that “If `errorMessage` is not nil, a red Text appears”, then you have covered the full cycle without excessive redundancy.


Part 5: Mocking Data for Views

One of the biggest challenges when testing SwiftUI views is the network. You don’t want your tests to fail because there is no internet. You must “trick” the app.

We use launchArguments in Xcode. When the app starts, it checks if it has that argument and loads a fake data service (Mock).

In your App code (Production):

class DataServiceFactory {
    static func create() -> DataProtocol {
        if ProcessInfo.processInfo.arguments.contains("-isRunningUITests") {
            return MockDataService() // Returns fixed instant data
        } else {
            return RealNetworkService()
        }
    }
}

Part 6: Accessibility and Rendering Tests

For an iOS developer, accessibility is not optional. XCUITest allows you to audit this automatically.

func testAccessibility() {
    // Scans the current view hierarchy for issues
    // like buttons without labels or low contrast.
    try? app.performAccessibilityAudit()
}

Snapshot Testing (Honorable Mention)

Sometimes you want to ensure that no pixel has changed. Although XCUITest doesn’t do this natively well, libraries like SnapshotTesting (by Point-Free) are industry standards. They render the SwiftUI view into an image and compare it with a reference saved on disk.


Considerations for macOS and watchOS

SwiftUI is cross-platform, and your tests are too.

  • macOS: Interactions are different. There is no “tap”, there is “click”. XCUITest handles this, but make sure to use .click() instead of .tap() if you share test code, or create extensions that abstract the action.
  • watchOS: Space is limited. UI tests on the watch are slower. Here it is vital to prioritize ViewModel Unit Tests over extensive UI Tests.

Advanced Tricks in Xcode

  1. Recording Tests: At the bottom of the code editor in a UI Test file, there is a red button (Record). If you press it and use the simulator, Xcode will write the Swift code for you. It’s great for starting, although generated code usually needs cleaning.
  2. Async Waits: Sometimes waitForExistence is not enough. You can use XCTNSPredicateExpectation to wait for complex conditions, like a button becoming enabled after a network validation.

Conclusion

Testing SwiftUI views requires a mindset shift. We no longer seek to access the internal properties of the view (label.text), but rather we test external behavior (what the user sees) via XCUITest and internal behavior (state) via Unit Tests.

As an iOS developer, integrating these tests into your workflow in Xcode will give you unwavering confidence to refactor and improve your application. Don’t wait for a user to report a bug; find it yourself first with a test.

If you have any questions about this article, please contact me and I will be happy to help you 🙂. You can contact me on my X profile or on my Instagram profile.

Leave a Reply

Your email address will not be published. Required fields are marked *

Previous Article

How to show SwiftUI Preview in Xcode

Next Article

Swift Charts with SwiftUI

Related Posts