Ios test
Skill markdavidgan/apple-dev-skills/platforms/codex/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
npx -y skills add markdavidgan/apple-dev-skills --skill ios-testAssembled 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:
| Target | Purpose | When to Run | Max Duration |
|---|---|---|---|
MyAppTests | Business logic, ViewModels, Services | Every build (⌘U) | <30 seconds |
MyApp-UITests | Critical user paths only | Pre-commit, CI | <60 seconds |
MyApp-Screenshots | App Store assets | CI release only | 5-10 minutes |
MyAppKitTests | Shared package tests | Every 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
| Suite | Target | Max | Command |
|---|---|---|---|
| Unit Tests | 15s | 30s | swift test or xcodebuild test |
| UI Tests | 30s | 60s | xcodebuild test -scheme MyApp-UITests |
| Screenshot Tests | — | CI only | bundle exec fastlane screenshots |
| Total CI | 60s | 120s | Full 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 simulatorAVAudioEngine— complex initialization stateHKHealthStore(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
| Argument | Purpose |
|---|---|
-UITestMode | Disables animations, analytics, alerts |
-SeedScenario=<name> | Pre-populates test data |
-FastTimer | Accelerated timer for tests |
-DisableOnboarding | Skips onboarding flow |
-ResetState | Clears 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
| Scenario | Sessions | Captures | Use Case |
|---|---|---|---|
FreshInstall | 0 | 0 | First launch, onboarding |
SingleSession | 1 | 2 | Basic timer flow |
ActiveSession | 1 (running) | 1 | Pause/resume testing |
History7Days | 7 | 15 | Weekly review screens |
History30Days | 30 | 60 | Insights, trends |
HighCompletion | 20 (18 done) | 40 | Success messaging |
LowCompletion | 20 (5 done) | 25 | Coaching 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
}
.accessibilityActiondoes 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 barethrow XCTSkip(...)), and move the behavior coverage to@MainActorViewModel 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)(aswipeDownafterswipeUpreports "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 state — snapshot() 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 worse — launch() 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
| Pattern | Code |
|---|---|
| In-memory container | ModelConfiguration(isStoredInMemoryOnly: true) |
| MainActor test class | @MainActor final class MyTests: XCTestCase — ALL methods must also be async |
| Async test | func test_...() async throws — required for any @MainActor subject |
| Expectation | let exp = expectation(description: "..."); wait(for: [exp], timeout: 5) |
| Mock service | Protocol + class with configurable behavior |
| UI test launch | app.launchArguments = ["-UITestMode"]; app.launch() |
| Wait for element | element.waitForExistence(timeout: 2) |
Troubleshooting
| Symptom | Likely Cause | Fix |
|---|---|---|
Main actor-isolated ... can not be referenced from a nonisolated context in a test | Test class subject is @MainActor but the test method is not | Mark the class @MainActor and make every test method async |
SwiftData test mutates real app data | Container is using the default on-disk store | Use ModelConfiguration(isStoredInMemoryOnly: true) for the test container |
Test hangs and times out at an expectation | The 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 tests | Element queried before the screen transition completes | Increase the timeout and assert on a stable accessibility identifier, not a label |
| UI test launches the real backend | Launch arguments not honored | Pass app.launchArguments = ["-UITestMode"] and branch on it in the app entry point |
| Whole suite crashes the machine | UI tests were started without approval | UI tests are gated — never start them without explicit user approval in the current conversation |
See Also
ios-standards— Swift 6 concurrency patternsios-build— Build validation and CI integration
What ships with it
Read from the repository
Just SKILL.md. No reference files, no scripts.