Lab 8.2 — UI Testing Login Flow

Goal: write a full XCUITest suite for a SwiftUI login screen. Cover happy path, validation errors, and server errors. Wire the app for testability via launch arguments and a stubbed network layer.

Time: 60–90 minutes.

Prereqs: Xcode 16+, completed Lab 8.1 (or comfort with URLProtocol mocking).

Setup

  1. New iOS App → SwiftUI → name LoginUITestLab.
  2. Add a UI Testing Bundle target: File → New → Target → UI Testing Bundle.
  3. Create the login screen described below.

The login screen (production code)

LoginView.swift:

import SwiftUI

@MainActor
@Observable
final class LoginViewModel {
    var email = ""
    var password = ""
    var isLoading = false
    var error: String?
    var isAuthenticated = false

    private let api: AuthAPI
    init(api: AuthAPI = LiveAuthAPI()) { self.api = api }

    var canSubmit: Bool {
        !isLoading && email.contains("@") && password.count >= 8
    }

    func submit() async {
        isLoading = true; defer { isLoading = false }
        error = nil
        do {
            try await api.signIn(email: email, password: password)
            isAuthenticated = true
        } catch let err as AuthError {
            error = err.message
        } catch {
            error = "Unexpected error"
        }
    }
}

struct LoginView: View {
    @State private var vm = LoginViewModel()

    var body: some View {
        NavigationStack {
            Form {
                Section {
                    TextField("Email", text: $vm.email)
                        .textContentType(.emailAddress)
                        .keyboardType(.emailAddress)
                        .autocapitalization(.none)
                        .accessibilityIdentifier("signIn.email")

                    SecureField("Password", text: $vm.password)
                        .textContentType(.password)
                        .accessibilityIdentifier("signIn.password")
                }
                if let error = vm.error {
                    Section {
                        Text(error)
                            .foregroundStyle(.red)
                            .accessibilityIdentifier("signIn.error")
                    }
                }
                Button {
                    Task { await vm.submit() }
                } label: {
                    if vm.isLoading {
                        ProgressView()
                    } else {
                        Text("Sign In")
                    }
                }
                .disabled(!vm.canSubmit)
                .accessibilityIdentifier("signIn.submit")
            }
            .navigationTitle("Sign In")
            .navigationDestination(isPresented: $vm.isAuthenticated) {
                Text("Welcome")
                    .accessibilityIdentifier("home.title")
            }
        }
    }
}

AuthAPI.swift:

import Foundation

protocol AuthAPI: Sendable {
    func signIn(email: String, password: String) async throws
}

struct AuthError: Error { let message: String }

struct LiveAuthAPI: AuthAPI {
    func signIn(email: String, password: String) async throws {
        let url = URL(string: "https://api.example.com/auth/signin")!
        var req = URLRequest(url: url)
        req.httpMethod = "POST"
        req.setValue("application/json", forHTTPHeaderField: "Content-Type")
        req.httpBody = try JSONEncoder().encode(["email": email, "password": password])
        let (_, resp) = try await URLSession.shared.data(for: req)
        guard let http = resp as? HTTPURLResponse, http.statusCode == 200 else {
            throw AuthError(message: "Invalid credentials")
        }
    }
}

Wire the app for test mode

LoginUITestLabApp.swift:

import SwiftUI

@main
struct LoginUITestLabApp: App {
    init() {
        if CommandLine.arguments.contains("-UITestMode") {
            UIView.setAnimationsEnabled(false)
            URLProtocol.registerClass(StubURLProtocol.self)
            StubURLProtocol.configure(from: CommandLine.arguments)
        }
    }

    var body: some Scene {
        WindowGroup { LoginView() }
    }
}

StubURLProtocol.swift (in the app target so it can be exercised at runtime):

import Foundation

final class StubURLProtocol: URLProtocol {
    nonisolated(unsafe) static var stubStatus: Int = 200
    nonisolated(unsafe) static var stubBody: Data = Data()

    static func configure(from args: [String]) {
        if let idx = args.firstIndex(of: "-StubAuthStatus"),
           idx + 1 < args.count, let s = Int(args[idx + 1]) {
            stubStatus = s
        }
    }

    override class func canInit(with request: URLRequest) -> Bool {
        request.url?.host == "api.example.com"
    }
    override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
    override func startLoading() {
        let response = HTTPURLResponse(url: request.url!,
                                       statusCode: Self.stubStatus,
                                       httpVersion: nil, headerFields: nil)!
        client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
        client?.urlProtocol(self, didLoad: Self.stubBody)
        client?.urlProtocolDidFinishLoading(self)
    }
    override func stopLoading() {}
}

Write the UI tests

LoginUITestLabUITests/LoginUITests.swift:

import XCTest

final class LoginUITests: XCTestCase {
    var app: XCUIApplication!

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

    private func launch(status: Int = 200) {
        app.launchArguments = ["-UITestMode", "-StubAuthStatus", String(status)]
        app.launch()
    }

    func test_emptyForm_submitDisabled() {
        launch()
        let submit = app.buttons["signIn.submit"]
        XCTAssertTrue(submit.waitForExistence(timeout: 2))
        XCTAssertFalse(submit.isEnabled)
    }

    func test_invalidEmail_keepsSubmitDisabled() {
        launch()
        let email = app.textFields["signIn.email"]
        email.tap(); email.typeText("not-an-email")

        let pw = app.secureTextFields["signIn.password"]
        pw.tap(); pw.typeText("longenough")

        XCTAssertFalse(app.buttons["signIn.submit"].isEnabled)
    }

    func test_validCredentials_navigatesHome() {
        launch(status: 200)
        signIn(email: "ada@example.com", password: "longenough")

        let home = app.staticTexts["home.title"]
        XCTAssertTrue(home.waitForExistence(timeout: 3))
    }

    func test_invalidCredentials_showsError() {
        launch(status: 401)
        signIn(email: "ada@example.com", password: "longenough")

        let error = app.staticTexts["signIn.error"]
        XCTAssertTrue(error.waitForExistence(timeout: 3))
    }

    private func signIn(email: String, password: String) {
        let emailField = app.textFields["signIn.email"]
        XCTAssertTrue(emailField.waitForExistence(timeout: 2))
        emailField.tap(); emailField.typeText(email)

        let pwField = app.secureTextFields["signIn.password"]
        pwField.tap(); pwField.typeText(password)

        app.buttons["signIn.submit"].tap()
    }
}

Run

  1. Select the UI test scheme.
  2. Cmd+U.
  3. Watch the simulator dance through four tests in under 30 seconds (animations off).
  4. If a test fails, screenshots are auto-attached to the test report — open the Report Navigator.

Stretch

  1. Screenshot on failure — add an override func tearDownWithError that attaches app.screenshot() whenever a test fails.
  2. Parallelize — enable parallel testing in the scheme; verify tests still pass with 4 cloned simulators.
  3. Localization smoke — set app.launchArguments += ["-AppleLanguages", "(de)"] and verify the accessibility identifiers still find elements (they should — identifiers are not localized).
  4. Network failure — add a StubAuthError mode that triggers URLError(.timedOut) and assert the error UI.
  5. Dark mode — pass -AppleInterfaceStyle Dark and re-run; assert no crashes and that the error text is still readable.

Notes

  • Test mode is opt-in via -UITestMode. Production builds and developer-launched debug builds skip the stub setup entirely.
  • StubURLProtocol only intercepts api.example.com. Image CDNs, analytics, etc. still talk to the real internet — for full hermetic tests, broaden canInit.
  • SecureField shows as secureTextFields (not textFields) in the accessibility tree. Easy to miss.
  • app.terminate() between tests is not needed — XCTest launches a fresh app per test by default.

Next: Lab 8.3 — Full Test Suite