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
- New iOS App → SwiftUI → name
LoginUITestLab. - Add a UI Testing Bundle target:
File → New → Target → UI Testing Bundle. - 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
- Select the UI test scheme.
- Cmd+U.
- Watch the simulator dance through four tests in under 30 seconds (animations off).
- If a test fails, screenshots are auto-attached to the test report — open the Report Navigator.
Stretch
- Screenshot on failure — add an
override func tearDownWithErrorthat attachesapp.screenshot()whenever a test fails. - Parallelize — enable parallel testing in the scheme; verify tests still pass with 4 cloned simulators.
- Localization smoke — set
app.launchArguments += ["-AppleLanguages", "(de)"]and verify the accessibility identifiers still find elements (they should — identifiers are not localized). - Network failure — add a
StubAuthErrormode that triggersURLError(.timedOut)and assert the error UI. - Dark mode — pass
-AppleInterfaceStyle Darkand 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. StubURLProtocolonly interceptsapi.example.com. Image CDNs, analytics, etc. still talk to the real internet — for full hermetic tests, broadencanInit.SecureFieldshows assecureTextFields(nottextFields) in the accessibility tree. Easy to miss.app.terminate()between tests is not needed — XCTest launches a fresh app per test by default.