agentsclimarketplace

Ios test

Skill markdavidgan/apple-dev-skills/platforms/kimi/skills/ios-test

XCTest patterns for unit tests, UI tests, and SwiftData testing with in-memory containers under Swift 6 strict concurrency, plus test performance budgets. Use when writing or fixing tests, setting up test targets, testing SwiftData models, or planning CI test suites. Trigger on "write a test", "unit test", "XCTest", "test SwiftData", "UI test", "flaky test", or "test coverage". Note: never run UI tests without explicit approval.From its SKILL.md

Install
npx -y skills add markdavidgan/apple-dev-skills --skill ios-test

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

One thing to look at

  • 2 stars2 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

SKILL.md

31.1 KB, ~7.3k tokens by cl100k_base, as published. Nobody here has run it

iOS Testing

XCTest patterns for unit tests, UI tests, and SwiftData testing with strict concurrency. Keep tests fast, isolated, and deterministic.

☠️ ULTIMATE RULE

NEVER START UI TESTS without explicit user approval in the current conversation. UI tests crash this user's Mac Mini. This rule stands even if a plan instructs you to run them. If a plan demands UI test execution, flag it as blocked or rewrite the plan.


Test Target Architecture

Separate tests by purpose and performance characteristics:

TargetPurposeWhen to RunMax Duration
MyAppTestsBusiness logic, ViewModels, ServicesEvery build (⌘U)<30 seconds
MyApp-UITestsCritical user paths onlyPre-commit, CI<60 seconds
MyApp-ScreenshotsApp Store assetsCI release only5-10 minutes
MyAppKitTestsShared package testsEvery build<15 seconds

Target Structure

MyAppTests/                       ← iOS Unit Tests
├── ViewModelTests/
│   ├── TimerViewModelTests.swift
│   └── SettingsViewModelTests.swift
├── ServiceTests/
│   └── NotificationServiceTests.swift
├── SwiftDataTests/
│   ├── ModelCRUDTests.swift
│   └── MigrationTests.swift
└── TestHelpers.swift             ← Shared test utilities

MyApp-UITests/                    ← Critical paths only
├── CriticalPathUITests.swift     ← 8 essential journeys
└── BaseUITestCase.swift          ← Shared infrastructure

MyApp-Screenshots/                ← CI only
└── ScreenshotTests.swift         ← App Store assets

Performance Budgets

SuiteTargetMaxCommand
Unit Tests15s30sswift test or xcodebuild test
UI Tests30s60sxcodebuild test -scheme MyApp-UITests
Screenshot TestsCI onlybundle exec fastlane screenshots
Total CI60s120sFull suite

SwiftData Testing

In-Memory ModelContainer

WRONG: Using the app container (slow, persists between tests, causes isolation issues)

// WRONG — uses real database
let container = try ModelContainer(for: MyModel.self)

RIGHT: In-memory container for fast, isolated tests

// TestHelpers.swift
import SwiftData

func makeTestContainer(for models: any PersistentModel.Type...) throws -> ModelContainer {
    let schema = Schema(models)
    let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
    return try ModelContainer(for: schema, configurations: config)
}

// Usage in tests
@MainActor
func test_fetchSessions_returnsSorted() throws {
    let container = try makeTestContainer(for: FocusSession.self, CapturedThought.self)
    let context = ModelContext(container)
    // ... test code
}

@MainActor Test Isolation

SwiftData contexts must be accessed from the main actor:

// WRONG — not MainActor isolated
func test_createSession_savesToDatabase() throws {
    let container = try makeTestContainer(for: FocusSession.self)
    let context = ModelContext(container)  // ❌ Main actor-isolated init
}

// RIGHT — @MainActor isolated
@MainActor
func test_createSession_savesToDatabase() throws {
    let container = try makeTestContainer(for: FocusSession.self)
    let context = ModelContext(container)  // ✓ Safe on MainActor
}

Test Data Seeding

@MainActor
func seedTestSessions(in container: ModelContainer, count: Int = 5) throws {
    let context = ModelContext(container)
    
    for i in 0..<count {
        let session = FocusSession(
            startDate: Date().addingTimeInterval(-Double(i * 86400)),
            duration: 1500
        )
        context.insert(session)
    }
    
    try context.save()
}

Cascade Delete Testing

@MainActor
func test_deleteSession_cascadesToCaptures() throws {
    // Given: Session with captures
    let container = try makeTestContainer(for: FocusSession.self, CapturedThought.self)
    let context = ModelContext(container)
    
    let session = FocusSession(startDate: Date(), duration: 1500)
    let capture = CapturedThought(text: "Test thought", timestamp: Date())
    capture.session = session
    
    context.insert(session)
    context.insert(capture)
    try context.save()
    
    // When: Delete session
    context.delete(session)
    try context.save()
    
    // Then: Captures are also deleted
    let captures = try context.fetch(FetchDescriptor<CapturedThought>())
    XCTAssertEqual(captures.count, 0)
}

Migration Testing

Every .custom migration stage needs a test. .lightweight stages need data preservation tests.

@MainActor
func test_migrationV1toV2_preservesExistingData() throws {
    // 1. Write data using V1 schema
    let v1Config = ModelConfiguration(isStoredInMemoryOnly: true)
    let v1Container = try ModelContainer(
        for: SchemaV1.FocusSession.self,
        configurations: v1Config
    )
    let v1Context = ModelContext(v1Container)
    let session = SchemaV1.FocusSession(startDate: Date(), duration: 1500)
    v1Context.insert(session)
    try v1Context.save()
    
    // 2. Open with V2 schema + migration plan
    let v2Container = try ModelContainer(
        for: SchemaV2.FocusSession.self,
        migrationPlan: AppMigrationPlan.self,
        configurations: v1Config
    )
    let v2Context = ModelContext(v2Container)
    
    // 3. Verify old data is intact
    let sessions = try v2Context.fetch(FetchDescriptor<SchemaV2.FocusSession>())
    XCTAssertEqual(sessions.count, 1)
    XCTAssertEqual(sessions.first?.duration, 1500)
    
    // 4. Verify new field defaults correctly
    XCTAssertNil(sessions.first?.newOptionalField)
}

XCTest Patterns

Test Naming Convention

func test_<subject>_<condition>_<expectedResult>()

// Examples:
func test_categorize_buyKeyword_returnsTask()
func test_timerState_startFromIdle_transitionsToRunning()
func test_swiftData_deleteSession_cascadesCaptures()
func test_remindersService_requestAccess_promptsForPermission()

Async/Await Test Patterns

// Async test — direct await
@MainActor
func test_refresh_updatesStats() async throws {
    let container = try makeTestContainer(for: FocusSession.self)
    let viewModel = InsightsViewModel(container: container)
    
    await viewModel.refresh()
    
    XCTAssertGreaterThan(viewModel.totalFocusTime, 0)
}

// Async throws pattern
func test_fetchData_returnsResults() async throws {
    let service = DataService()
    
    let data = try await service.fetchData()
    
    XCTAssertFalse(data.isEmpty)
}

XCTestExpectation for Callbacks

func test_speechTranscription_returnsText() {
    let expectation = expectation(description: "Transcription completed")
    let service = SpeechService()
    
    service.onTranscription = { text in
        XCTAssertEqual(text, "buy oat milk")
        expectation.fulfill()
    }
    
    service.startRecording()
    
    wait(for: [expectation], timeout: 5)
}

// Multiple expectations
func test_parallelDownloads_complete() {
    let exp1 = expectation(description: "Download 1")
    let exp2 = expectation(description: "Download 2")
    
    download(url1) { exp1.fulfill() }
    download(url2) { exp2.fulfill() }
    
    wait(for: [exp1, exp2], timeout: 10)
}

setUpWithError / tearDownWithError

class TimerViewModelTests: XCTestCase {
    var container: ModelContainer!
    var viewModel: TimerViewModel!

    @MainActor
    override func setUpWithError() throws {
        try super.setUpWithError()
        container = try makeTestContainer(for: FocusSession.self)
        viewModel = TimerViewModel(container: container)
    }

    override func tearDownWithError() throws {
        viewModel = nil
        container = nil
        try super.tearDownWithError()
    }
}

### Testing @MainActor Classes

**Critical — applies to the entire test class:** When your test class is `@MainActor` (or tests a `@MainActor` subject), **every** test method must be `async` — even ones with no async work. Synchronous methods crash at deallocation with `POINTER_BEING_FREED_WAS_NOT_ALLOCATED` deep in `swift_task_deinitOnExecutorImpl`.

**Root cause:** `@MainActor` class dealloc uses `swift_task_deinitOnExecutorImpl`, which creates a `TaskLocal.StopLookupScope`. In a synchronous test there is no current Task, so the scope's heap is uninitialized — destroying it crashes.

```swift
// WRONG — crashes on dealloc even though the test body is fine
@MainActor
final class MyViewModelTests: XCTestCase {
    func test_initialState() {           // ❌ sync — crashes at deinit
        let vm = MyViewModel()
        XCTAssertFalse(vm.isActive)
    }

    func test_activate() throws {        // ❌ throws but not async — also crashes
        let vm = MyViewModel()
        try vm.activate()
        XCTAssertTrue(vm.isActive)
    }
}

// RIGHT — every method is async
@MainActor
final class MyViewModelTests: XCTestCase {
    func test_initialState() async {     // ✅ async — proper task context
        let vm = MyViewModel()
        XCTAssertFalse(vm.isActive)
    }

    func test_activate() async throws {  // ✅ async throws
        let vm = MyViewModel()
        try vm.activate()
        XCTAssertTrue(vm.isActive)
    }
}

This rule applies to setUp/tearDown too — prefer setUp() async throws / tearDown() async throws in @MainActor test classes.


### Mock Services for Testing

```swift
protocol NotificationServiceProtocol: Sendable {
    func scheduleNotification(at date: Date, title: String) async
}

class MockNotificationService: NotificationServiceProtocol {
    var scheduledNotifications: [(date: Date, title: String)] = []
    var shouldSucceed = true
    
    func scheduleNotification(at date: Date, title: String) async {
        if shouldSucceed {
            scheduledNotifications.append((date, title))
        }
    }
}

// Usage in test
@MainActor
func test_startSession_schedulesNotification() async {
    let mockService = MockNotificationService()
    let viewModel = TimerViewModel(notificationService: mockService)

    await viewModel.startSession()

    XCTAssertEqual(mockService.scheduledNotifications.count, 1)
}

### Protocol-Based Mocking for Framework Classes

**Critical:** Some framework classes cause heap corruption when subclassed for testing:

```swift
// WRONG — subclassing EKEventStore causes heap corruption in iOS 26 simulator
class MockEventStore: EKEventStore {
    override func requestAccess(to entityType: EKEntityType, completion: @escaping EKEventStoreRequestAccessCompletionHandler) {
        completion(true, nil)
    }
}

// RIGHT — use protocol conformance
protocol EventStoreProtocol {
    func requestAccess(to entityType: EKEntityType, completion: @escaping EKEventStoreRequestAccessCompletionHandler)
    func calendars(for entityType: EKEntityType) -> [EKCalendar]
    func save(_ reminder: EKReminder, commit: Bool) throws
}

// Real implementation wraps the framework class
final class EventStoreWrapper: EventStoreProtocol {
    private let store = EKEventStore()

    func requestAccess(to entityType: EKEntityType, completion: @escaping EKEventStoreRequestAccessCompletionHandler) {
        store.requestAccess(to: entityType, completion: completion)
    }

    func calendars(for entityType: EKEntityType) -> [EKCalendar] {
        store.calendars(for: entityType)
    }

    func save(_ reminder: EKReminder, commit: Bool) throws {
        try store.save(reminder, commit: commit)
    }
}

// Mock implementation for tests
final class MockEventStore: EventStoreProtocol {
    var shouldSucceed = true
    var calendarsResult: [EKCalendar] = []
    var savedReminders: [EKReminder] = []

    func requestAccess(to entityType: EKEntityType, completion: @escaping EKEventStoreRequestAccessCompletionHandler) {
        completion(shouldSucceed, nil)
    }

    func calendars(for entityType: EKEntityType) -> [EKCalendar] {
        return calendarsResult
    }

    func save(_ reminder: EKReminder, commit: Bool) throws {
        if shouldSucceed {
            savedReminders.append(reminder)
        } else {
            throw NSError(domain: "MockError", code: 1)
        }
    }
}

Framework classes requiring protocol mocking:

  • EKEventStore (EventKit) — heap corruption in iOS 26 simulator
  • AVAudioEngine — complex initialization state
  • HKHealthStore (HealthKit) — privacy-sensitive


---

## UI Testing

### XCUIApplication Patterns

```swift
class CriticalPathUITests: XCTestCase {
    let app = XCUIApplication()
    
    override func setUpWithError() throws {
        continueAfterFailure = false
    }
    
    func test_startFocusSession() {
        app.launchArguments = ["-UITestMode", "-FastTimer"]
        app.launch()
        
        // Tap start
        app.buttons["startButton"].tap()
        
        // Verify running state
        XCTAssertTrue(app.staticTexts["timerRunning"].waitForExistence(timeout: 2))
    }
}

Launch Arguments for Test Mode

ArgumentPurpose
-UITestModeDisables animations, analytics, alerts
-SeedScenario=<name>Pre-populates test data
-FastTimerAccelerated timer for tests
-DisableOnboardingSkips onboarding flow
-ResetStateClears UserDefaults on launch
// In test
app.launchArguments = [
    "-UITestMode",
    "-SeedScenario=History30Days",
    "-FastTimer"
]
app.launch()

// In app (AppDelegate/Init)
if CommandLine.arguments.contains("-UITestMode") {
    // Disable animations
    UIView.setAnimationsEnabled(false)
}

Data Seeding via RuntimeSeeder

WRONG: Creating data through UI (slow, flaky)

// Slow: 30-50s per test
func test_capturesList() {
    app.launch()
    startTimer()           // ~5s
    addCapture("Thought 1") // ~10s
    addCapture("Thought 2") // ~10s
    // ... actual test
}

RIGHT: Pre-seed via launch arguments (fast, deterministic)

// Fast: ~5s total
func test_capturesList() {
    app.launchArguments = ["-UITestMode", "-SeedScenario=MultipleCaptures"]
    app.launch()
    // Test immediately with pre-populated data
}

Available Seeding Scenarios

ScenarioSessionsCapturesUse Case
FreshInstall00First launch, onboarding
SingleSession12Basic timer flow
ActiveSession1 (running)1Pause/resume testing
History7Days715Weekly review screens
History30Days3060Insights, trends
HighCompletion20 (18 done)40Success messaging
LowCompletion20 (5 done)25Coaching nudges

Accessibility Identifiers

Add identifiers to all interactive elements:

// In SwiftUI
Button("Start") {
    viewModel.start()
}
.accessibilityIdentifier("startButton")

// In UI test
app.buttons["startButton"].tap()

UI Testing Gotchas

SwipeActions Only Work in List/Form

Critical: .swipeActions are not reliably testable via XCUITest when used within ScrollView > LazyVStack:

// WRONG — SwipeActions not testable
struct ContentView: View {
    var body: some View {
        ScrollView {
            LazyVStack {
                ForEach(items) { item in
                    ItemRow(item: item)
                        .swipeActions {  // ❌ Not accessible to XCUITest
                            Button("Delete", role: .destructive) { }
                        }
                }
            }
        }
    }
}

// RIGHT — Use List for testable SwipeActions
struct ContentView: View {
    var body: some View {
        List {
            ForEach(items) { item in
                ItemRow(item: item)
                    .swipeActions {  // ✓ Accessible to XCUITest
                        Button("Delete", role: .destructive) { }
                    }
            }
        }
    }
}

// UI test
func test_deleteItem() {
    app.launch()
    let cell = app.cells.firstMatch
    cell.swipeLeft()
    cell.buttons["Delete"].tap()
}

TextField(axis: .vertical) Element Type

Critical: TextField(axis: .vertical) in iOS 26 is not exposed as app.textViews or app.textFields:

// In SwiftUI
TextField("Enter text", text: $text, axis: .vertical)
    .accessibilityIdentifier("notesTextField")
// WRONG — element not found
func test_enterText() {
    app.launch()
    app.textFields["notesTextField"].tap()  // Not found
}

// WRONG — also not found
func test_enterText() {
    app.launch()
    app.textViews["notesTextField"].tap()  // Also not found
}

// RIGHT — use descendants with any type
func test_enterText() {
    app.launch()
    let textField = app.descendants(matching: .any)["notesTextField"]
    textField.tap()
    textField.typeText("Test notes")
}

UI Test Data Seeding Race Condition

Critical: Async data seeding requires synchronization:

// In app — signal when seeding completes
func seedTestData() async {
    // ... seed data
    await MainActor.run {
        NotificationCenter.default.post(name: .testDataSeeded, object: nil)
    }
}

// In UI test — wait for signal
func test_withSeededData() {
    app.launchArguments = ["-UITestMode", "-SeedScenario=MultipleCaptures"]
    app.launch()

    // Wait for seeding completion
    let seeded = expectation(forNotification: .testDataSeeded, object: nil)
    wait(for: [seeded], timeout: 5)

    // Now test with data
    XCTAssertTrue(app.cells.firstMatch.exists)
}

Also: Use Date() timestamps for test data to avoid time-filter hiding:

// WRONG — past dates filtered by default
let capture = CapturedThought(text: "Test", timestamp: Date().addingTimeInterval(-86400))

// RIGHT — use current date
let capture = CapturedThought(text: "Test", timestamp: Date())

watchOS: tap() Is Delivered, press(forDuration:) Is Not

Critical (watchOS simulator): XCUITest synthesizes a coordinate tap() into a SwiftUI .onTapGesture on a custom element (e.g. a ZStack ring with .accessibilityElement(children: .ignore) + .isButton), but it does not synthesize press(forDuration:) into that element's .onLongPressGesture. The long-press simply never fires, so any assertion that depends on the post-long-press state hangs until it times out.

// Custom control: a morphing ring that is the only accessible element.
// tap starts/resumes; long press pauses/stops.
ZStack { /* … */ }
    .onTapGesture { viewModel.primaryAction() }
    .onLongPressGesture { viewModel.secondaryAction() }
    .accessibilityElement(children: .ignore)
    .accessibilityAddTraits(.isButton)
    .accessibilityLabel(ringLabel)

// WORKS — coordinate tap reaches .onTapGesture
func test_tapStarts() {
    ring.tap()
    XCTAssertTrue(waitForRingLabel(containing: "running"))  // ✓ passes
}

// DOES NOT WORK — press(forDuration:) is not delivered to .onLongPressGesture
func test_longPressPauses() throws {
    try XCTSkipIf(true, "watchOS XCUITest can't deliver a long press to a custom .onLongPressGesture; cover the pause/stop logic with ViewModel unit tests instead.")
    ring.tap()
    ring.press(forDuration: 0.6)
    XCTAssertTrue(waitForRingLabel(containing: "paused"))  // ✗ never satisfied → times out
}
  • .accessibilityAction does NOT change this. tap() is a coordinate tap, not an accessibility activation, so adding .accessibilityAction(.default)/.accessibilityAction(named:) neither helps the tap (already works) nor enables the long press. Don't reach for it as a fix — it was a tested dead end.
  • Skip, don't fight it. Gate undrivable-gesture tests with try XCTSkipIf(true, "<reason>") (a throwing call, so the compiler won't flag the preserved body as unreachable — unlike a bare throw XCTSkip(...)), and move the behavior coverage to @MainActor ViewModel unit tests. The gesture still works for real users; only the simulator's event synthesis is the gap.
  • The same class of limitation covers TabView(.verticalPage) (a swipeDown after swipeUp reports "app not running") and the double-tap hand gesture (handGestureShortcut(.primaryAction) — no XCUITest affordance at all).

watchOS: UI Tests Share an App Group — Reset It Under a Launch Arg

Critical: watchOS UI tests in one suite share the App Group store. A session (or any persisted UI state) that test A starts is restored into test B's launch via your "restore from shared state" hook, so tests pass alone but fail in sequence (state bleed). Reset the shared stores in the launch-once restore hook, gated by a launch argument:

// Test
override func setUp() async throws {
    app = XCUIApplication()
    app.launchArguments = ["--uitesting"]
    app.launch()
}

// App (launch-once restore hook, guarded so it never clears a live mid-test session)
func restoreFromSharedStateIfNeeded() {
    guard !didAttemptRestore else { return }
    didAttemptRestore = true
    if CommandLine.arguments.contains("--uitesting") {
        TimerStateStore.clear()
        WatchSessionRestoreStore.clear()
        return                       // start every test from a clean, independent state
    }
    // … normal restore …
}

Reset only what causes bleed. A separate SwiftData captures store often is not cleared by this hook, so a screenshot test that seeds captures leaves rows visible to a later "empty idle" test — design that later assertion to tolerate the seeded rows rather than assuming a pristine store (see below).

watchOS WatchConnectivity: Force Independent Start Under Test

If a watch screen normally begins by negotiating with the paired iPhone over WCSession, that round-trip stalls in the simulator (no reachable counterpart), so even tap()-to-start appears broken. Bypass the negotiation entirely under test:

#if os(watchOS)
if CommandLine.arguments.contains("--uitesting") {
    role = .independent
    onIndependent()            // start locally, never wait on the phone
    return
}
if isCounterpartReachable { /* normal WCSession path */ }
#endif

Separately, any one-shot WCSession request needs its completion gated so it fires exactly once and its timeout actually fires: store a sentinel keyed by requestID in a lock-guarded registry, and gate every completion path (success / error / timeout) on an atomic retrieve() (remove-and-return). Forgetting to store the sentinel means the timeout's retrieve() returns nil and the timeout never fires → a permanent hang on a reachable-but-silent counterpart.

Screenshot Tests Need Assertions, Not Just snapshot()

A fastlane screenshot test that calls snapshot("paused") right after ring.press(forDuration:) proves nothing about the paused statesnapshot() captures whatever is on screen, with no assertion that the long press actually landed. Such a test stays green even when the gesture is silently dropped (see the watchOS long-press gotcha), giving false confidence. If a screenshot is meant to document a state, assert the state was reached before capturing:

ring.tap()
XCTAssertTrue(waitForRingLabel(containing: "running"))   // assert, then…
snapshot("running")                                       // …capture

NavigationLink Counts as a Button — Avoid Exact Button Counts

A SwiftUI NavigationLink is exposed to XCUITest as a button. Assertions like XCTAssertEqual(app.buttons.count, 1) are brittle: a "View all" history link, a leaked .swipeActions button (swipe actions outside a List don't function but still leak an empty-label button into the tree), or any conditional link inflate the count. Assert on intent — that no labeled control of the forbidden kind exists — and tolerate known-benign extras:

let labels = app.buttons.allElementsBoundByIndex.map(\.label)
let unexpected = labels.filter {
    !$0.hasPrefix("Ember timer") && $0 != "View all" && !$0.isEmpty  // ring, history link, empty swipe-action leak
}
XCTAssertTrue(unexpected.isEmpty, "unexpected buttons: \(unexpected)")

watchOS Sim Launch Flake — Launch Once, Wait, Retry Once (Don't Pre-terminate())

The watchOS simulator on Xcode Cloud intermittently fails to (re)launch the app under test ("Simulator device failed to launch …watchkitapp"). A test suite that does app.terminate(); app.launch() in every setUp makes this worselaunch() already terminates a running instance, so the explicit terminate() just doubles the number of fragile relaunches (~2× tests/run). Replace it with a launch-then-confirm helper that retries exactly once:

extension XCUIApplication {
    func launchForWatchUITest(timeout: TimeInterval = 30) {
        launch()
        if wait(for: .runningForeground, timeout: timeout) { return }
        terminate(); launch()
        _ = wait(for: .runningForeground, timeout: timeout)   // wait(for:) is available on watchOS (Xcode 16.3+)
    }
}

Then give setUp headroom: the retry can spend up to 2 × timeout (~60s) before the app is foreground, which by itself trips a executionTimeAllowance = 60. Raise the allowance (≥120s) on any class using the retry, or the run fails with "Test exceeded execution time allowance of 1 minute" even though every test is logically green (0 failures, all SUCCESS/SKIPPED).

CI That Uses a Committed .xcodeproj Won't See New Files Until You Regenerate

If a project commits its .xcodeproj and the CI clone script does not run xcodegen (check ci_scripts/ci_post_clone.sh), a brand-new source file added to a target is invisible to CI — the build fails with Value of type 'X' has no member 'Y' for the new symbol. Regenerate (xcodegen generate), re-apply any committed post-gen patches, and commit the project.pbxproj; the diff should be only the new file-reference insertions (PBXBuildFile, PBXFileReference, group, Sources phase).

Waiting for State Changes

// WRONG — long sleep
sleep(5)
XCTAssertTrue(element.exists)

// RIGHT — predicate expectation
let predicate = NSPredicate(format: "label == %@", "tap to pause")
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: hintLabel)
XCTWaiter.wait(for: [expectation], timeout: 5)

// RIGHT — wait for existence with short timeout
XCTAssertTrue(element.waitForExistence(timeout: 2))

Common Pitfalls

Date Filtering in Test Data

Problem: ViewModel defaults to .today filter, but seeded data uses past dates.

WRONG:

let capture = CapturedThought(text: "Test", timestamp: Date().addingTimeInterval(-86400))
// Won't appear in today's filter

RIGHT:

let capture = CapturedThought(text: "Test", timestamp: Date())
// Visible with default filter

Test Isolation Violations

WRONG: Sharing state between tests

static var sharedContainer: ModelContainer!  // ❌ Never do this

override func setUp() {
    // Reusing container from previous test
}

RIGHT: Fresh container per test

override func setUpWithError() throws {
    container = try makeTestContainer(for: MyModel.self)
}

override func tearDownWithError() throws {
    container = nil  // Clean up
}

Excessive Wait Times

WRONG:

XCTAssertTrue(element.waitForExistence(timeout: 30))  // Too long
sleep(5)  // Wastes time

RIGHT:

XCTAssertTrue(element.waitForExistence(timeout: 2))   // UI responds in <2s
// Or use expectations for specific state changes

Creating Data Through UI vs Seeding

See UI Testing section above. Always prefer seeding for setup state.


Running Tests

Unit Tests (Package)

cd MyAppKit && swift test

Unit Tests (Xcode)

xcodebuild test -scheme MyApp-iOS \
  -destination 'platform=iOS Simulator,name=iPhone 17 Pro Max' \
  -only-testing:MyAppTests

Specific Test File

xcodebuild test -scheme MyApp-iOS \
  -destination 'platform=iOS Simulator,name=iPhone 17 Pro Max' \
  -only-testing:MyAppTests/TimerViewModelTests

Specific Test

xcodebuild test -scheme MyApp-iOS \
  -destination 'platform=iOS Simulator,name=iPhone 17 Pro Max' \
  -only-testing:MyAppTests/TimerViewModelTests/test_startFromIdle_transitionsToRunning

UI Tests

xcodebuild test -scheme MyApp-UITests \
  -destination 'platform=iOS Simulator,name=iPhone 17 Pro Max'

With Coverage

xcodebuild test -scheme MyApp-iOS \
  -destination 'platform=iOS Simulator,name=iPhone 17 Pro Max' \
  -enableCodeCoverage YES \
  -resultBundlePath TestResults.xcresult

Testing Checklist

When adding a new feature:

  • Unit tests for ViewModel logic
  • Unit tests for Service layer (if applicable)
  • UI test for critical user path
  • Screenshot test if new screen visible in App Store
  • Accessibility identifiers added to interactive elements
  • Test data scenario updated (if needed)
  • Async operations tested with expectations
  • Error states tested (failures, permissions denied)

Quick Reference

PatternCode
In-memory containerModelConfiguration(isStoredInMemoryOnly: true)
MainActor test class@MainActor final class MyTests: XCTestCase — ALL methods must also be async
Async testfunc test_...() async throws — required for any @MainActor subject
Expectationlet exp = expectation(description: "..."); wait(for: [exp], timeout: 5)
Mock serviceProtocol + class with configurable behavior
UI test launchapp.launchArguments = ["-UITestMode"]; app.launch()
Wait for elementelement.waitForExistence(timeout: 2)

Troubleshooting

SymptomLikely CauseFix
Main actor-isolated ... can not be referenced from a nonisolated context in a testTest class subject is @MainActor but the test method is notMark the class @MainActor and make every test method async
SwiftData test mutates real app dataContainer is using the default on-disk storeUse ModelConfiguration(isStoredInMemoryOnly: true) for the test container
Test hangs and times out at an expectationThe awaited callback never fires (wrong queue or unfulfilled mock)Verify the mock actually invokes the completion; keep wait(for:timeout:) ≥ 5s for async work
waitForExistence flakes in UI testsElement queried before the screen transition completesIncrease the timeout and assert on a stable accessibility identifier, not a label
UI test launches the real backendLaunch arguments not honoredPass app.launchArguments = ["-UITestMode"] and branch on it in the app entry point
Whole suite crashes the machineUI tests were started without approvalUI tests are gated — never start them without explicit user approval in the current conversation

See Also

  • ios-standards — Swift 6 concurrency patterns
  • ios-build — Build validation and CI integration

What ships with it

Read from the repository

Just SKILL.md. No reference files, no scripts.

Keep looking

Skills are one crate of 326,834. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.