
Axiom Testing
- 718 installs
- 1.1k repo stars
- Updated August 3, 2026
- charleswiltgen/axiom
axiom-testing is an agent skill that routes Swift and Apple-platform testing questions through structured guidance for unit tests, async tests, UI tests, and XCTest versus Swift Testing decisions for developers building
About
axiom-testing is a Testing & QA skill from charleswiltgen/axiom (MIT) that agents must invoke for any Apple-platform testing question. The skill provides a quick-reference decision table routing tasks to specialized guides: Swift Testing with @Test and #expect for unit tests, parameterized tests with tags and traits, simulator-free execution, UI test patterns, and XCTest migration choices. Developers reach for axiom-testing when writing new Swift tests, debugging flaky or slow test suites, choosing between Swift Testing and XCTest, or architecting test layers for async and UI coverage on Apple platforms.
- Mandatory entry point for ANY testing-related question on Apple platforms
- Swift Testing (@Test, #expect) vs XCTest migration and unit-test patterns
- Async/await testing, @MainActor, parallel execution, and callback confirmations
- UI tests: XCUITest, condition-based waiting, Xcode 26 recording references
- XCUIElement queries, accessibility IDs, test plans, and CI/CD execution
Axiom Testing by the numbers
- 718 all-time installs (skills.sh)
- Ranked #571 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/charleswiltgen/axiom --skill axiom-testingAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 718 |
|---|---|
| repo stars | ★ 1.1k |
| Security audit | 3 / 3 scanners passed |
| Last updated | August 3, 2026 |
| Repository | charleswiltgen/axiom ↗ |
How do you choose Swift Testing vs XCTest?
Route every Swift and Apple-platform testing question through structured guidance for unit, async, UI, and XCTest automation choices.
Who is it for?
Swift and Apple-platform developers writing or debugging tests who need framework selection guidance across unit, async, and UI test layers.
Skip if: Developers building non-Apple platforms or web-only test suites should use language-specific testing skills instead of axiom-testing.
When should I use this skill?
A developer asks to write Swift tests, debug flaky Apple tests, speed up test runs, or choose between Swift Testing and XCTest.
What you get
Swift unit tests, async test cases, UI automation tests, and a documented testing approach with framework selection rationale.
- Swift test files
- UI test suites
- Test architecture recommendations
Files
Testing
You MUST use this skill for ANY testing-related question, including writing tests, debugging test failures, making tests faster, or choosing between testing approaches.
Quick Reference
| Symptom / Task | Reference |
|---|---|
| Writing unit tests, Swift Testing (@Test, #expect) | See skills/swift-testing.md |
| Making tests run without simulator | See skills/swift-testing.md |
| Parameterized tests, tags, traits | See skills/swift-testing.md |
| Migrating from XCTest to Swift Testing | See skills/swift-testing.md |
Warning-severity issues / cancelling a test — Issue.record(severity:), Test.cancel (OS27) | See skills/swift-testing.md |
| Testing async/await functions | See skills/testing-async.md |
| confirmation for callbacks | See skills/testing-async.md |
| @MainActor tests, parallel execution | See skills/testing-async.md |
| Writing UI tests, XCUITest | See skills/ui-testing.md |
| Condition-based waiting patterns | See skills/ui-testing.md |
| Recording UI Automation (Xcode 26) | See skills/ui-testing.md |
| Network conditioning, multi-factor testing | See skills/ui-testing.md |
| Test Face ID/Touch ID, orientation, or simulator state from CI — devicectl | See skills/ui-testing.md |
| XCUIElement queries, waiting strategies | See skills/xctest-automation.md |
| Accessibility identifiers, test plans | See skills/xctest-automation.md |
| CI/CD test execution | See skills/xctest-automation.md |
| Record/Replay/Review workflow (Xcode 26) | See skills/ui-recording.md |
| Test plan multi-configuration replay | See skills/ui-recording.md |
| Enhancing recorded tests for stability | See skills/ui-recording.md |
Decision Tree
digraph testing {
start [label="Testing task" shape=ellipse];
what [label="What kind of test?" shape=diamond];
start -> what;
what -> "skills/swift-testing.md" [label="unit tests,\nSwift Testing,\nfast tests"];
what -> "skills/testing-async.md" [label="testing async code,\ncallbacks,\nconfirmation"];
what -> "skills/ui-testing.md" [label="UI tests,\nflaky tests,\nrecording"];
what -> "skills/xctest-automation.md" [label="XCUITest patterns,\nelement queries"];
what -> "skills/ui-recording.md" [label="Xcode 26\nRecord/Replay/Review"];
}1. Writing unit tests / Swift Testing? → skills/swift-testing.md 2. Testing async/await code? → skills/testing-async.md 3. Writing UI tests / XCUITest / flaky tests? → skills/ui-testing.md 4. XCUIElement queries, waiting, test plans, CI? → skills/xctest-automation.md 5. Record UI interactions (Xcode 26)? → skills/ui-recording.md 6. Flaky tests / race conditions (Swift Testing)? → test-failure-analyzer (Agent) 7. Tests crash / environment wrong? → See axiom-build (skills/xcode-debugging.md) 8. Run tests from CLI / parse results? → test-runner (Agent) 9. Fix failing tests automatically? → test-debugger (Agent) 10. Want test quality audit? → testing-auditor (Agent) or /axiom:audit testing 11. Automate without XCUITest / AXe CLI? → simulator-tester (Agent) + See axiom-xcode-mcp (skills/axe-ref.md)
Swift Testing vs XCTest Quick Guide
| Need | Use |
|---|---|
| Unit tests (logic, models) | Swift Testing |
| UI tests (tap, swipe, assert screens) | XCUITest (XCTest) |
| Tests without simulator | Swift Testing + Package/Framework |
| Parameterized tests | Swift Testing |
| Performance measurements | XCTest (XCTMetric) |
| Objective-C tests | XCTest |
Critical Patterns
Swift Testing (skills/swift-testing.md):
- @Test/@Suite macros, #expect/#require assertions
- Parameterized testing for eliminating repetitive tests
- Fast tests architecture: Package extraction, Host Application: None
- Reliable async testing with withMainSerialExecutor and TestClock
- Migration guide from XCTest (comparison table)
- XCTestCase + Swift 6.2 MainActor compatibility fix
Async Testing (skills/testing-async.md):
- confirmation for single/multiple callbacks
- expectedCount: 0 to verify something never happens
- @MainActor test isolation
- Timeout control with .timeLimit
- Parallel execution gotchas and .serialized
UI Testing (skills/ui-testing.md):
- Condition-based waiting (replaces sleep())
- Recording UI Automation (Xcode 26)
- Network conditioning for 3G/LTE testing
- Multi-factor testing (device size + network speed)
- Crash debugging from UI test failures
XCUITest Automation (skills/xctest-automation.md):
- Element identification with accessibilityIdentifier
- Waiting strategies (appear, disappear, hittable)
- Test plans for multi-configuration testing
- CI/CD integration with parallel execution
UI Recording (skills/ui-recording.md):
- Xcode 26 Record/Replay/Review workflow
- Enhancing recorded code for stability
- Query selection guidelines
- Test plan configuration for multi-language replay
Automated Scanning
Test quality audit → Launch testing-auditor agent or /axiom:audit testing (maps test coverage shape against production code, detects flaky patterns and speed issues, identifies untested critical paths, scores overall test health)
Flaky test analysis → Launch test-failure-analyzer agent (scans for patterns causing intermittent failures in Swift Testing: missing confirmation, shared mutable state, missing @MainActor)
Anti-Rationalization
| Thought | Reality |
|---|---|
| "Simple test question, I don't need the skill" | Proper patterns prevent test debt. skills/swift-testing.md has copy-paste solutions. |
| "I know XCTest well enough" | Swift Testing is significantly better for unit tests. Migration guide included. |
| "Tests are slow but it's fine" | Fast tests enable TDD. skills/swift-testing.md shows how to run without simulator. |
| "I'll fix the flaky test with a sleep()" | sleep() makes tests slower AND flakier. skills/ui-testing.md has condition-based waiting. |
| "I'll add tests later" | Tests written after implementation miss edge cases. |
Example Invocations
User: "How do I write a unit test in Swift?" → Read: skills/swift-testing.md
User: "My UI tests are flaky in CI" → Check codebase: XCUIApplication/XCUIElement? → skills/ui-testing.md → Check codebase: @Test/#expect? → test-failure-analyzer (Agent)
User: "How do I test async code without flakiness?" → Read: skills/testing-async.md
User: "What's the Swift Testing equivalent of XCTestExpectation?" → Read: skills/testing-async.md
User: "I want my tests to run faster" → Read: skills/swift-testing.md (Strategy 1: Package extraction)
User: "Should I use Swift Testing or XCTest?" → Read: skills/swift-testing.md (Migration section) + this decision tree
User: "How do I record UI automation in Xcode 26?" → Read: skills/ui-recording.md
User: "Run my tests and show me what failed" → Invoke: test-runner (Agent)
User: "Audit my tests for quality issues" → Invoke: testing-auditor (Agent)
Swift Testing
Overview
Swift Testing is Apple's modern testing framework introduced at WWDC 2024. It uses Swift macros (@Test, #expect) instead of naming conventions, runs tests in parallel by default, and integrates seamlessly with Swift concurrency.
Core principle: Tests should be fast, reliable, and expressive. The fastest tests run without launching your app or simulator.
The Speed Hierarchy
Tests run at dramatically different speeds depending on how they're configured:
| Configuration | Typical Time | Use Case |
|---|---|---|
swift test (Package) | ~0.1s | Pure logic, models, algorithms |
| Host Application: None | ~3s | Framework code, no UI dependencies |
| Bypass app launch | ~6s | App target but skip initialization |
| Full app launch | 20-60s | UI tests, integration tests |
Key insight: Move testable logic into Swift Packages or frameworks, then test with swift test or "None" host application.
---
Building Blocks
@Test Functions
import Testing
@Test func videoHasCorrectMetadata() {
let video = Video(named: "example.mp4")
#expect(video.duration == 120)
}Key differences from XCTest:
- No
testprefix required —@Testattribute is explicit - Can be global functions, not just methods in a class
- Supports
async,throws, and actor isolation - Each test runs on a fresh instance of its containing suite
#expect and #require
// Basic expectation — test continues on failure
#expect(result == expected)
#expect(array.isEmpty)
#expect(numbers.contains(42))
// Required expectation — test stops on failure
let user = try #require(await fetchUser(id: 123))
#expect(user.name == "Alice")
// Unwrap optionals safely
let first = try #require(items.first)
#expect(first.isValid)Why #expect is better than XCTAssert:
- Captures source code and sub-values automatically
- Single macro handles all operators (==, >, contains, etc.)
- No need for specialized assertions (XCTAssertEqual, XCTAssertNil, etc.)
Error Testing
// Expect any error
#expect(throws: (any Error).self) {
try dangerousOperation()
}
// Expect specific error type
#expect(throws: NetworkError.self) {
try fetchData()
}
// Expect specific error value
#expect(throws: ValidationError.invalidEmail) {
try validate(email: "not-an-email")
}
// Custom validation
#expect {
try process(data)
} throws: { error in
guard let networkError = error as? NetworkError else { return false }
return networkError.statusCode == 404
}@Suite Types
@Suite("Video Processing Tests")
struct VideoTests {
let video = Video(named: "sample.mp4") // Fresh instance per test
@Test func hasCorrectDuration() {
#expect(video.duration == 120)
}
@Test func hasCorrectResolution() {
#expect(video.resolution == CGSize(width: 1920, height: 1080))
}
}Key behaviors:
- Structs preferred (value semantics, no accidental state sharing)
- Each
@Testgets its own suite instance - Use
initfor setup,deinitfor teardown (actors/classes only) - Nested suites supported for organization
---
Traits
Traits customize test behavior:
// Display name
@Test("User can log in with valid credentials")
func loginWithValidCredentials() { }
// Disable with reason
@Test(.disabled("Waiting for backend fix"))
func brokenFeature() { }
// Conditional execution
@Test(.enabled(if: FeatureFlags.newUIEnabled))
func newUITest() { }
// Time limit
@Test(.timeLimit(.minutes(1)))
func longRunningTest() async { }
// Bug reference
@Test(.bug("https://github.com/org/repo/issues/123", "Flaky on CI"))
func sometimesFailingTest() { }
// OS version requirement
@available(iOS 18, *)
@Test func iOS18OnlyFeature() { }Tags for Organization
// Define tags
extension Tag {
@Tag static var networking: Self
@Tag static var performance: Self
@Tag static var slow: Self
}
// Apply to tests
@Test(.tags(.networking, .slow))
func networkIntegrationTest() async { }
// Apply to entire suite
@Suite(.tags(.performance))
struct PerformanceTests {
@Test func benchmarkSort() { } // Inherits .performance tag
}Use tags to:
- Run subsets of tests (filter by tag in Test Navigator)
- Exclude slow tests from quick feedback loops
- Group related tests across different files/suites
---
Parameterized Testing
Transform repetitive tests into a single parameterized test:
// ❌ Before: Repetitive
@Test func vanillaHasNoNuts() {
#expect(!IceCream.vanilla.containsNuts)
}
@Test func chocolateHasNoNuts() {
#expect(!IceCream.chocolate.containsNuts)
}
@Test func almondHasNuts() {
#expect(IceCream.almond.containsNuts)
}
// ✅ After: Parameterized
@Test(arguments: [IceCream.vanilla, .chocolate, .strawberry])
func flavorWithoutNuts(_ flavor: IceCream) {
#expect(!flavor.containsNuts)
}
@Test(arguments: [IceCream.almond, .pistachio])
func flavorWithNuts(_ flavor: IceCream) {
#expect(flavor.containsNuts)
}Two-Collection Parameterization
// Test all combinations (4 × 3 = 12 test cases)
@Test(arguments: [1, 2, 3, 4], ["a", "b", "c"])
func allCombinations(number: Int, letter: String) {
// Tests: (1,"a"), (1,"b"), (1,"c"), (2,"a"), ...
}
// Test paired values only (3 test cases)
@Test(arguments: zip([1, 2, 3], ["one", "two", "three"]))
func pairedValues(number: Int, name: String) {
// Tests: (1,"one"), (2,"two"), (3,"three")
}Benefits Over For-Loops
| For-Loop | Parameterized |
|---|---|
| Stops on first failure | All arguments run |
| Unclear which value failed | Each argument shown separately |
| Sequential execution | Parallel execution |
| Can't re-run single case | Re-run individual arguments |
---
Fast Tests: Architecture for Testability
Strategy 1: Swift Package for Logic (Fastest)
Extract app logic into a Swift Package. Tests run with swift test (~0.4s) instead of xcodebuild test (~25s) — no simulator, no app launch. This is the key enabler for TDD in Claude Code hooks.
Step 1: Create Package.swift
Create the package directory alongside your .xcodeproj:
// MyAppCore/Package.swift
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "MyAppCore",
platforms: [.iOS(.v18), .macOS(.v15)],
products: [
.library(name: "MyAppCore", targets: ["MyAppCore"]),
],
targets: [
.target(name: "MyAppCore"),
.testTarget(name: "MyAppCoreTests", dependencies: ["MyAppCore"]),
]
)Step 2: Link Package to App
Create an .xcworkspace containing both the app project and the package: 1. File → New → Workspace 2. Drag your .xcodeproj into the workspace 3. File → Add Package Dependencies → Add Local → select MyAppCore/ 4. Add MyAppCore framework to your app target's "Frameworks, Libraries, and Embedded Content"
Step 3: Move Logic, Expose Root View
Move models, services, and view models into MyAppCore/Sources/MyAppCore/. Types used by the app must be public. Create a public root view that accepts dependencies via injection:
// In MyAppCore
public struct MyAppRootView: View {
@State private var appState: AppStateController
public init(modelContainer: ModelContainer) {
_appState = State(initialValue: AppStateController(container: modelContainer))
}
public var body: some View { /* ... */ }
}Step 4: Thin-Shell App.swift
The app target becomes a thin shell that imports the package and delegates (see axiom-design (skills/app-composition.md) for the full thin-shell principle):
import SwiftUI
import MyAppCore
@main
struct MyApp: App {
let container = try! ModelContainer(for: /* schemas */)
var body: some Scene {
WindowGroup {
MyAppRootView(modelContainer: container)
}
}
}What Stays vs What Moves
| Stays in App Target | Moves to Package |
|---|---|
@main App.swift (thin shell) | Models, view models, services |
| Asset catalogs, resources | Business logic, algorithms |
| Info.plist, entitlements | Navigation, state management |
| Launch screen | Utilities, extensions |
Tests use @testable import MyAppCore for internal access.
Running Tests
cd MyAppCore
swift test # All tests (~0.4s)
swift test --filter MyAppCoreTests.UserTests # Single suiteFor project-level scripts separating unit from UI tests:
# script/test
#!/bin/bash
case "${1:-unit}" in
unit) cd MyAppCore && swift test ;;
ui) xcodebuild test -workspace MyApp.xcworkspace \
-scheme MyApp -destination 'platform=iOS Simulator,name=iPhone 16' ;;
esacProgressive Extraction for Existing Projects
For apps that can't extract everything at once, move modules incrementally:
Phase 1: Leaf Modules First
Start with code that has no dependencies on the app target:
- Data models and DTOs
- Networking layer (API clients, request builders)
- Business logic and validation rules
- Utility extensions
Phase 2: Break Circular Dependencies
If package code needs to call back into app-owned types: 1. Define a protocol in the package (the package owns the abstraction) 2. Inject a conforming implementation from the app target at startup 3. Move the implementation into the package once all its dependencies are in the package
Phase 3: Maintain Both Test Targets
During transition, keep two test targets:
MyAppCoreTests— runs withswift test(extracted logic)MyAppTests— runs withxcodebuild test(remaining app-level tests)
Gradually migrate tests from MyAppTests to MyAppCoreTests as you extract their source files.
Goal: Each extraction should leave the app building and all tests passing. Never extract more than one module boundary at a time.
Strategy 2: Framework with No Host Application
For code that must stay in the app project:
1. Create a framework target (File → New → Target → Framework) 2. Move model code into the framework 3. Make types public that need external access 4. Add imports in files using the framework 5. Set Host Application to "None" in test target settings
Project Settings → Test Target → Testing
Host Application: None ← Key setting
☐ Allow testing Host Application APIsBuild+test time: ~3 seconds vs 20-60 seconds with app launch.
Strategy 3: Bypass SwiftUI App Launch
If you can't use a framework, bypass the app launch:
// Simple solution (no custom startup code)
@main
struct ProductionApp: App {
var body: some Scene {
WindowGroup {
if !isRunningTests {
ContentView()
}
}
}
private var isRunningTests: Bool {
NSClassFromString("XCTestCase") != nil
}
}// Thorough solution (custom startup code)
@main
struct MainEntryPoint {
static func main() {
if NSClassFromString("XCTestCase") != nil {
TestApp.main() // Empty app for tests
} else {
ProductionApp.main()
}
}
}
struct TestApp: App {
var body: some Scene {
WindowGroup { } // Empty
}
}---
Async Testing
Basic Async Tests
@Test func fetchUserReturnsData() async throws {
let user = try await userService.fetch(id: 123)
#expect(user.name == "Alice")
}Testing Callbacks with Continuations
// Convert completion handler to async
@Test func legacyAPIWorks() async throws {
let result = try await withCheckedThrowingContinuation { continuation in
legacyService.fetchData { result in
continuation.resume(with: result)
}
}
#expect(result.count > 0)
}Confirmations for Multiple Events
@Test func cookiesAreEaten() async {
await confirmation("cookie eaten", expectedCount: 10) { confirm in
let jar = CookieJar(count: 10)
jar.onCookieEaten = { confirm() }
await jar.eatAll()
}
}
// Confirm something never happens
await confirmation(expectedCount: 0) { confirm in
let cache = Cache()
cache.onEviction = { confirm() }
cache.store("small-item") // Should not trigger eviction
}Reliable Async Testing with Concurrency Extras
Problem: Async tests can be flaky due to scheduling unpredictability.
// ❌ Flaky: Task scheduling is unpredictable
@Test func loadingStateChanges() async {
let model = ViewModel()
let task = Task { await model.loadData() }
#expect(model.isLoading == true) // Often fails!
await task.value
}Solution: Use Point-Free's swift-concurrency-extras:
import ConcurrencyExtras
@Test func loadingStateChanges() async {
await withMainSerialExecutor {
let model = ViewModel()
let task = Task { await model.loadData() }
await Task.yield()
#expect(model.isLoading == true) // Deterministic!
await task.value
#expect(model.isLoading == false)
}
}Why it works: Serializes async work to main thread, making suspension points deterministic.
Deterministic Time with TestClock
Use Point-Free's swift-clocks to control time in tests:
import Clocks
@MainActor
class FeatureModel: ObservableObject {
@Published var count = 0
let clock: any Clock<Duration>
var timerTask: Task<Void, Error>?
init(clock: any Clock<Duration>) {
self.clock = clock
}
func startTimer() {
timerTask = Task {
while true {
try await clock.sleep(for: .seconds(1))
count += 1
}
}
}
}
// Test with controlled time
@Test func timerIncrements() async {
let clock = TestClock()
let model = FeatureModel(clock: clock)
model.startTimer()
await clock.advance(by: .seconds(1))
#expect(model.count == 1)
await clock.advance(by: .seconds(4))
#expect(model.count == 5)
model.timerTask?.cancel()
}Clock types:
TestClock— Advance time manually, deterministicImmediateClock— All sleeps return instantly (great for previews)UnimplementedClock— Fails if used (catch unexpected time dependencies)
---
Parallel Testing
Swift Testing runs tests in parallel by default.
When to Serialize
// Serialize tests in a suite that share external state
@Suite(.serialized)
struct DatabaseTests {
@Test func createUser() { }
@Test func deleteUser() { } // Runs after createUser
}
// Serialize parameterized test cases
@Test(.serialized, arguments: [1, 2, 3])
func sequentialProcessing(value: Int) { }Hidden Dependencies
// ❌ Bug: Tests depend on execution order
@Suite struct CookieTests {
static var cookie: Cookie?
@Test func bakeCookie() {
Self.cookie = Cookie() // Sets shared state
}
@Test func eatCookie() {
#expect(Self.cookie != nil) // Fails if runs first!
}
}
// ✅ Fixed: Each test is independent
@Suite struct CookieTests {
@Test func bakeCookie() {
let cookie = Cookie()
#expect(cookie.isBaked)
}
@Test func eatCookie() {
let cookie = Cookie()
cookie.eat()
#expect(cookie.isEaten)
}
}Random order helps expose these bugs — fix them rather than serialize.
---
Known Issues
Handle expected failures without noise:
@Test func featureUnderDevelopment() {
withKnownIssue("Backend not ready yet") {
try callUnfinishedAPI()
}
}
// Conditional known issue
@Test func platformSpecificBug() {
withKnownIssue("Fails on iOS 17.0") {
try reproduceEdgeCaseBug()
} when: {
ProcessInfo().operatingSystemVersion.majorVersion == 17
}
}Better than .disabled because:
- Test still compiles (catches syntax errors)
- You're notified when the issue is fixed
- Results show "expected failure" not "skipped"
---
Recording Issues — Severity & Cancellation (OS27)
Non-fatal warnings — Issue.record(_:severity:) (OS27) records an issue that does not fail the test when severity is .warning (the default is .error). Flag something noteworthy without turning the run red:
@Test func importsLegacyFormat() throws {
let result = try importer.load(legacyFixture)
if result.usedFallbackParser {
Issue.record("Fell back to the legacy parser", severity: .warning) // logged; test still passes
}
#expect(result.records.count == 42)
}Issue.Severity is .warning or .error (it's Comparable); an issue's isFailure is false for .warning. The old Issue.record(_:sourceLocation:) overload (no severity) is now deprecated — pass severity: explicitly.
Cancel a test mid-run — Test.cancel(_:) (OS27) throws to stop the current test immediately (it returns Never). In a parameterized test, this cancels just the current argument's run when it genuinely can't proceed — reported as cancelled, not failed or skipped:
@Test(arguments: configs)
func runs(_ config: Config) throws {
guard config.isSupportedHere else {
try Test.cancel("\(config.name) isn't supported on this device")
}
// … real test …
}Use cancellation only for "can't run here," not for assertions — a wrong result is still an #expect failure.
---
Migration from XCTest
Comparison Table
| XCTest | Swift Testing |
|---|---|
func testFoo() | @Test func foo() |
XCTAssertEqual(a, b) | #expect(a == b) |
XCTAssertNil(x) | #expect(x == nil) |
XCTAssertThrowsError | #expect(throws:) |
XCTUnwrap(x) | try #require(x) |
class FooTests: XCTestCase | @Suite struct FooTests |
setUp() / tearDown() | init / deinit |
continueAfterFailure = false | #require (per-expectation) |
addTeardownBlock | deinit or defer |
Keep Using XCTest For
- UI tests (XCUIApplication)
- Performance tests (XCTMetric)
- Objective-C tests
Migration Tips
1. Both frameworks can coexist in the same target 2. Migrate incrementally, one test file at a time 3. Consolidate similar XCTests into parameterized Swift tests 4. Single-test XCTestCase → global @Test function 5. Cross-framework assertions (`OS27`) — the 27 toolchain lets you call XCTAssert* from inside a @Test function and #expect/#require from inside an XCTestCase, smoothing incremental migration. A cross-framework assertion is reported as a warning by default; opt into hard failures via an Xcode build setting. (WWDC 2026-262 — confirm the build-setting name against your Xcode 27.)
---
Common Mistakes
❌ Mixing Assertions
// Don't mix XCTest and Swift Testing
@Test func badExample() {
XCTAssertEqual(1, 1) // ❌ Wrong framework
#expect(1 == 1) // ✅ Use this
}❌ Using Classes for Suites
// ❌ Avoid: Reference semantics can cause shared state bugs
@Suite class VideoTests { }
// ✅ Prefer: Value semantics isolate each test
@Suite struct VideoTests { }❌ Forgetting @MainActor
// ❌ May fail with Swift 6 strict concurrency
@Test func updateUI() async {
viewModel.updateTitle("New") // Data race warning
}
// ✅ Isolate to main actor
@Test @MainActor func updateUI() async {
viewModel.updateTitle("New")
}❌ Over-Serializing
// ❌ Don't serialize just because tests use async
@Suite(.serialized) struct APITests { } // Defeats parallelism
// ✅ Only serialize when tests truly share mutable state❌ XCTestCase with Swift 6.2 MainActor Default
Swift 6.2's default-actor-isolation = MainActor breaks XCTestCase:
// ❌ Error: Main actor-isolated initializer 'init()' has different
// actor isolation from nonisolated overridden declaration
final class PlaygroundTests: XCTestCase {
override func setUp() async throws {
try await super.setUp()
}
}Solution: Mark XCTestCase subclass as nonisolated:
// ✅ Works with MainActor default isolation
nonisolated final class PlaygroundTests: XCTestCase {
@MainActor
override func setUp() async throws {
try await super.setUp()
}
@Test @MainActor
func testSomething() async {
// Individual tests can be @MainActor
}
}Why: XCTestCase is Objective-C, not annotated for Swift concurrency. Its initializers are nonisolated, causing conflicts with MainActor-isolated subclasses.
Better solution: Migrate to Swift Testing (@Suite struct) which handles isolation properly.
---
Xcode Optimization for Fast Feedback
Turn Off Parallel XCTest Execution
Swift Testing runs in parallel by default; XCTest parallelization adds overhead:
Test Plan → Options → Parallelization → "Swift Testing Only"Turn Off Test Debugger
Attaching the debugger costs ~1 second per run:
Scheme → Edit Scheme → Test → Info → ☐ DebuggerDelete UI Test Templates
Xcode's default UI tests slow everything down. Remove them: 1. Delete UI test target (Project Settings → select target → -) 2. Delete UI test source folder
Disable dSYM for Debug Builds
Build Settings → Debug Information Format
Debug: DWARF
Release: DWARF with dSYM FileCheck Build Scripts
Run Script phases without defined inputs/outputs cause full rebuilds. Always specify:
- Input Files / Input File Lists
- Output Files / Output File Lists
---
Checklist
Before Writing Tests
- [ ] Identify what can move to a Swift Package (pure logic)
- [ ] Set up framework target if package isn't viable
- [ ] Configure Host Application: None for unit tests
Writing Tests
- [ ] Use
@Testwith clear display names - [ ] Use
#expectfor all assertions - [ ] Use
#requireto fail fast on preconditions - [ ] Use parameterization for similar test cases
- [ ] Add
.tags()for organization
Async Tests
- [ ] Mark test functions
asyncand useawait - [ ] Use
confirmation()for callback-based code - [ ] Consider
withMainSerialExecutorfor flaky tests
Parallel Safety
- [ ] Avoid shared mutable state between tests
- [ ] Use fresh instances in each test
- [ ] Only use
.serializedwhen absolutely necessary
---
Resources
WWDC: 2024-10179, 2024-10195, 2026-262, 2026-267
Docs: /testing, /testing/migratingfromxctest, /testing/testing-asynchronous-code, /testing/parallelization, /testing/issue/severity
GitHub: pointfreeco/swift-concurrency-extras, pointfreeco/swift-clocks
---
History: See git log for changes
Testing Async Code — Swift Testing Patterns
Modern patterns for testing async/await code with Swift Testing framework.
When to Use
✅ Use when:
- Writing tests for async functions
- Testing callback-based APIs with Swift Testing
- Migrating async XCTests to Swift Testing
- Testing MainActor-isolated code
- Need to verify events fire expected number of times
❌ Don't use when:
- XCTest-only project (use XCTestExpectation)
- UI automation tests (use XCUITest)
- Performance testing with metrics (use XCTest)
Key Differences from XCTest
| XCTest | Swift Testing |
|---|---|
XCTestExpectation | confirmation { } |
wait(for:timeout:) | await confirmation |
@MainActor implicit | @MainActor explicit |
| Serial by default | Parallel by default |
XCTAssertEqual() | #expect() |
continueAfterFailure | #require per-expectation |
Patterns
Pattern 1: Simple Async Function
@Test func fetchUser() async throws {
let user = try await api.fetchUser(id: 1)
#expect(user.name == "Alice")
}Pattern 2: Completion Handler → Continuation
For APIs without async overloads:
@Test func legacyAPI() async throws {
let result = try await withCheckedThrowingContinuation { continuation in
legacyFetch { result, error in
if let result {
continuation.resume(returning: result)
} else {
continuation.resume(throwing: error!)
}
}
}
#expect(result.isValid)
}Pattern 3: Single Callback with confirmation
When a callback should fire exactly once:
@Test func notificationFires() async {
await confirmation { confirm in
NotificationCenter.default.addObserver(
forName: .didUpdate,
object: nil,
queue: .main
) { _ in
confirm() // Must be called exactly once
}
triggerUpdate()
}
}Pattern 4: Multiple Callbacks with expectedCount
@Test func delegateCalledMultipleTimes() async {
await confirmation(expectedCount: 3) { confirm in
delegate.onProgress = { progress in
confirm() // Called 3 times
}
startDownload() // Triggers 3 progress updates
}
}Pattern 5: Verify Callback Never Fires
@Test func noErrorCallback() async {
await confirmation(expectedCount: 0) { confirm in
delegate.onError = { _ in
confirm() // Should never be called
}
performSuccessfulOperation()
}
}Pattern 6: MainActor Tests
@Test @MainActor func viewModelUpdates() async {
let vm = ViewModel()
await vm.load()
#expect(vm.items.count > 0)
#expect(vm.isLoading == false)
}Pattern 7: Timeout Control
@Test(.timeLimit(.seconds(5)))
func slowOperation() async throws {
try await longRunningTask()
}Pattern 8: Testing Throws
@Test func invalidInputThrows() async throws {
await #expect(throws: ValidationError.self) {
try await validate(input: "")
}
}
// Specific error
@Test func specificError() async throws {
await #expect(throws: NetworkError.notFound) {
try await api.fetch(id: -1)
}
}Pattern 9: Optional Unwrapping with #require
@Test func firstVideo() async throws {
let videos = try await videoLibrary.videos()
let first = try #require(videos.first) // Fails if nil
#expect(first.duration > 0)
}Pattern 10: Parameterized Async Tests
@Test("Video loading", arguments: [
"Beach.mov",
"Mountain.mov",
"City.mov"
])
func loadVideo(fileName: String) async throws {
let video = try await Video.load(fileName)
#expect(video.isPlayable)
}Arguments run in parallel automatically.
Parallel Test Execution
Swift Testing runs tests in parallel by default (unlike XCTest).
Handling Shared State
// ❌ Shared mutable state — race condition
var sharedCounter = 0
@Test func test1() async {
sharedCounter += 1 // Data race!
}
@Test func test2() async {
sharedCounter += 1 // Data race!
}
// ✅ Each test gets fresh instance
struct CounterTests {
var counter = Counter() // Fresh per test
@Test func increment() {
counter.increment()
#expect(counter.value == 1)
}
}Forcing Serial Execution
When tests must run sequentially:
@Suite("Database tests", .serialized)
struct DatabaseTests {
@Test func createRecord() async { /* ... */ }
@Test func readRecord() async { /* ... */ } // After create
@Test func deleteRecord() async { /* ... */ } // After read
}Note: Other unrelated tests still run in parallel.
Common Mistakes
Mistake 1: Using sleep Instead of confirmation
// ❌ Flaky — arbitrary wait time
@Test func eventFires() async {
setupEventHandler()
try await Task.sleep(for: .seconds(1)) // Hope it happened?
#expect(eventReceived)
}
// ✅ Deterministic — waits for actual event
@Test func eventFires() async {
await confirmation { confirm in
onEvent = { confirm() }
triggerEvent()
}
}Mistake 2: Forgetting @MainActor on UI Tests
// ❌ Data race — ViewModel may be MainActor
@Test func viewModel() async {
let vm = ViewModel()
await vm.load() // May cause data race warnings
}
// ✅ Explicit isolation
@Test @MainActor func viewModel() async {
let vm = ViewModel()
await vm.load()
}Mistake 3: Missing confirmation for Callbacks
// ❌ Test passes immediately — doesn't wait for callback
@Test func callback() async {
api.fetch { result in
#expect(result.isSuccess) // Never executed before test ends
}
}
// ✅ Waits for callback
@Test func callback() async {
await confirmation { confirm in
api.fetch { result in
#expect(result.isSuccess)
confirm()
}
}
}Mistake 4: Not Handling Parallel Execution
// ❌ Tests interfere with each other
@Test func writeFile() async {
try! "data".write(to: sharedFileURL, atomically: true, encoding: .utf8)
}
@Test func readFile() async {
let data = try! String(contentsOf: sharedFileURL) // May fail!
}
// ✅ Use unique files or .serialized
@Test func writeAndRead() async {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
try! "data".write(to: url, atomically: true, encoding: .utf8)
let data = try! String(contentsOf: url)
#expect(data == "data")
}Migration from XCTest
XCTestExpectation → confirmation
// XCTest
func testFetch() {
let expectation = expectation(description: "fetch")
api.fetch { result in
XCTAssertNotNil(result)
expectation.fulfill()
}
wait(for: [expectation], timeout: 5)
}
// Swift Testing
@Test func fetch() async {
await confirmation { confirm in
api.fetch { result in
#expect(result != nil)
confirm()
}
}
}Async setUp → Suite init
// XCTest
class MyTests: XCTestCase {
var service: Service!
override func setUp() async throws {
service = try await Service.create()
}
}
// Swift Testing
struct MyTests {
let service: Service
init() async throws {
service = try await Service.create()
}
@Test func example() async {
// Use self.service
}
}Resources
WWDC: 2024-10179, 2024-10195
Docs: /testing, /testing/confirmation
Skills: See skills/swift-testing.md
Recording UI Automation (Xcode 26+)
Guide to Xcode 26's Recording UI Automation feature for creating UI tests through user interaction recording.
The Three-Phase Workflow
From WWDC 2025-344:
┌─────────────────────────────────────────────────────────────┐
│ UI Automation Workflow │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. RECORD ──────► Interact with app in Simulator │
│ Xcode captures as Swift test code │
│ │
│ 2. REPLAY ──────► Run across devices, languages, configs │
│ Using test plans for multi-config │
│ │
│ 3. REVIEW ──────► Watch video recordings in test report │
│ Analyze failures with screenshots │
│ │
└─────────────────────────────────────────────────────────────┘Phase 1: Recording
Starting a Recording
1. Open your UI test file in Xcode 2. Place cursor inside a test method 3. Debug → Record UI Automation (or use the record button) 4. App launches in Simulator 5. Perform interactions - Xcode generates code 6. Stop recording when done
What Gets Recorded
- Taps on buttons, cells, controls
- Text input into text fields
- Swipes and scrolling
- Gestures (pinch, rotate)
- Hardware button presses (Home, volume)
Generated Code Example
// Xcode generates this from your interactions
func testLoginFlow() {
let app = XCUIApplication()
app.launch()
// Recorded: Tap email field, type email
app.textFields["Email"].tap()
app.textFields["Email"].typeText("user@example.com")
// Recorded: Tap password field, type password
app.secureTextFields["Password"].tap()
app.secureTextFields["Password"].typeText("password123")
// Recorded: Tap login button
app.buttons["Login"].tap()
}Enhancing Recorded Code
Critical: Recorded code is often fragile. Always enhance it for stability.
1. Add Accessibility Identifiers
Recorded code uses labels which break with localization:
// RECORDED (fragile - breaks with localization)
app.buttons["Login"].tap()
// ENHANCED (stable - uses identifier)
app.buttons["loginButton"].tap()Add identifiers in your app code:
// SwiftUI
Button("Login") { ... }
.accessibilityIdentifier("loginButton")
// UIKit
loginButton.accessibilityIdentifier = "loginButton"2. Add waitForExistence
Recorded code assumes elements exist immediately:
// RECORDED (may fail if app is slow)
app.buttons["Login"].tap()
// ENHANCED (waits for element)
let loginButton = app.buttons["loginButton"]
XCTAssertTrue(loginButton.waitForExistence(timeout: 5))
loginButton.tap()3. Add Assertions
Recorded code just performs actions without verification:
// RECORDED (no verification)
app.buttons["Login"].tap()
// ENHANCED (with assertion)
app.buttons["loginButton"].tap()
let welcomeLabel = app.staticTexts["welcomeLabel"]
XCTAssertTrue(welcomeLabel.waitForExistence(timeout: 10),
"Welcome screen should appear after login")4. Use Shorter Queries
Recorded code may have overly specific queries:
// RECORDED (too specific)
app.tables.cells.element(boundBy: 0).buttons["Action"].tap()
// ENHANCED (simpler)
app.buttons["actionButton"].tap()Query Selection Guidelines
From WWDC 2025-344:
| Scenario | Problem | Solution |
|---|---|---|
| Localized strings | "Login" changes by language | Use accessibilityIdentifier |
| Deeply nested views | Long query chains break easily | Use shortest possible query |
| Dynamic content | Cell content changes | Use identifier or generic query |
| Multiple matches | Query returns many elements | Add unique identifier |
Best Practices
1. Prefer identifiers over labels 2. Use the shortest query that works 3. Avoid index-based queries (element(boundBy: 0)) 4. Add identifiers to dynamic content
Phase 2: Replay with Test Plans
Test plans allow running the same tests across multiple configurations.
Creating a Test Plan
1. File → New → File → Test Plan 2. Add test targets 3. Configure configurations
Test Plan Structure
{
"configurations": [
{
"name": "iPhone - English",
"options": {
"targetForVariableExpansion": {
"containerPath": "container:MyApp.xcodeproj",
"identifier": "MyApp"
},
"language": "en",
"region": "US"
}
},
{
"name": "iPhone - Spanish",
"options": {
"language": "es",
"region": "ES"
}
},
{
"name": "iPhone - Dark Mode",
"options": {
"userInterfaceStyle": "dark"
}
},
{
"name": "iPad - Landscape",
"options": {
"defaultTestExecutionTimeAllowance": 120,
"testTimeoutsEnabled": true
}
}
],
"defaultOptions": {
"targetForVariableExpansion": {
"containerPath": "container:MyApp.xcodeproj",
"identifier": "MyApp"
}
},
"testTargets": [
{
"target": {
"containerPath": "container:MyApp.xcodeproj",
"identifier": "MyAppUITests",
"name": "MyAppUITests"
}
}
],
"version": 1
}Configuration Options
| Option | Purpose |
|---|---|
language | Test localization |
region | Test regional formatting |
userInterfaceStyle | Test dark/light mode |
targetForVariableExpansion | App target for configuration |
testTimeoutsEnabled | Enable timeout enforcement |
defaultTestExecutionTimeAllowance | Timeout in seconds |
Running with Test Plan
# Command line
xcodebuild test \
-scheme "MyApp" \
-testPlan "MyTestPlan" \
-destination "platform=iOS Simulator,name=iPhone 16" \
-resultBundlePath /tmp/results.xcresult
# In Xcode
# Product → Test Plan → Select your plan
# Then Cmd+U to run testsPhase 3: Review
Test Report Features
After tests complete:
1. View test results in Report Navigator 2. Watch video recordings of each test 3. See screenshots at failure points 4. Analyze timeline of actions
Enabling Attachments
In test plan or scheme:
"options": {
"systemAttachmentLifetime": "keepAlways",
"userAttachmentLifetime": "keepAlways"
}Capturing Custom Screenshots
func testCheckout() {
// ... actions ...
// Manual screenshot at specific point
let screenshot = app.screenshot()
let attachment = XCTAttachment(screenshot: screenshot)
attachment.name = "Checkout Confirmation"
attachment.lifetime = .keepAlways
add(attachment)
}Common Patterns
Login Flow Template
func testLoginWithValidCredentials() throws {
let app = XCUIApplication()
app.launch()
// Navigate to login
let showLoginButton = app.buttons["showLoginButton"]
XCTAssertTrue(showLoginButton.waitForExistence(timeout: 5))
showLoginButton.tap()
// Enter credentials
let emailField = app.textFields["emailTextField"]
XCTAssertTrue(emailField.waitForExistence(timeout: 5))
emailField.tap()
emailField.typeText("test@example.com")
let passwordField = app.secureTextFields["passwordTextField"]
passwordField.tap()
passwordField.typeText("password123")
// Submit
app.buttons["loginButton"].tap()
// Verify success
let welcomeScreen = app.staticTexts["welcomeLabel"]
XCTAssertTrue(welcomeScreen.waitForExistence(timeout: 10))
}Navigation Flow Template
func testNavigateToSettings() throws {
let app = XCUIApplication()
app.launch()
// Open tab bar item
app.tabBars.buttons["Settings"].tap()
// Verify navigation
let settingsTitle = app.navigationBars["Settings"]
XCTAssertTrue(settingsTitle.waitForExistence(timeout: 5))
// Navigate deeper
app.tables.cells["Account"].tap()
XCTAssertTrue(app.navigationBars["Account"].exists)
}Form Validation Template
func testFormValidation() throws {
let app = XCUIApplication()
app.launch()
// Submit empty form
app.buttons["submitButton"].tap()
// Verify error appears
let errorAlert = app.alerts["Error"]
XCTAssertTrue(errorAlert.waitForExistence(timeout: 5))
XCTAssertTrue(errorAlert.staticTexts["Please fill all fields"].exists)
// Dismiss alert
errorAlert.buttons["OK"].tap()
}Troubleshooting
Recording Doesn't Start
1. Ensure you're in a test method 2. Check simulator is available 3. Verify app builds and runs 4. Try restarting Xcode
Recorded Code Doesn't Work
1. Add waitForExistence before interactions 2. Check accessibility identifiers are set 3. Simplify queries to shortest form 4. Run app manually to verify flow works
Tests Pass Locally, Fail in CI
1. Increase timeouts for slower CI machines 2. Add explicit waits for animations 3. Check simulator configuration matches 4. Disable animations in test setup:
app.launchArguments = ["--disable-animations"]Anti-Patterns
Don't Use Raw Recorded Code in CI
// BAD - Raw recorded code
app.buttons["Login"].tap()
app.textFields["Email"].typeText("user@example.com")
// GOOD - Enhanced for CI
let loginButton = app.buttons["loginButton"]
XCTAssertTrue(loginButton.waitForExistence(timeout: 10))
loginButton.tap()Don't Hardcode Coordinates
// BAD - Coordinates from recording
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
// GOOD - Use element queries
app.buttons["centerButton"].tap()Don't Skip Assertions
// BAD - Actions only
app.buttons["Login"].tap()
sleep(2) // Hope it works
// GOOD - Verify outcomes
app.buttons["loginButton"].tap()
XCTAssertTrue(app.staticTexts["Welcome"].waitForExistence(timeout: 10))Resources
WWDC: 2025-344, 2024-10206, 2019-413
Docs: /xcode/testing/recording-ui-tests, /xctest/xcuiapplication
Skills: See skills/xctest-automation.md, skills/ui-testing.md
UI Testing
Overview
Wait for conditions, not arbitrary timeouts. Core principle Flaky tests come from guessing how long operations take. Condition-based waiting eliminates race conditions.
NEW in WWDC 2025: Recording UI Automation allows you to record interactions, replay across devices/languages, and review video recordings of test runs.
Example Prompts
These are real questions developers ask that this skill is designed to answer:
1. "My UI tests pass locally on my Mac but fail in CI. How do I make them more reliable?"
→ The skill shows condition-based waiting patterns that work across devices/speeds, eliminating CI timing differences
2. "My tests use sleep(2) and sleep(5) but they're still flaky. How do I replace arbitrary timeouts with real conditions?"
→ The skill demonstrates waitForExistence, XCTestExpectation, and polling patterns for data loads, network requests, and animations
3. "I just recorded a test using Xcode 26's Recording UI Automation. How do I review the video and debug failures?"
→ The skill covers Video Debugging workflows to analyze recordings and find the exact step where tests fail
4. "My test is failing on iPad but passing on iPhone. How do I write tests that work across all device sizes?"
→ The skill explains multi-factor testing strategies and device-independent predicates for robust cross-device testing
5. "I want to write tests that are not flaky. What are the critical patterns I need to know?"
→ The skill provides condition-based waiting templates, accessibility-first patterns, and the decision tree for reliable test architecture
---
Red Flags — Test Reliability Issues
If you see ANY of these, suspect timing issues:
- Tests pass locally, fail in CI (timing differences)
- Tests sometimes pass, sometimes fail (race conditions)
- Tests use
sleep()orThread.sleep()(arbitrary delays) - Tests fail with "UI element not found" then pass on retry
- Long test runs (waiting for worst-case scenarios)
Quick Decision Tree
Test failing?
├─ Element not found?
│ └─ Use waitForExistence(timeout:) not sleep()
├─ Passes locally, fails CI?
│ └─ Replace sleep() with condition polling
├─ Animation causing issues?
│ └─ Wait for animation completion, don't disable
└─ Network request timing?
└─ Use XCTestExpectation or waitForExistenceCore Pattern: Condition-Based Waiting
❌ WRONG (Arbitrary Timeout):
func testButtonAppears() {
app.buttons["Login"].tap()
sleep(2) // ❌ Guessing it takes 2 seconds
XCTAssertTrue(app.buttons["Dashboard"].exists)
}✅ CORRECT (Wait for Condition):
func testButtonAppears() {
app.buttons["Login"].tap()
let dashboard = app.buttons["Dashboard"]
XCTAssertTrue(dashboard.waitForExistence(timeout: 5))
}Common UI Testing Patterns
Pattern 1: Waiting for Elements
// Wait for element to appear
func waitForElement(_ element: XCUIElement, timeout: TimeInterval = 5) -> Bool {
return element.waitForExistence(timeout: timeout)
}
// Usage
XCTAssertTrue(waitForElement(app.buttons["Submit"]))Pattern 2: Waiting for Element to Disappear
func waitForElementToDisappear(_ element: XCUIElement, timeout: TimeInterval = 5) -> Bool {
let predicate = NSPredicate(format: "exists == false")
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
let result = XCTWaiter().wait(for: [expectation], timeout: timeout)
return result == .completed
}
// Usage
XCTAssertTrue(waitForElementToDisappear(app.activityIndicators["Loading"]))Pattern 3: Waiting for Specific State
func waitForButton(_ button: XCUIElement, toBeEnabled enabled: Bool, timeout: TimeInterval = 5) -> Bool {
let predicate = NSPredicate(format: "isEnabled == %@", NSNumber(value: enabled))
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: button)
let result = XCTWaiter().wait(for: [expectation], timeout: timeout)
return result == .completed
}
// Usage
let submitButton = app.buttons["Submit"]
XCTAssertTrue(waitForButton(submitButton, toBeEnabled: true))
submitButton.tap()Pattern 4: Accessibility Identifiers
Set in app:
Button("Submit") {
// action
}
.accessibilityIdentifier("submitButton")Use in tests:
func testSubmitButton() {
let submitButton = app.buttons["submitButton"] // Uses identifier, not label
XCTAssertTrue(submitButton.waitForExistence(timeout: 5))
submitButton.tap()
}Why: Accessibility identifiers don't change with localization, remain stable across UI updates.
Pattern 5: Network Request Delays
func testDataLoads() {
app.buttons["Refresh"].tap()
// Wait for loading indicator to disappear
let loadingIndicator = app.activityIndicators["Loading"]
XCTAssertTrue(waitForElementToDisappear(loadingIndicator, timeout: 10))
// Now verify data loaded
XCTAssertTrue(app.cells.count > 0)
}Pattern 6: Animation Handling
func testAnimatedTransition() {
app.buttons["Next"].tap()
// Wait for destination view to appear
let destinationView = app.otherElements["DestinationView"]
XCTAssertTrue(destinationView.waitForExistence(timeout: 2))
// Do NOT add a fixed sleep to "let the animation settle." XCUITest
// auto-waits for an element to become hittable before interacting, so the
// next action already waits for the right condition. If you must assert
// mid-transition, wait on a real post-animation element, never a delay.
let primaryButton = app.buttons["Primary"]
XCTAssertTrue(primaryButton.waitForExistence(timeout: 2))
}Testing Checklist
Before Writing Tests
- [ ] Use accessibility identifiers for all interactive elements
- [ ] Avoid hardcoded labels (use identifiers instead)
- [ ] Plan for network delays and animations
- [ ] Choose appropriate timeouts (2s UI, 10s network)
When Writing Tests
- [ ] Use
waitForExistence()notsleep() - [ ] Use predicates for complex conditions
- [ ] Test both success and failure paths
- [ ] Make tests independent (can run in any order)
After Writing Tests
- [ ] Run tests 10 times locally (catch flakiness)
- [ ] Run tests on slowest supported device
- [ ] Run tests in CI environment
- [ ] Check test duration (if >30s per test, optimize)
Xcode UI Testing Tips
Launch Arguments for Testing
func testExample() {
let app = XCUIApplication()
app.launchArguments = ["UI-Testing"]
app.launch()
}In app code:
if ProcessInfo.processInfo.arguments.contains("UI-Testing") {
// Use mock data, skip onboarding, etc.
}Faster Test Execution
override func setUpWithError() throws {
continueAfterFailure = false // Stop on first failure
}Debugging Failing Tests
func testExample() {
// Take screenshot on failure
addUIInterruptionMonitor(withDescription: "Alert") { alert in
alert.buttons["OK"].tap()
return true
}
// Print element hierarchy
print(app.debugDescription)
}Common Mistakes
❌ Using sleep() for Everything
sleep(5) // ❌ Wastes time if operation completes in 1s❌ Not Handling Animations
app.buttons["Next"].tap()
XCTAssertTrue(app.buttons["Back"].exists) // ❌ May fail during animation❌ Hardcoded Text Labels
app.buttons["Submit"].tap() // ❌ Breaks with localization❌ Tests Depend on Each Other
// ❌ Test 2 assumes Test 1 ran first
func test1_Login() { /* ... */ }
func test2_ViewDashboard() { /* assumes logged in */ }❌ No Timeout Strategy
element.waitForExistence(timeout: 100) // ❌ Too long
element.waitForExistence(timeout: 0.1) // ❌ Too shortUse appropriate timeouts:
- UI animations: 2-3 seconds
- Network requests: 10 seconds
- Complex operations: 30 seconds max
Real-World Impact
Before (using sleep()):
- Test suite: 15 minutes (waiting for worst-case)
- Flaky tests: 20% failure rate
- CI failures: 50% require retry
After (condition-based waiting):
- Test suite: 5 minutes (waits only as needed)
- Flaky tests: <2% failure rate
- CI failures: <5% require retry
Key insight Tests finish faster AND are more reliable when waiting for actual conditions instead of guessing times.
---
Recording UI Automation
Overview
NEW in Xcode 26: Record, replay, and review UI automation tests with video recordings.
Three Phases: 1. Record — Capture interactions (taps, swipes, hardware button presses) as Swift code 2. Replay — Run across multiple devices, languages, regions, orientations 3. Review — Watch video recordings, analyze failures, view UI element overlays
Supported Platforms: iOS, iPadOS, macOS, watchOS, tvOS, visionOS (Designed for iPad)
How UI Automation Works
Key Principles:
- UI automation interacts with your app as a person does using gestures and hardware events
- Runs completely independently from your app (app models/data not directly accessible)
- Uses accessibility framework as underlying technology
- Tells OS which gestures to perform, then waits for completion synchronously one at a time
Actions include:
- Launching your app
- Interacting with buttons and navigation
- Setting system state (Dark Mode, localization, etc.)
- Setting simulated location
Accessibility is the Foundation
Critical Understanding: Accessibility provides information directly to UI automation.
What accessibility sees:
- Element types (button, text, image, etc.)
- Labels (visible text)
- Values (current state for checkboxes, etc.)
- Frames (element positions)
- Identifiers (accessibility identifiers — NOT localized)
Best Practice: Great accessibility experience = great UI automation experience.
Preparing Your App for Recording
Step 1: Add Accessibility Identifiers
SwiftUI:
Button("Submit") {
// action
}
.accessibilityIdentifier("submitButton")
// Make identifiers specific to instance
List(landmarks) { landmark in
LandmarkRow(landmark)
.accessibilityIdentifier("landmark-\(landmark.id)")
}UIKit:
let button = UIButton()
button.accessibilityIdentifier = "submitButton"
// Use index for table cells
cell.accessibilityIdentifier = "cell-\(indexPath.row)"Good identifiers are:
- ✅ Unique within entire app
- ✅ Descriptive of element contents
- ✅ Static (don't react to content changes)
- ✅ Not localized (same across languages)
Why identifiers matter:
- Titles/descriptions may change, identifiers remain stable
- Work across localized strings
- Uniquely identify elements with dynamic content
Pro Tip: Use Xcode coding assistant to add identifiers:
Prompt: "Add accessibility identifiers to the relevant parts of this view"Step 2: Review Accessibility with Accessibility Inspector
Launch Accessibility Inspector:
- Xcode menu → Open Developer Tool → Accessibility Inspector
- Or: Launch from Spotlight
Features: 1. Element Inspector — List accessibility values for any view 2. Property details — Click property name for documentation 3. Platform support — Works on all Apple platforms
What to check:
- Elements have labels
- Interactive elements have types (button, not just text)
- Values set for stateful elements (checkboxes, toggles)
- Identifiers set for elements with dynamic/localized content
Step 3: Add UI Testing Target
1. Open project settings in Xcode 2. Click "+" below targets list 3. Select UI Testing Bundle 4. Click Finish
Result: New UI test folder with template tests added to project.
Recording Interactions
Starting a Recording (Xcode 26)
1. Open UI test source file 2. Popover appears explaining how to start recording (first time only) 3. Click "Start Recording" button in editor gutter 4. Xcode builds and launches app in Simulator/device
During Recording:
- Interact with app normally (taps, swipes, text entry, etc.)
- Code representing interactions appears in source editor in real-time
- Recording updates as you type (e.g., text field entries)
Stopping Recording:
- Click "Stop Run" button in Xcode
Example Recording Session
func testCreateAustralianCollection() {
let app = XCUIApplication()
app.launch()
// Tap "Collections" tab (recorded automatically)
app.tabBars.buttons["Collections"].tap()
// Tap "+" to add new collection
app.navigationBars.buttons["Add"].tap()
// Tap "Edit" button
app.buttons["Edit"].tap()
// Type collection name
app.textFields.firstMatch.tap()
app.textFields.firstMatch.typeText("Max's Australian Adventure")
// Tap "Edit Landmarks"
app.buttons["Edit Landmarks"].tap()
// Add landmarks
app.tables.cells.containing(.staticText, identifier:"Great Barrier Reef").buttons["Add"].tap()
app.tables.cells.containing(.staticText, identifier:"Uluru").buttons["Add"].tap()
// Tap checkmark to save
app.navigationBars.buttons["Done"].tap()
}Reviewing Recorded Code
After recording, review and adjust queries:
Multiple Options: Each line has dropdown showing alternative ways to address element.
Selection Recommendations: 1. For localized strings (text, button labels): Choose accessibility identifier if available 2. For deeply nested views: Choose shortest query (stays resilient as app changes) 3. For dynamic content (timestamps, temperature): Use generic query or identifier
Example:
// Recorded options for text field:
app.textFields["Collection Name"] // ❌ Breaks if label localizes
app.textFields["collectionNameField"] // ✅ Uses identifier
app.textFields.element(boundBy: 0) // ✅ Position-based
app.textFields.firstMatch // ✅ Generic, shortestChoose shortest, most stable query for your needs.
Adding Validations
After recording, add assertions to verify expected behavior:
Wait for Existence
// Validate collection created
let collection = app.buttons["Max's Australian Adventure"]
XCTAssertTrue(collection.waitForExistence(timeout: 5))Wait for Property Changes
// Wait for button to become enabled (first arg is a key path, not a literal)
let submitButton = app.buttons["Submit"]
XCTAssertTrue(submitButton.wait(for: \.isEnabled, toEqual: true, timeout: 5))Combine with XCTAssert
// Fail test if element doesn't appear
let landmark = app.staticTexts["Great Barrier Reef"]
XCTAssertTrue(landmark.waitForExistence(timeout: 5), "Landmark should appear in collection")Advanced Automation APIs
Setup Device State
import XCTest
import CoreLocation // XCUILocation wraps CLLocation
final class DeviceStateUITests: XCTestCase {
private let app = XCUIApplication() // instance property, shared across tests
override func setUpWithError() throws {
// Device orientation
XCUIDevice.shared.orientation = .landscapeLeft
// Force appearance mode (UIKit reads this from launch arguments)
app.launchArguments += ["-UIUserInterfaceStyle", "Dark"]
app.launch()
// Simulate location — a settable property on the running device,
// NOT a launch argument. There is no `-SimulatedLocation` argument.
XCUIDevice.shared.location = XCUILocation(
location: CLLocation(latitude: 37.7749, longitude: -122.4194)
)
}
}Launch Arguments & Environment
func testWithMockData() {
let app = XCUIApplication()
// Pass arguments to app
app.launchArguments = ["-UI-Testing", "-UseMockData"]
// Set environment variables
app.launchEnvironment = ["API_URL": "https://mock.api.com"]
app.launch()
}In app code:
if ProcessInfo.processInfo.arguments.contains("-UI-Testing") {
// Use mock data, skip onboarding
}Custom URL Schemes
// Launch the target app to a specific URL (instance method, iOS 16.4+)
let app = XCUIApplication()
app.openURL(URL(string: "myapp://landmark/123")!)
// Open a URL with the system's default app, via XCUISystem on XCUIDevice
XCUIDevice.shared.system.openURL(URL(string: "https://example.com")!)Accessibility Audits in Tests
func testAccessibility() throws {
let app = XCUIApplication()
app.launch()
// Perform accessibility audit
try app.performAccessibilityAudit()
}Test Plans for Multiple Configurations
Test Plans let you:
- Include/exclude individual tests
- Set system settings (language, region, appearance)
- Configure test properties (timeouts, repetitions, parallelization)
- Associate with schemes for specific build settings
Creating Test Plan
1. Create new or use existing test plan 2. Add/remove tests on first screen 3. Switch to Configurations tab
Adding Multiple Languages
Configurations:
├─ English
├─ German (longer strings)
├─ Arabic (right-to-left)
└─ Hebrew (right-to-left)Each locale = separate configuration in test plan.
Settings:
- Focused for specific locale
- Shared across all configurations
Video & Screenshot Capture
In Configurations tab:
- Capture screenshots: On/Off
- Capture video: On/Off
- Keep media: "Only failures" or "On, and keep all"
Defaults: Videos/screenshots kept only for failing runs (for review).
"On, and keep all" use cases:
- Documentation
- Tutorials
- Marketing materials
Replaying Tests in Xcode Cloud
Xcode Cloud = built-in service for:
- Building app
- Running tests
- Uploading to App Store
- All in cloud without using team devices
Workflow configuration:
- Same test plan used locally
- Runs on multiple devices and configurations
- Videos/results available in App Store Connect
Viewing Results:
- Xcode: Xcode Cloud section
- App Store Connect: Xcode Cloud section
- See build info, logs, failure descriptions, video recordings
Team Access: Entire team can see run history and download results/videos.
Reviewing Test Results with Videos
Accessing Test Report
1. Click Test button in Xcode 2. Double-click failing run to see video + description
Features:
- Runs dropdown — Switch between video recordings of different configurations (languages, devices)
- Save video — Secondary click → Save
- Play/pause — Video playback with UI interaction overlays
- Timeline dots — UI interactions shown as dots on timeline
- Jump to failure — Click failure diamond on timeline
UI Element Overlay at Failure
At moment of failure:
- Click timeline failure point
- Overlay shows all UI elements present on screen
- Click any element to see code recommendations for addressing it
- Show All — See alternative examples
Workflow: 1. Identify what was actually present (vs what test expected) 2. Click element to get query code 3. Secondary click → Copy code 4. View Source → Go directly to test 5. Paste corrected code
Example:
// Test expected:
let button = app.buttons["Max's Australian Adventure"]
// But overlay shows it's actually text, not button:
let text = app.staticTexts["Max's Australian Adventure"] // ✅ CorrectRunning Test in Different Language
Click test diamond → Select configuration (e.g., Arabic) → Watch automation run in right-to-left layout.
Validates: Same automation works across languages/layouts.
Recording UI Automation Checklist
Before Recording
- [ ] Add accessibility identifiers to interactive elements
- [ ] Review app with Accessibility Inspector
- [ ] Add UI Testing Bundle target to project
- [ ] Plan workflow to record (user journey)
During Recording
- [ ] Interact naturally with app
- [ ] Record complete user journeys (not individual taps)
- [ ] Check code generates as you interact
- [ ] Stop recording when workflow complete
After Recording
- [ ] Review recorded code options (dropdown on each line)
- [ ] Choose stable queries (identifiers > labels)
- [ ] Add validations (waitForExistence, XCTAssert)
- [ ] Add setup code (device state, launch arguments)
- [ ] Run test to verify it passes
Test Plan Configuration
- [ ] Create/update test plan
- [ ] Add multiple language configurations
- [ ] Include right-to-left languages (Arabic, Hebrew)
- [ ] Configure video/screenshot capture settings
- [ ] Set appropriate timeouts for network tests
Running & Reviewing
- [ ] Run test locally across configurations
- [ ] Review video recordings for failures
- [ ] Use UI element overlay to debug failures
- [ ] Run in Xcode Cloud for team visibility
- [ ] Download and share videos if needed
Network Conditioning in Tests
Overview
UI tests can pass on fast networks but fail on 3G/LTE. Network Link Conditioner simulates real-world network conditions to catch timing-sensitive crashes.
Critical scenarios:
- ❌ iPad Pro over Wi-Fi (fast) → pass
- ❌ iPad Pro over 3G (slow) → crash
- ✅ Test both to catch device-specific failures
How conditioning actually works
Network Link Conditioner (NLC) is a host-level macOS System Settings pane. There is no `XCUIApplication` launch argument, launch environment, or `simctl`/`devicectl` subcommand that selects a profile — you enable conditioning around the test run, not from inside the test. Treat any launchArguments/launchEnvironment "network profile" switch as fiction; it is silently ignored.
Critical caveat NLC throttles the entire Mac. The Simulator shares the host network stack, so it inherits whatever NLC is doing — but conditioning cannot be scoped to one simulator or one app. SSH, package fetches, and everything else are throttled too while it runs.
digraph nlc {
"Where do tests run?" [shape=diamond];
"Where do tests run?" -> "NLC pane or dnctl/pfctl" [label="simulator (whole Mac)"];
"Where do tests run?" -> "Settings > Developer > NLC" [label="physical device"];
"Where do tests run?" -> "RocketSim Network Speed Control" [label="need sim-only isolation"];
}1. NLC pane (manual; simulator + host) — install via Xcode → Open Developer Tool → More Developer Tools → download "Additional Tools for Xcode" → install the package from the Hardware folder. Then System Settings → Network Link Conditioner, pick a profile (3G, Edge, LTE, DSL, 100% Loss, High Latency DNS, Very Bad Network), Start, run tests, Stop.
2. `dnctl`/`pfctl` (scriptable; CI — what NLC drives under the hood) — NLC is a GUI over BSD dummynet. For headless CI, drive it directly (needs sudo; conditions the whole host, same caveat):
# 3G-like: 1.6 Mbit/s, 150 ms latency, 1% packet loss
sudo dnctl pipe 1 config bw 1600Kbit/s delay 150 plr 0.01
printf 'dummynet-anchor "nlc"\nanchor "nlc"\n' | sudo pfctl -f -
echo 'dummynet out proto tcp from any to any pipe 1' | sudo pfctl -a nlc -f -
sudo pfctl -E
xcodebuild test -scheme MyApp -destination 'platform=iOS Simulator,name=iPhone 16 Pro'
# Teardown
sudo pfctl -a nlc -F all && sudo pfctl -d && sudo dnctl -q flushpfctl -f - replaces the active pf ruleset — fine on an ephemeral CI runner, but on a dev Mac that already runs pf, back up /etc/pf.conf first (and restore with sudo pfctl -f /etc/pf.conf).
3. Physical device — Settings → Developer → Network Link Conditioner (the Developer menu appears once the device has connected to Xcode). Built in, no install, and it conditions only that device.
4. Simulator-only isolation — to throttle just the Simulator without touching the rest of the Mac, RocketSim's Network Speed Control (third-party) scopes throttling to the Simulator app.
Because conditioning is external, the test code itself stays normal — it just needs realistic timeouts for the slow profile.
No-sudo, automatable conditioning (app-layer URLProtocol)
Methods 1–4 condition the network outside the app (host kernel or a GUI). For automated / CI / agent-driven testing with no sudo and no GUI, condition inside the app: register a custom URLProtocol on the URLSession that returns a canned response with injected latency (optional jitter), a byte-rate cap (low bitrate), and deterministic or probabilistic failures.
Trade-off: app-layer sees only this app's URLSession traffic (not third-party SDKs or raw sockets) and needs a test hook — but no sudo, no host change, and deterministic by default (jitter/failureRate opt into controlled randomness). It's the right default for unit/integration tests.
It models application-observable network behavior (how fast bytes arrive, whether a request fails) — not transport-layer packet loss, jitter shape, or retransmit dynamics, which sit below URLProtocol. For true packet-level fidelity, use the OS-level (dnctl) or toxiproxy paths. Mocker (WeTransfer) and OHHTTPStubs wrap this if you'd rather not hand-roll it.
import Foundation
import Synchronization
/// Returns a canned response with injected conditions — latency (+ jitter), a
/// byte-rate cap (low bitrate), and hard or probabilistic failures. No sudo,
/// in-process; deterministic unless jitter/failureRate are set.
final class ThrottlingURLProtocol: URLProtocol {
struct Conditions: Sendable {
var latency: TimeInterval = 0 // seconds before first byte
var jitter: TimeInterval = 0 // ± random seconds added to latency
var bytesPerSecond: Int? = nil // nil = unlimited (throughput cap)
var failure: URLError.Code? = nil // failure code (default .networkConnectionLost when only a rate is set)
var failureRate: Double = 0 // 0...1 chance of failing this request; a failure code alone = always fail
var body = Data() // canned response body (a whole, zero-based Data)
var statusCode = 200
}
// Set by the test before launch. startLoading runs off the main thread
// (an observed URLProtocol contract), so a lock keeps this Sendable-clean.
static let conditions = Mutex(Conditions())
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func stopLoading() {}
override func startLoading() {
guard let url = request.url else {
client?.urlProtocol(self, didFailWithError: URLError(.badURL)); return
}
let c = Self.conditions.withLock { $0 }
let delay = c.latency + (c.jitter > 0 ? Double.random(in: -c.jitter...c.jitter) : 0)
if delay > 0 { Thread.sleep(forTimeInterval: delay) } // off-main loading thread
// A failure code alone = always fail; a failureRate rolls per request (flaky).
let rate = c.failureRate > 0 ? c.failureRate : (c.failure != nil ? 1.0 : 0.0)
if rate > 0, Double.random(in: 0..<1) < rate {
client?.urlProtocol(self, didFailWithError: URLError(c.failure ?? .networkConnectionLost)); return
}
let response = HTTPURLResponse(url: url, statusCode: c.statusCode,
httpVersion: "HTTP/1.1", headerFields: nil)!
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
if let rate = c.bytesPerSecond, rate > 0 { // drip to simulate low bitrate
let chunk = max(rate / 10, 1)
var offset = 0
while offset < c.body.count {
let end = min(offset + chunk, c.body.count)
client?.urlProtocol(self, didLoad: Data(c.body[offset..<end]))
Thread.sleep(forTimeInterval: Double(end - offset) / Double(rate))
offset = end
}
} else {
client?.urlProtocol(self, didLoad: c.body)
}
client?.urlProtocolDidFinishLoading(self)
}
}Named profiles (NLC quotes bits/s; this harness is bytes/s, so ÷8):
extension ThrottlingURLProtocol.Conditions {
static let edge = Self(latency: 0.4, jitter: 0.1, bytesPerSecond: 30_000) // ~240 kbps
static let threeG = Self(latency: 0.1, jitter: 0.05, bytesPerSecond: 200_000) // ~1.6 Mbps
static let lte = Self(latency: 0.05, bytesPerSecond: 6_250_000) // ~50 Mbps
static let flaky = Self(failureRate: 0.3) // 30% of requests drop
static let offline = Self(failure: .notConnectedToInternet)
}Unit / integration tests — inject directly, no app hook:
let config = URLSessionConfiguration.ephemeral
config.protocolClasses = [ThrottlingURLProtocol.self]
ThrottlingURLProtocol.conditions.withLock {
$0 = .init(latency: 0.8, bytesPerSecond: 20_000, body: Data(#"{"ok":true}"#.utf8))
}
let session = URLSession(configuration: config) // inject into code under testUI tests — the harness lives in the app process, so gate it behind a launch argument the app reads at startup (works when UI tests stub the backend for determinism). To throttle a UI test's real backend traffic instead, use the OS-level paths (1–2) or the proxy below.
app.launchArguments += ["-NetworkProfile", "edge"]; app.launch() // in the test
// app startup, test builds only:
let args = ProcessInfo.processInfo.arguments
if let i = args.firstIndex(of: "-NetworkProfile"), i + 1 < args.count {
URLProtocol.registerClass(ThrottlingURLProtocol.self)
// map args[i+1] ("edge"/"3g"/"loss"/"offline") → Conditions, then set it:
ThrottlingURLProtocol.conditions.withLock { $0 = .init(latency: 0.4, bytesPerSecond: 30_000) }
}Proxy-level conditioning (toxiproxy) — optional, not bundled
When you must condition traffic the app's own URLSession doesn't own (third-party SDKs, raw sockets), or throttle a UI test's real backend, route it through toxiproxy — a TCP proxy whose "toxics" inject latency, bandwidth caps, and timeouts. Axiom does not ship it; it is an optional dependency you install. Skills/agents reaching for it MUST first check and, if absent, say the proxy path is unavailable and fall back to the URLProtocol harness above — never silently skip.
if command -v toxiproxy-server &> /dev/null && command -v toxiproxy-cli &> /dev/null; then
toxiproxy-server & # control API on :8474
toxiproxy-cli create api --listen localhost:6443 --upstream api.example.com:443
toxiproxy-cli toxic add api -t latency -a latency=400 -a jitter=100
toxiproxy-cli toxic add api -t bandwidth -a rate=30 # KB/s → low bitrate
else
echo "toxiproxy NOT installed - proxy conditioning unavailable until you install it."
echo " Install: brew install toxiproxy"
echo " Docs: https://github.com/Shopify/toxiproxy · https://formulae.brew.sh/formula/toxiproxy"
echo " Fallback: the in-process URLProtocol harness above needs no install."
fiHTTPS caveat toxiproxy forwards raw TCP, so it throttles encrypted bytes without MITM — but the app must connect to the proxy's host:port, which fails default TLS validation against a real public host (cert is for api.example.com, you connected to localhost). Clean only when the app's base URL is configurable to point at the proxy (a test backend, or a test-only trust override). No sudo either way. For app-traffic-only testing, prefer the URLProtocol path — no proxy, no cert wrinkle.
Real-World Example: Photo Upload with Network Throttling
❌ Without Network Conditioning:
func testPhotoUpload() {
app.buttons["Upload Photo"].tap()
// Passes locally (fast network)
XCTAssertTrue(app.staticTexts["Upload complete"].waitForExistence(timeout: 5))
}
// ✅ Passes locally, ❌ FAILS on 3G with timeout✅ With Network Conditioning:
func testPhotoUploadOn3G() {
let app = XCUIApplication()
// Network Link Conditioner running (3G profile)
app.launch()
app.buttons["Upload Photo"].tap()
// Increase timeout for 3G
XCTAssertTrue(app.staticTexts["Upload complete"].waitForExistence(timeout: 30))
// Verify no crash occurred
XCTAssertFalse(app.alerts.element.exists, "App should not crash on 3G")
}Key differences:
- Longer timeout (30s instead of 5s)
- Check for crashes
- Run on slowest expected network
---
Multi-Factor Testing: Device Size + Network Speed
The Problem
Tests can pass on device A but fail on device B due to layout differences + network delays. Multi-factor testing catches these combinations.
Common failure patterns:
- ✅ iPhone 14 Pro (compact, fast network)
- ❌ iPad Pro 12.9 (large, 3G network) → crashes
- ✅ iPhone 15 (compact, LTE)
- ❌ iPhone 12 (older GPU, 3G) → timeout
Running the same tests across devices
Device is not a test-plan setting — you select it with -destination at xcodebuild time (or in the scheme). Run one test plan against several destinations to cover the device matrix. Network is not a test-plan setting either — condition it externally (NLC or dnctl, above) for the run.
A test plan configuration varies launch arguments, environment variables, localization (language/region), simulated location, sanitizers, and code coverage — not device, not network.
Device matrix via destinations:
# device names: xcrun simctl list devicetypes
for dest in \
'platform=iOS Simulator,name=iPhone 16 Pro' \
'platform=iOS Simulator,name=iPad Pro 13-inch (M4)' ; do
xcodebuild test -scheme MyApp -testPlan UITests -destination "$dest"
donePair this with NLC/dnctl started beforehand to add a slow-network dimension (⚠️ large device + slow network is where most failures surface).
Programmatic Device-Specific Testing
import XCTest
import UIKit
final class MultiFactorUITests: XCTestCase {
private let app = XCUIApplication()
private var deviceModel: String { UIDevice.current.model }
// Larger/slower devices need longer waits — scale the timeout, never sleep.
private var loadTimeout: TimeInterval {
switch deviceModel {
case "iPad" where UIScreen.main.bounds.width > 1000: return 30 // iPad Pro
case "iPhone": return 10
default: return 15
}
}
override func setUpWithError() throws {
continueAfterFailure = false
app.launch()
}
func testListLoadingAcrossDevices() {
app.buttons["Refresh"].tap()
// Wait on a real condition with the device-scaled timeout. `count`
// alone is read eagerly and would race the load.
let firstCell = app.tables.cells.firstMatch
XCTAssertTrue(
firstCell.waitForExistence(timeout: loadTimeout),
"List should load on \(deviceModel)"
)
// No crash dialog
XCTAssertFalse(app.alerts.element.exists)
}
}Real-World Example: iPad Pro + 3G Crash
Scenario: App works on iPhone 14, crashes on iPad Pro over 3G.
Why it crashes: 1. iPad Pro has larger layout (landscape) 2. 3G network is slow (latency 100ms+) 3. Images don't load in time, layout engine crashes 4. Single-device testing misses this combo
Test that catches it:
func testLargeLayoutOn3G() {
let app = XCUIApplication()
// Running with Network Link Conditioner on 3G profile
app.launch()
// iPad Pro: Large grid of images
app.buttons["Browse"].tap()
// Wait longer for images on slow network
let firstImage = app.images["photoGrid-0"]
XCTAssertTrue(
firstImage.waitForExistence(timeout: 20),
"First image must load on slow network"
)
// Verify grid loaded without crash
// matching(_:) takes an NSPredicate (matching(identifier:) takes a String)
let loadedCount = app.images.matching(NSPredicate(format: "identifier BEGINSWITH 'photoGrid'")).count
XCTAssertGreaterThan(loadedCount, 5, "Multiple images should load on 3G")
// No alerts (no crashes)
XCTAssertFalse(app.alerts.element.exists, "App should not crash on large device + slow network")
}Running Multi-Factor Tests in CI
A single xcodebuild test runs on one destination with whatever network the host currently has. To cover the matrix, loop destinations and condition the network around the loop:
- name: Run tests across devices (3G-conditioned)
run: |
sudo dnctl pipe 1 config bw 1600Kbit/s delay 150 plr 0.01
printf 'dummynet-anchor "nlc"\nanchor "nlc"\n' | sudo pfctl -f -
echo 'dummynet out proto tcp from any to any pipe 1' | sudo pfctl -a nlc -f -
sudo pfctl -E
for dest in \
'platform=iOS Simulator,name=iPhone 16 Pro' \
'platform=iOS Simulator,name=iPad Pro 13-inch (M4)' ; do
xcodebuild test -scheme MyApp -testPlan UITests -destination "$dest"
done
sudo pfctl -a nlc -F all && sudo pfctl -d && sudo dnctl -q flushResult: Catch device-specific + slow-network crashes before App Store submission. Hosted CI that disallows sudo (e.g. Xcode Cloud) can't shape the network this way — condition on a physical device or drop the network dimension there.
---
Simulator control from CI: devicectl
devicectl manages simulators and physical devices through one interface — every device leaf command takes -d/--device <udid|name|ecid|…>, which accepts a simulator UDID or a physical-device identifier, so the same script drives a real iPhone in the dev loop and a simulator in CI with no branching. This is not new in Xcode 27: the devicectl CLI is byte-identical across the Xcode 26 and 27 toolchains (binary 629.3, verified on 26.6 and 27.0). Device Hub (the Xcode 27 GUI that replaces Simulator.app) is a front-end over these same operations — see axiom-build (skills/xcode-debugging.md) for the Device Hub workflow and the unified list devices inventory.
Parse `--json-output <path>`, never stdout. devicectl guarantees the JSON file is versioned and stable across releases; its stdout is explicitly not stable. simctl's human output never carried that guarantee — the stability contract, not the unified syntax, is the real CI win.
Interaction vs lifecycle — devicectl does NOT replace simctl
devicectl configures and interacts with a device/sim; it has no create/boot/erase. simctl still owns the simulator lifecycle and is still required.
| Need | Tool |
|---|---|
| create / boot / shutdown / erase a sim | `xcrun simctl boot\ |
| pick the test destination | xcodebuild -destination |
| configure / interact with a booted sim or device | xcrun devicectl |
CI order is unchanged at the front: simctl or xcodebuild boots the sim → devicectl configures it → run tests.
Simulator-capable subcommands (verified on Xcode 26.6 + 27.0)
| Subcommand | On simulator | Use |
|---|---|---|
device info displays | works (verified) | bounds, pointScale, nativeSize, framebufferMaskIdentifier (exact JSON keys) |
device orientation get (also set, rotate) | works (get verified) | orientation without entering the app |
| `device settings biometrics [--enable\ | --disable]` | works (verified) |
| `device simulate biometrics --success\ | --failure` | works (verified) |
| `device settings appearance --mode light\ | dark` | works (verified) |
device simulate location / device simulate statusBar | available | inject location; clean status bar for screenshots |
device process sendMemoryWarning | available | memory-pressure scenarios |
device info lockState / info files / copy / profile * | physical-device-only | see caveat below |
Face ID in CI (verified end-to-end on a simulator)
simctl has no biometric command — enrolling/matching was a GUI-only Simulator menu, unscriptable. devicectl makes it a CI primitive:
SIM=$(xcrun simctl list devices booted | grep -Eo '[0-9A-F-]{36}' | head -1)
xcrun devicectl device settings biometrics -d "$SIM" --enable # enroll
xcrun devicectl device simulate biometrics -d "$SIM" --success # match (use --failure for the reject path)
# … run the XCUITest that asserts the unlocked state …
xcrun devicectl device settings biometrics -d "$SIM" --disable # restoreThe flags are --success / --failure (mutually exclusive) — not --match.
Capability not supported on a simulator
Some capabilities are physical-device-only on a simulator. They fail with a distinct, detectable error — not a crash, not a silent no-op:
ERROR: The capability "Get Lock State" is not supported by this device.
(com.apple.dt.CoreDeviceError error 1001)info lockState is confirmed device-only; info files, copy, and profile * are reported device-only on simulators. In CI, treat CoreDeviceError 1001 as "skip on simulator" rather than a failure.
simctl still owns simulator-only features
devicectl works on simulators across Xcode 26+, so this is not a Xcode-27-only path and needs no toolchain gate. But simctl stays primary for simulator-only control devicectl doesn't cover — push notifications, privacy permissions, media injection, and the status_bar / location / ui appearance overrides. Use whichever you already script; reach for devicectl when you want one -d selector and stable JSON across device + simulator.
For the in-test (in-process) counterpart to this out-of-process control — XCUIDevice.shared.orientation / .location set inside the test — see Setup Device State above. devicectl configures the sim around the run; XCUIDevice configures it from inside the run.
---
Debugging Crashes Revealed by UI Tests
Overview
UI tests sometimes reveal crashes that don't happen in manual testing. Key insight Automated tests run faster, interact with app differently, and can expose concurrency/timing bugs.
When crashes happen:
- ❌ Manual testing: Can't reproduce (works when you run it)
- ✅ UI Test: Crashes every time (automated repetition finds race condition)
Recognizing Test-Revealed Crashes
Signs in test output:
Failing test: testPhotoUpload
Error: The app crashed while responding to a UI event
App died from an uncaught exception
Stack trace: [EXC_BAD_ACCESS in PhotoViewController]Video shows: App visibly crashes (black screen, immediate termination).
Systematic Debugging Approach
Step 1: Capture Crash Details
Enable detailed logging:
override func setUpWithError() throws {
let app = XCUIApplication()
// Enable all logging (configure via launchEnvironment before launch())
app.launchEnvironment = [
"OS_ACTIVITY_MODE": "debug",
"DYLD_PRINT_STATISTICS": "1"
]
app.launch()
}Step 2: Reproduce Locally
func testReproduceCrash() {
let app = XCUIApplication()
app.launch()
// Run exact sequence that causes crash
app.buttons["Browse"].tap()
app.buttons["Photo Album"].tap()
app.buttons["Select All"].tap()
app.buttons["Upload"].tap()
// Should crash here
let uploadButton = app.buttons["Upload"]
XCTAssertFalse(uploadButton.exists, "App crashed (expected)")
// Don't assert - just let it crash and read logs
}Run test with Console logs visible:
- Xcode: View → Navigators → Show Console
- Watch for exception messages
Step 3: Analyze Crash Logs
Locations: 1. Xcode Console (real-time, less detail) 2. ~/Library/Logs/DiagnosticReports/*.ips (full crash reports on current macOS) 3. Device Settings → Privacy & Security → Analytics & Improvements → Analytics Data
For parsing and symbolicating .ips/MetricKit/.crash reports, use Axiom's xcsym tool or the crash-analyzer agent instead of reading them by hand.
Look for:
- Thread that crashed
- Exception type (EXC_BAD_ACCESS, EXC_CRASH, etc.)
- Stack trace showing which method crashed
Example crash log:
Exception Type: EXC_BAD_ACCESS (SIGSEGV)
Exception Codes: KERN_INVALID_ADDRESS at 0x0000000000000000
Thread 0 Crashed:
0 MyApp 0x0001a234 -[PhotoViewController reloadPhotos:] + 234
1 MyApp 0x0001a123 -[PhotoViewController viewDidLoad] + 180This tells us:
- Crash in
PhotoViewController.reloadPhotos(_:) - Likely null pointer dereference
- Called from
viewDidLoad
Step 4: Connection to Swift Concurrency Issues
Most UI test crashes are concurrency bugs (not specific to UI testing). Reference related skills:
// Common pattern: Race condition in async image loading
class PhotoViewController: UIViewController {
var photos: [Photo] = []
override func viewDidLoad() {
super.viewDidLoad()
// ❌ WRONG: Accessing photos array from multiple threads
Task {
let newPhotos = await fetchPhotos()
self.photos = newPhotos // May crash if main thread access
reloadPhotos() // ❌ Crash here
}
}
}
// ✅ CORRECT: UIViewController is already @MainActor-isolated, so a Task
// started in viewDidLoad runs on the main actor and resumes there after the
// await — no per-property @MainActor and no MainActor.run hop needed. The
// original bug wasn't the assignment; it was doing UI work without that
// guarantee.
class PhotoViewController: UIViewController {
var photos: [Photo] = []
override func viewDidLoad() {
super.viewDidLoad()
Task {
let newPhotos = await fetchPhotos() // suspends, resumes on MainActor
self.photos = newPhotos
reloadPhotos() // ✅ Safe — still on the main actor
}
}
}For deep crash analysis: See axiom-concurrency (swift-concurrency reference) for @MainActor patterns and axiom-performance (skills/memory-debugging.md) skill for thread-safety issues.
Step 5: Add Crash-Prevention Tests
After fixing:
func testPhotosLoadWithoutCrash() {
let app = XCUIApplication()
app.launch()
// Rapid fire interactions that previously caused crash
app.buttons["Browse"].tap()
app.buttons["Photo Album"].tap()
// Load should complete without crash
let photoGrid = app.otherElements["photoGrid"]
XCTAssertTrue(photoGrid.waitForExistence(timeout: 10))
// No alerts (no crash dialogs)
XCTAssertFalse(app.alerts.element.exists)
}Step 6: Stress Test to Verify Fix
func testPhotosLoadUnderStress() {
let app = XCUIApplication()
app.launch()
// Repeat the crash-causing action multiple times
for iteration in 0..<10 {
app.buttons["Browse"].tap()
// Wait for load
let grid = app.otherElements["photoGrid"]
XCTAssertTrue(grid.waitForExistence(timeout: 10), "Iteration \(iteration)")
// Go back
app.navigationBars.buttons["Back"].tap()
app.buttons["Refresh"].tap()
}
// Completed without crash — assert a real end state, not `true`
XCTAssertTrue(app.otherElements["photoGrid"].exists, "Grid should survive 10 navigation cycles")
XCTAssertFalse(app.alerts.element.exists, "No crash dialog after stress loop")
}Prevention Checklist
Before releasing
- [ ] Run UI tests on slowest network (3G)
- [ ] Run on largest device (iPad Pro)
- [ ] Run on oldest supported device (iPhone 12)
- [ ] Record video of test runs (saves debugging time)
- [ ] Check for crashes in logs
- [ ] Run stress tests (10x repeated actions)
- [ ] Verify UI-state mutations run on the main actor
- [ ] Check for race conditions in async code
---
Resources
WWDC: 2025-344, 2024-10179, 2023-10269, 2023-10175, 2023-10035, 2022-110371
Docs: /xctest, /xcuiautomation/recording-ui-automation-for-testing, /xctest/xctwaiter, /accessibility/delivering_an_exceptional_accessibility_experience, /accessibility/performing_accessibility_testing_for_your_app
Note: This skill focuses on reliability patterns and Recording UI Automation. For TDD workflow, see superpowers:test-driven-development.
---
History: See git log for changes
XCUITest Automation Patterns
Comprehensive guide to writing reliable, maintainable UI tests with XCUITest.
Core Principle
Reliable UI tests require three things: 1. Stable element identification (accessibilityIdentifier) 2. Condition-based waiting (never hardcoded sleep) 3. Clean test isolation (no shared state)
Element Identification
The Accessibility Identifier Pattern
ALWAYS use accessibilityIdentifier for test-critical elements.
// SwiftUI
Button("Login") { ... }
.accessibilityIdentifier("loginButton")
TextField("Email", text: $email)
.accessibilityIdentifier("emailTextField")
// UIKit
loginButton.accessibilityIdentifier = "loginButton"
emailTextField.accessibilityIdentifier = "emailTextField"Query Selection Guidelines
From WWDC 2025-344 "Recording UI Automation":
1. Localized strings change → Use accessibilityIdentifier instead 2. Deeply nested views → Use shortest possible query 3. Dynamic content → Use generic query or identifier
// BAD - Fragile queries
app.buttons["Login"] // Breaks with localization
app.tables.cells.element(boundBy: 0).buttons.firstMatch // Too specific
// GOOD - Stable queries
app.buttons["loginButton"] // Uses identifier
app.tables.cells.containing(.staticText, identifier: "itemTitle").firstMatchWaiting Strategies
Never Use sleep()
// BAD - Hardcoded wait
sleep(5)
XCTAssertTrue(app.buttons["submit"].exists)
// GOOD - Condition-based wait
let submitButton = app.buttons["submit"]
XCTAssertTrue(submitButton.waitForExistence(timeout: 5))Wait Patterns
// Wait for element to appear
func waitForElement(_ element: XCUIElement, timeout: TimeInterval = 10) -> Bool {
element.waitForExistence(timeout: timeout)
}
// Wait for element to disappear
func waitForElementToDisappear(_ element: XCUIElement, timeout: TimeInterval = 10) -> Bool {
let predicate = NSPredicate(format: "exists == false")
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
let result = XCTWaiter.wait(for: [expectation], timeout: timeout)
return result == .completed
}
// Wait for element to be hittable (visible AND enabled)
func waitForElementHittable(_ element: XCUIElement, timeout: TimeInterval = 10) -> Bool {
let predicate = NSPredicate(format: "isHittable == true")
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
let result = XCTWaiter.wait(for: [expectation], timeout: timeout)
return result == .completed
}
// Wait for text to appear anywhere
func waitForText(_ text: String, timeout: TimeInterval = 10) -> Bool {
app.staticTexts[text].waitForExistence(timeout: timeout)
}Async Operations
// Wait for network response
func waitForNetworkResponse() {
let loadingIndicator = app.activityIndicators["loadingIndicator"]
// Wait for loading to start
_ = loadingIndicator.waitForExistence(timeout: 5)
// Wait for loading to finish
_ = waitForElementToDisappear(loadingIndicator, timeout: 30)
}Test Structure
Setup and Teardown
class LoginTests: XCTestCase {
var app: XCUIApplication!
override func setUpWithError() throws {
continueAfterFailure = false
app = XCUIApplication()
// Reset app state for clean test
app.launchArguments = ["--uitesting", "--reset-state"]
app.launchEnvironment = ["DISABLE_ANIMATIONS": "1"]
app.launch()
}
override func tearDownWithError() throws {
// Capture screenshot on failure
if testRun?.failureCount ?? 0 > 0 {
let screenshot = XCUIScreen.main.screenshot()
let attachment = XCTAttachment(screenshot: screenshot)
attachment.name = "Failure Screenshot"
attachment.lifetime = .keepAlways
add(attachment)
}
app.terminate()
}
}Test Method Pattern
func testLoginWithValidCredentials() throws {
// ARRANGE - Navigate to login screen
let loginButton = app.buttons["showLoginButton"]
XCTAssertTrue(loginButton.waitForExistence(timeout: 5))
loginButton.tap()
// ACT - Enter credentials and submit
let emailField = app.textFields["emailTextField"]
XCTAssertTrue(emailField.waitForExistence(timeout: 5))
emailField.tap()
emailField.typeText("user@example.com")
let passwordField = app.secureTextFields["passwordTextField"]
passwordField.tap()
passwordField.typeText("password123")
app.buttons["loginSubmitButton"].tap()
// ASSERT - Verify successful login
let welcomeLabel = app.staticTexts["welcomeLabel"]
XCTAssertTrue(welcomeLabel.waitForExistence(timeout: 10))
XCTAssertTrue(welcomeLabel.label.contains("Welcome"))
}Common Interactions
Text Input
// Clear and type
let textField = app.textFields["emailTextField"]
textField.tap()
textField.clearText() // Custom extension
textField.typeText("new@email.com")
// Extension to clear text
extension XCUIElement {
func clearText() {
guard let stringValue = value as? String else { return }
tap()
let deleteString = String(repeating: XCUIKeyboardKey.delete.rawValue, count: stringValue.count)
typeText(deleteString)
}
}Scrolling
// Scroll until element is visible
func scrollToElement(_ element: XCUIElement, in scrollView: XCUIElement) {
while !element.isHittable {
scrollView.swipeUp()
}
}
// Scroll to specific element
let targetCell = app.tables.cells["targetItem"]
let table = app.tables.firstMatch
scrollToElement(targetCell, in: table)
targetCell.tap()Alerts and Sheets
// Handle system alert
addUIInterruptionMonitor(withDescription: "Permission Alert") { alert in
if alert.buttons["Allow"].exists {
alert.buttons["Allow"].tap()
return true
}
return false
}
app.tap() // Trigger the monitor
// Handle app alert
let alert = app.alerts["Error"]
if alert.waitForExistence(timeout: 5) {
alert.buttons["OK"].tap()
}Keyboard Dismissal
// Dismiss keyboard
if app.keyboards.count > 0 {
app.toolbars.buttons["Done"].tap()
// Or tap outside
// app.tap()
}Test Plans
Multi-Configuration Testing
Test plans allow running the same tests with different configurations:
<!-- TestPlan.xctestplan -->
{
"configurations" : [
{
"name" : "English",
"options" : {
"language" : "en",
"region" : "US"
}
},
{
"name" : "Spanish",
"options" : {
"language" : "es",
"region" : "ES"
}
},
{
"name" : "Dark Mode",
"options" : {
"userInterfaceStyle" : "dark"
}
}
],
"testTargets" : [
{
"target" : {
"containerPath" : "container:MyApp.xcodeproj",
"identifier" : "MyAppUITests",
"name" : "MyAppUITests"
}
}
]
}Running with Test Plan
xcodebuild test \
-scheme "MyApp" \
-testPlan "MyTestPlan" \
-destination "platform=iOS Simulator,name=iPhone 16" \
-resultBundlePath /tmp/results.xcresultCI/CD Integration
Parallel Test Execution
xcodebuild test \
-scheme "MyAppUITests" \
-destination "platform=iOS Simulator,name=iPhone 16" \
-parallel-testing-enabled YES \
-maximum-parallel-test-targets 4 \
-resultBundlePath /tmp/results.xcresultRetry Failed Tests
xcodebuild test \
-scheme "MyAppUITests" \
-destination "platform=iOS Simulator,name=iPhone 16" \
-retry-tests-on-failure \
-test-iterations 3 \
-resultBundlePath /tmp/results.xcresultCode Coverage
xcodebuild test \
-scheme "MyAppUITests" \
-destination "platform=iOS Simulator,name=iPhone 16" \
-enableCodeCoverage YES \
-resultBundlePath /tmp/results.xcresult
# Export coverage report
xcrun xcresulttool export coverage \
--path /tmp/results.xcresult \
--output-path /tmp/coverageDebugging Failed Tests
Capture Screenshots
// Manual screenshot capture
let screenshot = app.screenshot()
let attachment = XCTAttachment(screenshot: screenshot)
attachment.name = "Before Login"
attachment.lifetime = .keepAlways
add(attachment)Capture Videos
Enable in test plan or scheme:
"systemAttachmentLifetime" : "keepAlways",
"userAttachmentLifetime" : "keepAlways"Print Element Hierarchy
// Debug: Print all elements
print(app.debugDescription)
// Debug: Print specific container
print(app.tables.firstMatch.debugDescription)Anti-Patterns to Avoid
1. Hardcoded Delays
// BAD
sleep(5)
button.tap()
// GOOD
XCTAssertTrue(button.waitForExistence(timeout: 5))
button.tap()2. Index-Based Queries
// BAD - Breaks if order changes
app.tables.cells.element(boundBy: 0)
// GOOD - Uses identifier
app.tables.cells["firstItem"]3. Shared State Between Tests
// BAD - Tests depend on order
func test1_CreateItem() { ... }
func test2_EditItem() { ... } // Depends on test1
// GOOD - Independent tests
func testCreateItem() {
// Creates own item
}
func testEditItem() {
// Creates item, then edits
}4. Testing Implementation Details
// BAD - Tests internal structure
XCTAssertEqual(app.tables.cells.count, 10)
// GOOD - Tests user-visible behavior
XCTAssertTrue(app.staticTexts["10 items"].exists)Recording UI Automation (Xcode 26+)
From WWDC 2025-344:
1. Record — Record interactions in Xcode (Debug → Record UI Automation) 2. Replay — Run across devices/languages/configurations via test plans 3. Review — Watch video recordings in test report
Enhancing Recorded Code
// RECORDED (may be fragile)
app.buttons["Login"].tap()
// ENHANCED (stable)
let loginButton = app.buttons["loginButton"]
XCTAssertTrue(loginButton.waitForExistence(timeout: 5))
loginButton.tap()Resources
WWDC: 2025-344, 2024-10206, 2023-10175, 2019-413
Docs: /xctest/xcuiapplication, /xctest/xcuielement, /xctest/xcuielementquery
Skills: See skills/ui-testing.md, skills/swift-testing.md
Related skills
How it compares
Pick axiom-testing for Apple-platform test architecture and framework decisions; use generic testing skills for non-Swift stacks.
FAQ
When must agents use axiom-testing?
Agents must use axiom-testing for any testing-related question on Apple platforms—including writing tests, debugging failures, improving test speed, or choosing Swift Testing versus XCTest. The skill routes tasks to specialized reference guides.
What test types does axiom-testing cover?
axiom-testing covers unit tests with Swift Testing (@Test, #expect), UI tests, async testing, parameterized tests with tags and traits, and XCTest alternatives. A quick-reference table maps each symptom to the correct guide file.
Is Axiom Testing safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.