ios-testing

iOS Testing Standards

These rules apply when working with Swift test files.

TDD Requirement

All changes MUST follow Test-Driven Development:

  1. Write a failing test first
  2. Write minimal code to make it pass
  3. Refactor while keeping tests green
  4. No code changes without corresponding tests

Framework: Swift Testing (NOT XCTest)

MUST use Swift Testing for all new tests:

Three Testing Layers

Layer What How
Pure functions Correctness Direct unit tests
ViewModels Outcomes Fakes wired up, verify state and captured data
Framework wrappers Integration Separate concern, integration tests or manual

Fakes Over Mocks (Critical)

Use fakes that you control, not mocks that verify interactions.

Example fake from codebase:

class LocalFunctions: RekoDayFunctions {
    var result: [String: Any]? = nil
    var calledParams: [String: Any] = [:]
    var calledFunc: String = ""
    var error: Error? = nil

    func callLambdaNamed(_ namedFunction: String,
                         withParameters params: [String: Any]) async throws -> [String: Any]? {
        if let e = error { throw e }
        self.calledFunc = namedFunction
        self.calledParams = params
        return result
    }
}

Control what it returns, assert on outcomes.

Test Outcomes, Not Interactions

Fragile - tests implementation details:

func testSaveReko_callsProviderThenSendsPromotion() {
    await viewModel.saveReko(user: user, rekoCount: 5)
    XCTAssertTrue(mockProvider.createRekoCalled)  // WRONG
}

Better - tests observable outcomes:

@Test("saveReko returns true when valid")
func saveRekoReturnsTrue() async {
    let result = await viewModel.saveReko(user: user, rekoCount: 5)
    #expect(result == true)
    #expect(fakeProvider.createdReko?.name == "Test Reko")
}

@Test("saveReko returns false when address lookup fails")
func saveRekoFailsOnAddressError() async {
    fakeAddressProvider.shouldFail = true
    let result = await viewModel.saveReko(user: user, rekoCount: 5)
    #expect(result == false)
}

Test File Structure

import Testing
@testable import Reko_Day_Dev

@Suite("Feature Name Tests")
@MainActor  // Add if testing UI code
struct FeatureTests {
    let provider: DataProvider

    init() {
        setupFirebase()
        DataProvider.operatingMode = .local
        provider = DataProvider(mode: .local)
    }

    @Test("Descriptive test name in sentence case")
    func testSomething() async throws {
        // Given: Setup
        let viewModel = SomeViewModel(provider: provider)

        // When: Action
        await viewModel.doSomething()

        // Then: Assertion
        #expect(viewModel.result == expectedValue)
    }
}

Running Tests

cd iOS

# Run all tests
./run-tests.sh

# Run specific test class (TDD workflow)
./run-tests.sh -t ShoppingCartTests

# Debug with full output
./run-tests.sh -f

Test Coverage Targets

Component Target Current
ViewModels 85%+ ~60%
Providers 90%+ ~40%
Models 95%+ ~30%

Requirements for New Code

File Naming


UI Testing

Philosophy

UI tests should be the smallest layer. They're slow, flaky, and expensive.

Goal is confidence at critical paths, not coverage.

What to test (5-10 tests total, not 50):

Setup with Launch Arguments

enum LaunchArgument: String {
    case uiTesting = "--uitesting"
    case mockAuth = "--mock-auth"
    case useEmulator = "--use-emulator"
}

// In AppDelegate or App init
func configureForTesting() {
    let args = ProcessInfo.processInfo.arguments

    if args.contains(LaunchArgument.uiTesting.rawValue) {
        DataProvider.operatingMode = .emulator
    }

    if args.contains(LaunchArgument.mockAuth.rawValue) {
        AuthProvider.shared = MockAuthProvider(user: .testUser)
    }
}

Firebase Emulator Configuration

if args.contains(LaunchArgument.useEmulator.rawValue) {
    Auth.auth().useEmulator(withHost: "localhost", port: 9099)
    Firestore.firestore().useEmulator(withHost: "localhost", port: 8080)
    Functions.functions().useEmulator(withHost: "localhost", port: 5001)
}

Avoiding Flakiness

Wait for elements, not timers:

let button = app.buttons["Pay Now"]
XCTAssertTrue(button.waitForExistence(timeout: 5))

Use accessibility identifiers:

// In View
Button("Pay Now")
    .accessibilityIdentifier("cart.payNow")

// In test
app.buttons["cart.payNow"].tap()

Disable animations:

if args.contains(LaunchArgument.uiTesting.rawValue) {
    UIView.setAnimationsEnabled(false)
}

Suggested Starting Tests

  1. Fresh user can sign up
  2. Existing user can sign in
  3. User can add item to cart and complete purchase
  4. User can create a Reko

Four tests. If those pass, the app fundamentally works.