Files
gh-glittercowboy-taches-cc-…/skills/expertise/iphone-apps/references/testing.md
2025-11-29 18:28:37 +08:00

11 KiB

Testing

Unit tests, UI tests, snapshot tests, and testing patterns for iOS apps.

Swift Testing (Xcode 16+)

Basic Tests

import Testing
@testable import MyApp

@Suite("Item Tests")
struct ItemTests {
    @Test("Create item with name")
    func createItem() {
        let item = Item(name: "Test")
        #expect(item.name == "Test")
        #expect(item.isCompleted == false)
    }

    @Test("Toggle completion")
    func toggleCompletion() {
        var item = Item(name: "Test")
        item.isCompleted = true
        #expect(item.isCompleted == true)
    }
}

Async Tests

@Test("Fetch items from network")
func fetchItems() async throws {
    let service = MockNetworkService()
    service.mockResult = [Item(name: "Test")]

    let viewModel = ItemListViewModel(networkService: service)
    await viewModel.load()

    #expect(viewModel.items.count == 1)
    #expect(viewModel.items[0].name == "Test")
}

@Test("Handle network error")
func handleNetworkError() async {
    let service = MockNetworkService()
    service.mockError = NetworkError.noConnection

    let viewModel = ItemListViewModel(networkService: service)
    await viewModel.load()

    #expect(viewModel.items.isEmpty)
    #expect(viewModel.error != nil)
}

Parameterized Tests

@Test("Validate email", arguments: [
    ("test@example.com", true),
    ("invalid", false),
    ("@example.com", false),
    ("test@", false)
])
func validateEmail(email: String, expected: Bool) {
    let isValid = EmailValidator.isValid(email)
    #expect(isValid == expected)
}

Test Lifecycle

@Suite("Database Tests")
struct DatabaseTests {
    let database: TestDatabase

    init() async throws {
        database = try await TestDatabase.create()
    }

    @Test func insertItem() async throws {
        try await database.insert(Item(name: "Test"))
        let items = try await database.fetchAll()
        #expect(items.count == 1)
    }
}

XCTest (Traditional)

Basic XCTest

import XCTest
@testable import MyApp

class ItemTests: XCTestCase {
    var sut: Item!

    override func setUp() {
        super.setUp()
        sut = Item(name: "Test")
    }

    override func tearDown() {
        sut = nil
        super.tearDown()
    }

    func testCreateItem() {
        XCTAssertEqual(sut.name, "Test")
        XCTAssertFalse(sut.isCompleted)
    }

    func testToggleCompletion() {
        sut.isCompleted = true
        XCTAssertTrue(sut.isCompleted)
    }
}

Async XCTest

func testFetchItems() async throws {
    let service = MockNetworkService()
    service.mockResult = [Item(name: "Test")]

    let viewModel = ItemListViewModel(networkService: service)
    await viewModel.load()

    XCTAssertEqual(viewModel.items.count, 1)
}

Mocking

Protocol-Based Mocks

// Protocol
protocol NetworkServiceProtocol {
    func fetch<T: Decodable>(_ endpoint: Endpoint) async throws -> T
}

// Mock
class MockNetworkService: NetworkServiceProtocol {
    var mockResult: Any?
    var mockError: Error?
    var fetchCallCount = 0

    func fetch<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
        fetchCallCount += 1

        if let error = mockError {
            throw error
        }

        guard let result = mockResult as? T else {
            fatalError("Mock result type mismatch")
        }

        return result
    }
}

Testing with Mocks

@Test func loadItemsCallsNetwork() async {
    let mock = MockNetworkService()
    mock.mockResult = [Item]()

    let viewModel = ItemListViewModel(networkService: mock)
    await viewModel.load()

    #expect(mock.fetchCallCount == 1)
}

Testing SwiftUI Views

View Tests with ViewInspector

import ViewInspector
@testable import MyApp

@Test func itemRowDisplaysName() throws {
    let item = Item(name: "Test Item")
    let view = ItemRow(item: item)

    let text = try view.inspect().hStack().text(0).string()
    #expect(text == "Test Item")
}

Testing View Models

@Test func viewModelUpdatesOnSelection() async {
    let viewModel = ItemListViewModel()
    viewModel.items = [Item(name: "A"), Item(name: "B")]

    viewModel.select(viewModel.items[0])

    #expect(viewModel.selectedItem?.name == "A")
}

UI Testing

Basic UI Test

import XCTest

class MyAppUITests: XCTestCase {
    let app = XCUIApplication()

    override func setUpWithError() throws {
        continueAfterFailure = false
        app.launchArguments = ["--uitesting"]
        app.launch()
    }

    func testAddItem() {
        // Tap add button
        app.buttons["Add"].tap()

        // Enter name
        let textField = app.textFields["Item name"]
        textField.tap()
        textField.typeText("New Item")

        // Save
        app.buttons["Save"].tap()

        // Verify
        XCTAssertTrue(app.staticTexts["New Item"].exists)
    }

    func testSwipeToDelete() {
        // Assume item exists
        let cell = app.cells["Item Row"].firstMatch

        // Swipe and delete
        cell.swipeLeft()
        app.buttons["Delete"].tap()

        // Verify
        XCTAssertFalse(cell.exists)
    }
}

Accessibility Identifiers

struct ItemRow: View {
    let item: Item

    var body: some View {
        HStack {
            Text(item.name)
        }
        .accessibilityIdentifier("Item Row")
    }
}

struct NewItemView: View {
    @State private var name = ""

    var body: some View {
        TextField("Item name", text: $name)
            .accessibilityIdentifier("Item name")
    }
}

Launch Arguments for Testing

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .onAppear {
                    if CommandLine.arguments.contains("--uitesting") {
                        // Use mock data
                        // Skip onboarding
                        // Clear state
                    }
                }
        }
    }
}

Snapshot Testing

Using swift-snapshot-testing:

import SnapshotTesting
import XCTest
@testable import MyApp

class SnapshotTests: XCTestCase {
    func testItemRow() {
        let item = Item(name: "Test", subtitle: "Subtitle")
        let view = ItemRow(item: item)
            .frame(width: 375)

        assertSnapshot(of: view, as: .image)
    }

    func testItemRowDarkMode() {
        let item = Item(name: "Test", subtitle: "Subtitle")
        let view = ItemRow(item: item)
            .frame(width: 375)
            .preferredColorScheme(.dark)

        assertSnapshot(of: view, as: .image, named: "dark")
    }

    func testItemRowLargeText() {
        let item = Item(name: "Test", subtitle: "Subtitle")
        let view = ItemRow(item: item)
            .frame(width: 375)
            .environment(\.sizeCategory, .accessibilityExtraLarge)

        assertSnapshot(of: view, as: .image, named: "large-text")
    }
}

Testing SwiftData

@Suite("SwiftData Tests")
struct SwiftDataTests {
    @Test func insertAndFetch() async throws {
        // In-memory container for testing
        let config = ModelConfiguration(isStoredInMemoryOnly: true)
        let container = try ModelContainer(for: Item.self, configurations: config)
        let context = container.mainContext

        // Insert
        let item = Item(name: "Test")
        context.insert(item)
        try context.save()

        // Fetch
        let descriptor = FetchDescriptor<Item>()
        let items = try context.fetch(descriptor)

        #expect(items.count == 1)
        #expect(items[0].name == "Test")
    }
}

Testing Network Calls

Using URLProtocol

class MockURLProtocol: URLProtocol {
    static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))?

    override class func canInit(with request: URLRequest) -> Bool {
        return true
    }

    override class func canonicalRequest(for request: URLRequest) -> URLRequest {
        return request
    }

    override func startLoading() {
        guard let handler = MockURLProtocol.requestHandler else {
            fatalError("Handler not set")
        }

        do {
            let (response, data) = try handler(request)
            client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
            client?.urlProtocol(self, didLoad: data)
            client?.urlProtocolDidFinishLoading(self)
        } catch {
            client?.urlProtocol(self, didFailWithError: error)
        }
    }

    override func stopLoading() {}
}

@Test func fetchItemsReturnsData() async throws {
    // Configure mock
    let config = URLSessionConfiguration.ephemeral
    config.protocolClasses = [MockURLProtocol.self]
    let session = URLSession(configuration: config)

    let mockItems = [Item(name: "Test")]
    let mockData = try JSONEncoder().encode(mockItems)

    MockURLProtocol.requestHandler = { request in
        let response = HTTPURLResponse(
            url: request.url!,
            statusCode: 200,
            httpVersion: nil,
            headerFields: nil
        )!
        return (response, mockData)
    }

    // Test
    let service = NetworkService(session: session)
    let items: [Item] = try await service.fetch(.items)

    #expect(items.count == 1)
}

Test Helpers

Factory Methods

extension Item {
    static func sample(
        name: String = "Sample",
        isCompleted: Bool = false,
        priority: Int = 0
    ) -> Item {
        Item(name: name, isCompleted: isCompleted, priority: priority)
    }

    static var samples: [Item] {
        [
            .sample(name: "First"),
            .sample(name: "Second", isCompleted: true),
            .sample(name: "Third", priority: 5)
        ]
    }
}

Async Test Utilities

func waitForCondition(
    timeout: TimeInterval = 1.0,
    condition: @escaping () -> Bool
) async throws {
    let start = Date()
    while !condition() {
        if Date().timeIntervalSince(start) > timeout {
            throw TestError.timeout
        }
        try await Task.sleep(nanoseconds: 10_000_000) // 10ms
    }
}

enum TestError: Error {
    case timeout
}

Running Tests from CLI

# Run all tests
xcodebuild test \
    -project MyApp.xcodeproj \
    -scheme MyApp \
    -destination 'platform=iOS Simulator,name=iPhone 16'

# Run specific test
xcodebuild test \
    -project MyApp.xcodeproj \
    -scheme MyApp \
    -destination 'platform=iOS Simulator,name=iPhone 16' \
    -only-testing:MyAppTests/ItemTests

# With code coverage
xcodebuild test \
    -project MyApp.xcodeproj \
    -scheme MyApp \
    -destination 'platform=iOS Simulator,name=iPhone 16' \
    -enableCodeCoverage YES \
    -resultBundlePath TestResults.xcresult

Best Practices

Test Naming

// Describe what is being tested and expected outcome
@Test func itemListViewModel_load_setsItemsFromNetwork()
@Test func purchaseService_purchaseProduct_updatesEntitlements()

Arrange-Act-Assert

@Test func toggleCompletion() {
    // Arrange
    var item = Item(name: "Test")

    // Act
    item.isCompleted.toggle()

    // Assert
    #expect(item.isCompleted == true)
}

One Assertion Per Test

Focus each test on a single behavior:

// Good
@Test func loadSetsItems() async { ... }
@Test func loadSetsLoadingFalse() async { ... }
@Test func loadClearsError() async { ... }

// Avoid
@Test func loadWorks() async {
    // Too many assertions
}