Ios testing
Use when writing, modifying, or reviewing tests for iOS/macOS apps. Activates for Swift Testing (@Test, #expect, @Suite), XCTest (XCTestCase, measure, XCTAssert), XCUITest (XCUIApplication, XCUIElement), performance testing, animation hitch testing, Instruments .trace file analysis (CPU profiling, system trace, allocations, etc.), flaky / timing-sensitive tests, actor-isolation crashes (EXC_BREAKPOINT / SIGTRAP, "passes locally crashes in CI"), continuation leaks / hangs, test-host stability, and CI runner flakiness — or when the user asks to write tests.From its SKILL.md
npx -y skills add ioloro/iOS-Testing --skill ios-testingAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 24 stars24 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
27.1 KB, as published. Nobody here has run it
iOS Testing Skill
You are an expert in Apple platform testing. Follow these rules strictly.
Decision flowchart (READ FIRST)
┌─────────────────────────────────┐
│ User wants tests written or run │
└────────────────┬────────────────┘
│
▼
┌────────────────────────┐
│ What are we testing? │
└─┬──────────┬──────────┬┘
│ │ │
logic/async UI/screens perf / energy / hitches
│ │ │
▼ ▼ ▼
┌─────────┐ ┌──────────┐ ┌────────────────────┐
│ Swift │ │ XCUITest │ │ XCTest + │
│ Testing │ │ (part │ │ XCTMetric │
│ │ │ of │ │ subclasses. │
│ default │ │ XCTest) │ │ Physical device + │
│ for new │ │ │ │ Release scheme │
│ tests │ │ │ │ required for │
│ │ │ │ │ accurate numbers. │
└────┬────┘ └─────┬────┘ └──────────┬─────────┘
│ │ │
└──────┬─────┴─────────────────┘
│
▼
┌──────────────────────────────────────────────────┐
│ Test compiles + builds (xcodebuild build / │
│ build-for-testing / swift build / etc.) │
└──────────────────┬───────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────┐
│ How do we run it? │
├──────────────────────────────────────────────────┤
│ logic / unit → iostesting test run │
│ app-hosted XCTest +→ IOSTESTING_BACKEND=fb │
│ Swift Testing iostesting test run │
│ UI test bundle → xcodebuild test (FB path │
│ has iOS 26 quirks → 2.1) │
│ performance → xcodebuild test on real │
│ device, Release scheme, │
│ perf-only scheme │
└──────────────────────────────────────────────────┘
The two iron rules: (1) do not invent assertion syntax — when in doubt about #expect(throws:), try #require, confirmation, or @Test(arguments:), read swift-testing.md before writing. (2) the test must run and pass before the change is done — writing is half the job; closing the loop with iostesting test run (or xcodebuild test for UI/perf) is the other half.
Framework Selection
Choose the correct framework based on what you are testing:
| What you are testing | Framework | Import |
|---|---|---|
| Unit tests, integration tests, logic, async code | Swift Testing | import Testing |
| Performance benchmarks (CPU, memory, clock, storage) | XCTest | import XCTest |
| Energy/power measurement | XCTest | import XCTest |
| Animation hitches, scroll performance, render metrics | XCTest + XCUITest | import XCTest |
| UI automation, element interaction, accessibility audits | XCUITest | import XCTest |
| Light/dark mode appearance testing | XCUITest (launch args) | import XCTest |
| Contrast and visibility testing | XCUITest (accessibility audit) | import XCTest |
| Snapshot/visual regression testing | XCTest (+ swift-snapshot-testing) | import XCTest |
| Exit/crash tests (Swift 6.2+) | Swift Testing | import Testing |
| Analyzing .trace files from Instruments | xctrace CLI | N/A (command-line tool) |
| Generating App Store screenshots / marketing tiles | XCUITest (matrix + framing) | import XCTest |
Critical Rules
- Default to Swift Testing for all new unit and integration tests. Never use
XCTestCasefor pure logic tests. - Never mix assertion styles in a single test function. Do not use
XCTAssertEqualalongside#expect, or vice versa. - Both frameworks can coexist in the same test target. Use
import Testingfor Swift Testing tests andimport XCTestfor XCTest/XCUITest tests — but keep them in separate files. - Use Swift Testing equivalents, not XCTest legacy:
#expect(a == b)notXCTAssertEqual(a, b)#expect(condition)notXCTAssertTrue(condition)try #require(optional)notXCTUnwrap(optional)#expect(throws: ErrorType.self) { ... }notXCTAssertThrowsErrorconfirmation { }notXCTestExpectation+wait(for:)@Test(.disabled("reason"))notXCTSkipIf/XCTSkipUnless
- Use
init()for setup in Swift Testing, notsetUp()/setUpWithError(). - Performance tests require XCTest. Swift Testing has no
measure {}equivalent. - UI tests require XCUITest (which is part of XCTest). Swift Testing cannot drive UI.
- Each test gets a fresh instance. Swift Testing creates a new suite instance per test — do not rely on shared mutable state between tests.
- Do not invent assertion syntax. If you can't recall the exact shape of
#expect(throws:),try #require,confirmation, or@Test(arguments:), readswift-testing.mdbefore writing it. Hallucinated APIs compile-fail and waste a build cycle. The cost of looking is one read; the cost of guessing is one cycle.
Common Mistakes
| Mistake | Why it's wrong | Use instead |
|---|---|---|
#expect(throws: SomeError()) | takes an error type or value-matching closure, not an instance | #expect(throws: SomeError.self) or #expect(throws: SomeError.tooShort) { ... } |
try #require placed inside #expect(...) | #require is its own assertion — it doesn't nest | call let x = try #require(optional) on its own line, then #expect(x.foo == ...) |
confirmation { confirm in confirm.fulfill() } | confused with XCTestExpectation | the parameter is called like a function: confirm() |
@Test(arguments: collectionA, collectionB) for paired inputs | produces a cartesian product, not pairs | @Test(arguments: zip(collectionA, collectionB)) for paired |
setUp() / setUpWithError() inside a Swift Testing struct | wrong framework — those are XCTest methods | implement init() (synchronous) or init() async throws (async) on the suite |
XCTUnwrap(optional) inside a @Test function | mixed-framework call | try #require(optional) |
XCTestExpectation + wait(for:timeout:) inside @Test | XCTest pattern in Swift Testing file | await confirmation("description", expectedCount: 1) { confirm in ...; confirm() } |
measure { ... } inside @Test | Swift Testing has no perf primitive | move perf tests to an XCTestCase file and use measure(metrics:) with specific XCTMetric subclasses |
app.staticTexts["Welcome"] matching localized text | breaks under any non-English locale | use accessibilityIdentifier and query with [.byId("welcome_screen_title")] (or your project's helper) |
| Running performance tests on simulator | sim CPU/memory metrics are unreliable, sim only supports Duration | physical device, Release config, perf-only test scheme |
| Only auditing accessibility on the first screen | misses regressions on flows | call app.performAccessibilityAudit(for: [.contrast, .dynamicType]) on every screen |
Hardcoded UIColor.black / Color.white | breaks dark mode + dynamic type contrast | semantic: UIColor.label, .systemBackground, SwiftUI .primary |
Best Practices
General
- F.I.R.S.T. principles: Fast (leverage default parallelism), Isolated (fresh instance per test), Repeatable (control all inputs), Self-Validating (use assertions, never
print), Timely (write tests alongside production code). - Do not weaken encapsulation for testing. If something is
private, do not make itpublicjust to test it. Test through the public API. - Do not randomize test data. Tests must verify that fixed input produces fixed output. Use deterministic values.
- Mark XCTest methods as
throwsinstead of usingdo/catchortry!. Force unwraps crash the entire suite and produce incomplete reports. - Never compare floating-point values for exact equality. Use a tolerance:
#expect(abs(result - 0.3) < 0.0001).
Swift Testing Specific
- Prefer
#expectover#requirefor most assertions. Use#requireonly when continuing after failure is meaningless (e.g., unwrapping an optional needed by subsequent assertions). - Do not overuse
.serialized. It masks concurrency bugs and slows tests. Only use it temporarily for legacy non-thread-safe code. - Do not add
@MainActorto tests unnecessarily. It forces serial execution and defeats parallelism. - Apply
.timeLimit()to async tests to prevent CI hangs. - Use
zip()for paired test arguments, not separate collections (which create a cartesian product). - Conform parameterized types to
CustomTestStringConvertiblefor readable failure messages.
XCTest/XCUITest Specific
- Always use specific
XCTMetricsubclasses inmeasure(metrics:), not baremeasure {}. - Run performance and animation tests on physical devices. Simulators do not produce accurate hitch data and only support the Duration metric.
- Use a separate test scheme for performance tests with Release config, debugger disabled, screenshots off, code coverage off, and all diagnostics disabled.
- Always use
accessibilityIdentifierfor element queries, never localized display text. - Set
continueAfterFailure = falsein UI testsetUpWithError().
Reliability, Concurrency & the CI host
Tests that pass on an idle laptop but flake on a loaded/shared/newer-OS runner. Full patterns + code in test-reliability.md — read it before "fixing" a flaky or CI-only crash. Highest-value rules:
- Never assert a wall-clock UPPER bound (
#expect(elapsed < X)). It tests machine speed, not correctness, and flakes under load. Keep meaningful lower bounds; use.timeLimit()to catch real hangs. - Poll to a condition, don't
sleepa fixed amount to wait for async work — a fixed wait under-waits a starved background task under load. - When timing IS the contract, measure it relatively (compare two runs on the same machine — the difference is load-independent), never with an absolute bound.
@MainActortypes must not be decoded/used off the main thread. On the iOS 26+/Swift 6 runtime an off-main call into MainActor-isolated code is a hardEXC_BREAKPOINT/SIGTRAP(older runtimes silently continued → "passes locally, crashes in CI"). Codable DTOs/models should be nonisolated.- Bridge continuations safely. A
withCheckedContinuationover a callback API leaks and hangs forever if no callback fires — add a defensive timeout + a once-guarded resume. Mocks must invoke a reply/error handler like the real thing. - Keep the unit-test host inert. Guard app-startup network/listeners/gates and
crash-reporting SDKs (Sentry) on
XCTestConfigurationFilePath— otherwise their background work trips the iOS watchdog and the crash lands on a random test. - On CI:
-parallel-testing-enabled NOon constrained runners; treat pure runner-flakiness crashes with-retry-tests-on-failure, not named-suite skips (whack-a-mole). Read the.xcresult/.ipsbefore diagnosing.
Anti-Patterns to Avoid
- Writing multiple near-identical test functions instead of using
@Test(arguments:). - Using
XCTestCasesubclass withsetUp()/tearDown()for pure logic tests. - Mixing
import Testingandimport XCTestassertions in the same test function. - Hardcoding timeouts without
waitForExistenceor expectations for async UI. - Testing on simulator and expecting accurate performance/hitch metrics.
- Using
.serializedor@MainActoras a default — these should be rare exceptions. - Only testing in light mode — always test both light and dark appearances.
- Using hardcoded colors (
UIColor.black,Color.white) instead of semantic colors. - Running accessibility audits on only the first screen — audit every screen in the flow.
- Asserting a wall-clock upper bound (
#expect(elapsed < 1.0)) — tests machine speed, not correctness; flakes under CI load. (See test-reliability.md §1.) - A fixed
Task.sleepto wait for async/background work instead of polling to a condition — under-waits a starved task under load. (§2) @MainActoron aCodableDTO/model that gets decoded off-main — a latent productionSIGTRAPon iOS 26+ that only surfaces on newer CI runtimes. (§5)- A
withCheckedContinuationbridging a callback with no defensive timeout — leaks and hangs forever if the callback never fires. (§6) - A test mock that records a call but invokes neither its reply nor error handler — hangs the awaiting caller. (§6)
- Booting the full app service stack (network, listeners, crash SDKs) in the unit-test host — background work trips the watchdog and crashes a random test. Guard on
XCTestConfigurationFilePath. (§7) - "Fixing" a flaky CI-only crash by skipping the named victim suite — the crash just moves to the next test. Use
-retry-tests-on-failureand read the real crash log. (§8–9) - Trusting
.xctestplanskippedTests/-skip-testing:to quarantine a nested Swift Testing@Suite— it silently no-ops. Gate at the source with.enabled(if:)/.disabled. (§8)
Running Tests
Writing the test is half the job. The test must run and pass before the change is considered done.
This repo ships an iostesting CLI (in cli/) that drives simulators, physical devices, and .xctest bundles. When the user asks you to actually run the tests you just wrote, use iostesting rather than constructing raw xcodebuild test or xcrun simctl invocations.
Build with whatever build tool the project already uses, then run the resulting .xctest bundle:
# build the test bundle via the project's build tool (xcodebuild, swift build, etc.)
# then drive execution with iostesting:
iostesting sim list --state Booted # find or boot a sim
iostesting sim boot "iPhone 17 Pro" # idempotent
iostesting test list path/to/MyTests.xctest # confirm tests compiled in
iostesting test run path/to/MyTests.xctest --json # NDJSON events
iostesting test run path/to/MyTests.xctest --filter MySuite/testThing
When in doubt, ask the binary. Every command supports --examples:
iostesting test run --examples # curated snippets — never guess flag shapes
iostesting sim location --examples
iostesting stop --examples
Two backends — pick per test type:
- Default
IOSTESTING_BACKEND=simctl— shells toxcrun simctl spawn xctest. Logic tests only. Works on any Mac with Xcode installed. IOSTESTING_BACKEND=fb— direct linkage against XCTestBootstrap. Runs app-hosted XCTest + Swift Testing bundles end-to-end on iOS 26 simulators. Emits per-case NDJSON events (caseStarted/casePassed/caseFailedwith durations + attachments). Set this env var when you need to run real test bundles via iostesting.
2.0.0 limitations:
- XCUITest via the FB backend has known iOS 26 quirks (DTX handshake stall /
.xctestrunwrite fail). Usexcodebuild testfor UI tests until 2.1. - UI verbs beyond
tapandswipe(find/wait/assert/type/screen+AX-tree) need FBAccessibilityElement bridging — queued for 2.1. - Device runtime control + log streaming —
device list/install/launchwork viaxcrun devicectl; richer device ops need FBDeviceControl wired through FBBridge. - Performance tests still require physical hardware for accurate metrics. iostesting cannot make simulator perf numbers meaningful — that constraint is from XCTest itself.
Decision rule per test type:
- Unit / logic Swift Testing or XCTest, no host app →
iostesting test run(default simctl backend is enough). Run after writing. - App-hosted XCTest + Swift Testing bundles →
IOSTESTING_BACKEND=fb iostesting test run. Auto-detects the host app from the bundle's.app/PlugIns/parent. - UI tests (XCUITest) → still
xcodebuild testin 2.0.0. iostesting's XCUITest path lands cleanly in 2.1. - Performance tests → physical device, Release scheme,
xcodebuild testdirectly. Do not run on simulator.
Config makes flags implicit. Once iostesting config set --sim "iPhone 17 Pro" --bundle-id com.example.MyApp has been run, --sim and positional bundle ids become optional on every command. Use iostesting config show to see what's bound. For CI, set IOSTESTING_SIM and IOSTESTING_BUNDLE_ID env vars instead of writing a config file.
App registry + short IDs. iostesting launch records each launched app and prints a 6-character short ID. iostesting stop <short-id> and iostesting logs --bundle-id <short-id> resolve sim + bundle automatically — no need to retype them:
$ iostesting launch com.example.MyApp
Launched com.example.MyApp on iPhone 17 Pro (pid 12345) [id ab12kw]
$ iostesting logs --bundle-id ab12kw # sim + bundle resolved from registry
$ iostesting stop ab12kw
iostesting apps list shows the registry; iostesting apps prune clears terminated records.
Physical devices. iostesting device list shows paired devices; device install and device launch work via xcrun devicectl. Device log streaming and richer runtime control land in a follow-up release via FBDeviceControl.
Other useful sim ops you don't have to remember the simctl flags for:
iostesting sim appearance light # or dark — for screenshot tests
iostesting sim media-add ./fixtures/*.jpg
iostesting sim location --set "37.7749,-122.4194"
iostesting sim open-url myapp://reset-password
iostesting screenshot -o ./shot.png
SpringBoard alerts XCUITest can't reach (iOS 26)
XCUITest's addUIInterruptionMonitor is scoped to the app under test. It catches the initial CLLocationManager prompt ("When in Use / Once / Don't Allow") but reliably misses SpringBoard-owned modals that fire mid-test — most notably iOS 26's location-upgrade prompt:
Allow "X" to also use your location even when you are not using the app? [Keep Only While Using] [Change to Always Allow]
The test stalls while the runner tries to interact with elements behind the modal, eventually hitting --termination-timeout with "host application process stalled".
Two layered fixes, both wired into iostesting test run:
-
Pre-grant the privacy state before the runner launches.
simctl privacy grant location-alwayswritesAuthorization=4, AuthorizationUpgradeAvailable=falseinto locationd'sclients.plist. The upgrade prompt then never fires because iOS sees the auth is already at Always:IOSTESTING_BACKEND=fb iostesting test run ./MyAppUITests.xctest \ --privacy-grant all-locationThe grant MUST happen after the app is installed (locationd needs the bundle path).
iostesting test rundoes the install + grant in the right order. -
Background watcher with OCR-driven dismissal. When a prompt is going to fire regardless (notifications, tracking, second-time grants), spawn a watcher that screenshots the sim every 2s, OCRs the screen with Vision, and taps any matching button:
IOSTESTING_BACKEND=fb iostesting test run ./MyAppUITests.xctest \ --auto-dismiss-alertsBelt-and-suspenders combination:
IOSTESTING_BACKEND=fb iostesting test run ./MyAppUITests.xctest \ --privacy-grant all-location \ --auto-dismiss-alerts \ --termination-timeout 300
Defaults cover the common labels: Keep Only While Using, Allow While Using App, Allow Once, Don't Allow, OK, Allow. Override with --auto-dismiss-label.
Standalone alert tools (when not running a full test):
IOSTESTING_BACKEND=fb iostesting alerts dismiss # one-shot, exit 2 on no match
IOSTESTING_BACKEND=fb iostesting alerts watch --duration 120 --json
IOSTESTING_BACKEND=fb iostesting privacy grant --all-location com.example.MyApp
When to reach for which:
- App requires "Always" location for a background task →
--privacy-grant all-location. - App requires a one-time When-In-Use grant and you still want to test the prompt path →
--auto-dismiss-alerts(the test still sees the prompt fire; the watcher handles it if XCUITest's monitor fails). - App fires a notifications/tracking prompt mid-flow →
--auto-dismiss-alerts(no--privacy-grantfor that service exists; simctl doesn't expose notification-permission grants).
Optional enforcement hook. The repo ships hooks/iostesting-guard.sh, a Claude Code PreToolUse hook that blocks xcrun simctl boot/install/launch/... and xcodebuild test and suggests the iostesting equivalent. See hooks/README.md for install instructions. Without the hook the skill is advice; with it, it's a contract.
Analyzing .trace Files
You can read and analyze Xcode Instruments .trace files directly. When a user provides a .trace file:
- Export the TOC with
xctrace export --input /path/to/file.trace --tocto discover available data tables. - Export specific tables to a file using
--xpathand--output /tmp/trace_data.xml(piping large XML loses ref context). - Parse with Python (
xml.etree.ElementTree) to resolve the id/ref deduplication system and extract meaningful data. - Suggest code improvements based on the findings.
Critical Gotchas
- Use
time-profileschema (symbolicated frames), NOTtime-sample(rawkperf-bthex addresses). This is the #1 time-waster. --filter-timedoes not exist — export full data and filter programmatically.- Ref resolution is mandatory — threads, states, and frames use
id="N"/ref="N"deduplication. Build lookup dicts. - For hangs: export
potential-hangsfirst for time ranges, thentime-profilefiltered to main thread + Running state.
See the trace analysis reference for the complete guide including hang analysis workflow with Python script, .trace file structure, all schemas, and XML format details.
Generating App Store Screenshots
This skill can also generate App Store screenshots — not just test an app, but
drive it through every marketing state and produce framed store tiles. The same
XCUITest muscle (launch args, accessibility identifiers, XCUIScreenshot) powers
an end-to-end pipeline: capture deterministic states → apply a 9:41 status bar →
extract from the .xcresult → composite into 1320×2868 store tiles with a baked
headline.
Reach for this when the user asks to "generate App Store screenshots", "capture screenshots for the store", "make screenshot tiles", or "frame screenshots for submission".
Key moves (full playbook in the reference):
- Matrix harness — one XCUITest iterates
Config×ScreenKind, relaunching a fresh app per capture and attaching each PNG. Prove it with a smoke test first. - Deterministic seeding — seed the data layer before the scene renders; full-history seeds must wipe ALL prior rows or re-runs accumulate duplicates and silently misclassify.
- 9:41 status bar —
xcrun simctl status_bar <udid> overrideon the booted sim, thentest-without-buildingon that exactid=<udid>so no reboot wipes it. - Extract + frame —
xcresulttool export attachmentsmaps UUIDs back toslug/screen.png; PIL composites them onto brand-framed store canvases. Archive the submission build with a stable Xcode (beta Xcode → ITMS-90534 on upload).
See the App Store screenshot pipeline for the complete
runnable playbook including the TEST_RUNNER_ env-forwarding gotcha, the
fileSystemSynchronizedGroup landmine, the reusable PIL framing snippet, and ASC
upload specifics.
Reference Files
For detailed patterns and code examples, see:
- Swift Testing patterns and examples
- XCTest performance, power, and energy testing
- XCUITest UI automation and animation testing
- Test reliability: concurrency, timing, and the CI host — flaky-timing tests, iOS 26+ actor-isolation crashes, continuation hangs, test-host hygiene, CI runner flakiness, crash-log diagnosis
- Instruments .trace file analysis
- App Store screenshot pipeline — capture matrix, 9:41 status bar, deterministic seeding,
.xcresultextraction, PIL framing into store tiles, ASC upload - For running tests via the iostesting CLI: see
../../cli/README.mdin this repo.
When Generating Tests
- Ask yourself: "Is this testing logic/behavior, or performance/UI?" Pick the framework accordingly.
- For Swift Testing: use free functions or structs, not classes. Use
@Testand@Suite. - For parameterized tests: always prefer
@Test(arguments:)over writing multiple similar test functions. - For async code: Swift Testing supports
asyncnatively. Useconfirmation()for callback-based APIs. - For performance: use
measure(metrics:)with specificXCTMetricsubclasses, not baremeasure {}. - For UI tests: always use accessibility identifiers, never match on localized strings.
- For error testing: use
#expect(throws:)with the specific error value or type. - For known bugs: use
withKnownIssue("description") { }instead of disabling the test. - For appearance testing: test both light and dark mode using launch arguments +
overrideUserInterfaceStyle. - For contrast/visibility: run
performAccessibilityAudit(for: .contrast)in both appearance modes. - Use semantic/system colors (
UIColor.label,.systemBackground, SwiftUI.primary) — never hardcode colors.