
Ios Testing
- 217 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
ios-testing: A skill for development. This provides functionality for development workflows.
Key points
- ios-testing
Ios Testing by the numbers
- 217 all-time installs (skills.sh)
- +6 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #1,808 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill ios-testingAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 217 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do I use ios-testing for development tasks?
Use ios-testing for development tasks
Who is it for?
Best when you're working on backend & apis and need structured help with ios-testing.
Skip if: Teams with no backend & apis needs, or anyone wanting a generic chat assistant without this specific workflow.
When should I use this skill?
When you need to use ios-testing for development tasks, or when ios-testing: a skill for development. this provides functionality for development workflows.
What you get
Structured output aligned to ios-testing: ios-testing.
Files
iOS Testing Best Practices
Comprehensive testing guide for iOS and Swift applications, written at principal engineer level. Contains 44 rules across 8 categories, prioritized by impact to guide test architecture decisions, test authoring patterns, and CI infrastructure.
Clinic Architecture Contract (iOS 26 / Swift 6.2)
All guidance in this skill assumes the clinic modular MVVM-C architecture:
- Feature modules import
Domain+DesignSystemonly (neverData, never sibling features) - App target is the convergence point and owns
DependencyContainer, concrete coordinators, and Route Shell wiring Domainstays pure Swift and defines models plus repository,*Coordinating,ErrorRouting, andAppErrorcontractsDataowns SwiftData/network/sync/retry/background I/O and implements Domain protocols- Read/write flow defaults to stale-while-revalidate reads and optimistic queued writes
- ViewModels call repository protocols directly (no default use-case/interactor layer)
When to Apply
Reference these guidelines when:
- Writing new unit tests or UI tests for iOS apps
- Designing testable architecture with dependency injection
- Testing async/await, actors, and Combine publishers
- Setting up snapshot testing or visual regression suites
- Configuring CI pipelines, test plans, and parallel execution
Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | Test Architecture & Testability | CRITICAL | arch- |
| 2 | Unit Testing Fundamentals | CRITICAL | unit- |
| 3 | Test Doubles & Isolation | HIGH | mock- |
| 4 | Async & Concurrency Testing | HIGH | async- |
| 5 | SwiftUI Testing | MEDIUM-HIGH | swiftui- |
| 6 | UI & Acceptance Testing | MEDIUM | ui- |
| 7 | Snapshot & Visual Testing | MEDIUM | snap- |
| 8 | Test Reliability & CI | LOW-MEDIUM | ci- |
Quick Reference
1. Test Architecture & Testability (CRITICAL)
- `arch-protocol-dependencies` - Depend on protocols, not concrete types
- `arch-constructor-injection` - Use constructor injection over service locators
- `arch-test-target-separation` - Separate unit and UI test targets
- `arch-testable-import` - Use @testable import sparingly
- `arch-single-responsibility-tests` - One assertion concept per test
- `arch-arrange-act-assert` - Structure tests as Arrange-Act-Assert
2. Unit Testing Fundamentals (CRITICAL)
- `unit-swift-testing-framework` - Use Swift Testing over XCTest for new tests
- `unit-parameterized-tests` - Use parameterized tests for input variations
- `unit-descriptive-test-names` - Name tests after the behavior they verify
- `unit-expect-over-assert` - Use #expect and #require over XCTAssert
- `unit-require-preconditions` - Use #require for test preconditions
- `unit-test-suites` - Organize related tests into suites
- `unit-test-tags` - Use tags to categorize cross-cutting tests
3. Test Doubles & Isolation (HIGH)
- `mock-protocol-based-mocks` - Create mocks from protocols, not subclasses
- `mock-spy-for-verification` - Use spies to verify interactions
- `mock-stub-return-values` - Use stubs for deterministic return values
- `mock-avoid-over-mocking` - Avoid mocking value types and simple logic
- `mock-fake-for-integration` - Use in-memory fakes for integration tests
- `mock-dependency-container` - Use a dependency container for test configuration
4. Async & Concurrency Testing (HIGH)
- `async-await-directly` - Await async functions directly in tests
- `async-confirmation` - Use confirmation() for callback-based APIs
- `async-mainactor-isolation` - Test MainActor-isolated code on MainActor
- `async-actor-testing` - Test actor state through async interface
- `async-task-cancellation` - Test task cancellation paths explicitly
5. SwiftUI Testing (MEDIUM-HIGH)
- `swiftui-test-observable-models` - Test @Observable models as plain objects
- `swiftui-environment-injection` - Inject environment dependencies for tests
- `swiftui-preview-as-test` - Use previews as visual smoke tests
- `swiftui-view-model-extraction` - Extract logic from views into testable models
- `swiftui-binding-testing` - Test binding behavior with @Bindable
6. UI & Acceptance Testing (MEDIUM)
- `ui-accessibility-identifiers` - Use accessibility identifiers for element queries
- `ui-page-object-pattern` - Encapsulate screens in page objects
- `ui-launch-arguments` - Configure test state via launch arguments
- `ui-wait-for-elements` - Wait for elements instead of using sleep()
- `ui-test-user-journeys` - Test complete user journeys, not individual screens
- `ui-reset-state-between-tests` - Reset app state between UI tests
7. Snapshot & Visual Testing (MEDIUM)
- `snap-swift-snapshot-testing` - Use swift-snapshot-testing for visual regression
- `snap-device-matrix` - Snapshot across device sizes and traits
- `snap-named-references` - Use named snapshot references for clarity
- `snap-inline-snapshots` - Use inline snapshots for non-image assertions
8. Test Reliability & CI (LOW-MEDIUM)
- `ci-test-plans` - Use Xcode Test Plans for environment configurations
- `ci-parallel-execution` - Enable parallel test execution
- `ci-flaky-test-quarantine` - Quarantine flaky tests instead of disabling them
- `ci-deterministic-test-data` - Use deterministic test data over random generation
- `ci-coverage-thresholds` - Set coverage thresholds for critical paths
How to Use
Read individual reference files for detailed explanations and code examples:
- Section definitions - Category structure and impact levels
- Rule template - Template for adding new rules
Reference Files
| File | Description |
|---|---|
| references/_sections.md | Category definitions and ordering |
| assets/templates/_template.md | Template for new rules |
| metadata.json | Version and reference information |
Rule Title Here
1-3 sentences explaining WHY this matters for testing. Focus on the testing cost or risk.
Incorrect (problem/cost description):
// Production-realistic bad example
// 1-2 comments on key lines explaining consequencesCorrect (benefit/solution description):
// Production-realistic good example
// Minimal diff from incorrectWhen NOT to use this pattern:
- Exception 1
- Exception 2
Reference: Source Title
{
"version": "1.0.6",
"organization": "iOS Engineering",
"technology": "iOS Testing (Swift/SwiftUI, iOS 26 / Swift 6.2)",
"date": "February 2026",
"abstract": "Comprehensive testing guide for iOS and Swift applications at principal engineer level. Contains 44 rules across 8 categories, prioritized by impact from critical (test architecture and testability, unit testing fundamentals) to incremental (test reliability and CI infrastructure). Each rule includes detailed explanations, production-realistic Swift code examples comparing incorrect vs. correct patterns, and specific impact metrics to guide test authoring, architecture decisions, and CI configuration. Aligned with the iOS 26 / Swift 6.2 clinic modular MVVM-C architecture.",
"references": [
"https://developer.apple.com/documentation/xctest",
"https://developer.apple.com/xcode/swift-testing",
"https://developer.apple.com/videos/play/wwdc2024/10195/",
"https://github.com/pointfreeco/swift-snapshot-testing",
"https://www.swiftbysundell.com/articles/unit-testing-code-that-uses-async-await/",
"https://www.avanderlee.com/concurrency/unit-testing-async-await/",
"https://fatbobman.com/en/posts/mastering-the-swift-testing-framework/",
"https://alexilyenko.github.io/xcuitest-page-object/"
]
}
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group rules.
---
1. Test Architecture & Testability (arch)
Impact: CRITICAL Description: If production code is not designed for testability, no testing technique can compensate. Protocol-based dependency injection, testable module boundaries, and proper test target organization are the foundation every other category depends on.
2. Unit Testing Fundamentals (unit)
Impact: CRITICAL Description: Unit tests provide the fastest feedback loop and catch the highest density of bugs per line of test code. Mastering Swift Testing and XCTest assertion patterns, parameterized tests, and structured test naming determines the entire suite's signal-to-noise ratio.
3. Test Doubles & Isolation (mock)
Impact: HIGH Description: Incorrect use of mocks produces brittle tests that break on every refactor. Protocol-based fakes, spies, and stubs isolate the system under test while keeping tests resilient to implementation changes.
4. Async & Concurrency Testing (async)
Impact: HIGH Description: Modern Swift apps are async-first. Mishandled expectations, missing MainActor isolation, and untested actor boundaries produce flaky tests and shipped race conditions.
5. SwiftUI Testing (swiftui)
Impact: MEDIUM-HIGH Description: SwiftUI's declarative reactive model requires testing strategies distinct from UIKit. Testing @Observable models, environment injection, and view behavior ensures correctness without fighting the framework.
6. UI & Acceptance Testing (ui)
Impact: MEDIUM Description: XCUITest validates end-to-end user journeys. The Page Object pattern, accessibility identifiers, and launch argument configuration determine whether UI tests are a reliable safety net or an unmaintainable liability.
7. Snapshot & Visual Testing (snap)
Impact: MEDIUM Description: Snapshot tests catch unintended visual regressions across devices, dark mode, and dynamic type sizes with zero manual effort per assertion.
8. Test Reliability & CI (ci)
Impact: LOW-MEDIUM Description: Flaky tests erode team confidence and slow delivery. Test plans, parallel execution, deterministic ordering, and coverage thresholds keep the suite green and fast at scale.
Structure Tests as Arrange-Act-Assert
When setup, execution, and verification are interleaved, readers must mentally untangle what the test does before they can diagnose a failure. Separating each test into Arrange, Act, and Assert sections makes the intent scannable at a glance and cuts debugging time in half.
Incorrect (interleaved phases obscure what is being tested):
final class ShoppingCartTests: XCTestCase {
func testApplyDiscount() {
let cart = ShoppingCart()
cart.add(Item(name: "Keyboard", price: 80.00))
XCTAssertEqual(cart.total, 80.00)
cart.add(Item(name: "Mouse", price: 40.00))
XCTAssertEqual(cart.itemCount, 2) // assertion mixed into setup
cart.applyDiscount(code: "SAVE20")
XCTAssertEqual(cart.total, 96.00)
XCTAssertTrue(cart.hasActiveDiscount) // unclear which action this validates
}
}Correct (three distinct phases, scannable in seconds):
final class ShoppingCartTests: XCTestCase {
func testApplyDiscountReducesTotal() {
// Arrange
let cart = ShoppingCart()
cart.add(Item(name: "Keyboard", price: 80.00))
cart.add(Item(name: "Mouse", price: 40.00))
// Act
cart.applyDiscount(code: "SAVE20")
// Assert
XCTAssertEqual(cart.total, 96.00) // 20% off 120.00
}
}Use Constructor Injection Over Service Locators
Service locators and singletons hide dependencies, making it impossible to know what a class needs without reading its implementation. Constructor injection surfaces every dependency at the call site, letting the compiler catch missing or misconfigured dependencies before tests even run.
Incorrect (hidden dependency discovered only at runtime):
final class OrderService {
func placeOrder(_ order: Order) async throws -> Confirmation {
let paymentGateway = ServiceLocator.shared.resolve(PaymentGateway.self) // hidden — crashes if not registered
let inventory = ServiceLocator.shared.resolve(InventoryChecker.self)
guard try await inventory.isAvailable(order.itemId) else {
throw OrderError.outOfStock
}
return try await paymentGateway.charge(order.total, method: order.paymentMethod)
}
}Correct (compiler enforces all dependencies at init):
final class OrderService {
private let paymentGateway: PaymentProcessing
private let inventory: InventoryChecking
init(paymentGateway: PaymentProcessing, inventory: InventoryChecking) { // missing dependency = compile error
self.paymentGateway = paymentGateway
self.inventory = inventory
}
func placeOrder(_ order: Order) async throws -> Confirmation {
guard try await inventory.isAvailable(order.itemId) else {
throw OrderError.outOfStock
}
return try await paymentGateway.charge(order.total, method: order.paymentMethod)
}
}Depend on Protocols, Not Concrete Types
Clinic architecture alignment (iOS 26 / Swift 6.2): Keep Feature modules on Domain + DesignSystem only; keep App-target DependencyContainer, route shells, and concrete coordinators as the integration point; keep Data as the only owner of SwiftData/network/sync I/O.
Concrete dependencies make it impossible to substitute test doubles, forcing tests to hit real networks, databases, and services. Protocol-based dependencies let each component be tested in complete isolation with deterministic behavior.
Incorrect (every test hits the real network):
final class ProfileViewModel: ObservableObject {
@Published var user: User?
@Published var errorMessage: String?
func loadProfile(userId: String) async {
let url = URL(string: "https://api.example.com/users/\(userId)")!
do {
let (data, _) = try await URLSession.shared.data(from: url) // untestable — real HTTP call every run
user = try JSONDecoder().decode(User.self, from: data)
} catch {
errorMessage = error.localizedDescription
}
}
}Correct (swap in a mock for sub-millisecond tests):
protocol ProfileFetching {
func fetchProfile(userId: String) async throws -> User
}
final class ProfileViewModel: ObservableObject {
@Published var user: User?
@Published var errorMessage: String?
private let profileFetcher: ProfileFetching // depends on protocol, not URLSession
init(profileFetcher: ProfileFetching) {
self.profileFetcher = profileFetcher
}
func loadProfile(userId: String) async {
do {
user = try await profileFetcher.fetchProfile(userId: userId)
} catch {
errorMessage = error.localizedDescription
}
}
}One Assertion Concept Per Test
When a test asserts multiple unrelated behaviors, the first failure masks all subsequent checks. Splitting each concept into its own test method produces a failure list that immediately tells you which behaviors broke and which still work.
Incorrect (first failure hides 4 other potential problems):
final class UserRegistrationTests: XCTestCase {
func testRegistration() async throws {
let service = RegistrationService(validator: MockValidator(), store: MockUserStore())
let result = try await service.register(email: "jo@example.com", password: "Str0ng!Pass")
XCTAssertTrue(result.isSuccess)
XCTAssertEqual(result.user?.email, "jo@example.com")
XCTAssertNotNil(result.user?.id)
XCTAssertTrue(result.user?.isEmailVerified == false) // if line 2 fails, you never know about lines 3-5
XCTAssertEqual(result.welcomeEmailQueued, true)
}
}Correct (each failure names the exact broken behavior):
final class UserRegistrationTests: XCTestCase {
private var service: RegistrationService!
override func setUp() {
service = RegistrationService(validator: MockValidator(), store: MockUserStore())
}
func testRegistrationSucceeds() async throws {
let result = try await service.register(email: "jo@example.com", password: "Str0ng!Pass")
XCTAssertTrue(result.isSuccess)
}
func testRegistrationAssignsUniqueId() async throws {
let result = try await service.register(email: "jo@example.com", password: "Str0ng!Pass")
XCTAssertNotNil(result.user?.id) // one concept — ID assignment
}
func testRegistrationQueuesWelcomeEmail() async throws {
let result = try await service.register(email: "jo@example.com", password: "Str0ng!Pass")
XCTAssertTrue(result.welcomeEmailQueued)
}
}Separate Unit and UI Test Targets
Mixing UI tests into the unit test target forces the entire test suite to launch the simulator and boot the app for every run. Separating targets lets unit tests execute in-process in under a second while UI tests run independently on CI.
Incorrect (unit tests wait for app launch on every run):
// MyAppTests target (single target for everything)
import XCTest
@testable import MyApp
// This runs fast but is trapped in a UI test target
final class PriceFormatterTests: XCTestCase {
func testFormatsWithCurrency() {
let result = PriceFormatter.format(amount: 29.99, currency: .gbp)
XCTAssertEqual(result, "£29.99")
}
}
// This forces the entire target to launch the app
final class CheckoutFlowUITests: XCTestCase { // UI test mixed into unit target — 8s launch penalty on every run
func testCompletePurchase() {
let app = XCUIApplication()
app.launch()
app.buttons["Add to Cart"].tap()
app.buttons["Checkout"].tap()
XCTAssertTrue(app.staticTexts["Order Confirmed"].exists)
}
}Correct (unit tests run in milliseconds, UI tests run separately):
// MyAppUnitTests target — no host app, no simulator boot
import XCTest
@testable import MyApp
final class PriceFormatterTests: XCTestCase {
func testFormatsWithCurrency() {
let result = PriceFormatter.format(amount: 29.99, currency: .gbp)
XCTAssertEqual(result, "£29.99")
}
}
// MyAppUITests target — separate target, runs only when explicitly selected
import XCTest
final class CheckoutFlowUITests: XCTestCase { // isolated target — unit tests unaffected
func testCompletePurchase() {
let app = XCUIApplication()
app.launch()
app.buttons["Add to Cart"].tap()
app.buttons["Checkout"].tap()
XCTAssertTrue(app.staticTexts["Order Confirmed"].exists)
}
}Use @testable import Sparingly
@testable import exposes internal symbols to test targets, tempting you to test private implementation details. When those internals change, tests break even though the public behavior is identical. Prefer testing through the public API and reserve @testable for cases where no public surface exists.
Incorrect (tests break when internal cache strategy changes):
@testable import Networking
final class ImageLoaderTests: XCTestCase {
func testImageIsCached() async throws {
let loader = ImageLoader()
_ = try await loader.loadImage(from: thumbnailURL)
let cached = loader.memoryCache.object(forKey: thumbnailURL.absoluteString as NSString) // coupled to internal cache type
XCTAssertNotNil(cached)
}
func testPendingRequestTracking() async throws {
let loader = ImageLoader()
async let _ = loader.loadImage(from: thumbnailURL)
XCTAssertTrue(loader.pendingRequests.contains(thumbnailURL)) // breaks if pendingRequests is renamed or restructured
}
}Correct (tests verify observable behavior, survive refactors):
import Networking
final class ImageLoaderTests: XCTestCase {
func testImageIsCached() async throws {
let loader = ImageLoader()
let first = try await loader.loadImage(from: thumbnailURL)
let second = try await loader.loadImage(from: thumbnailURL)
XCTAssertEqual(first.pngData(), second.pngData()) // verifies caching through public API
}
func testDuplicateRequestsCoalesced() async throws {
let loader = ImageLoader()
async let imageA = loader.loadImage(from: thumbnailURL)
async let imageB = loader.loadImage(from: thumbnailURL)
let (a, b) = try await (imageA, imageB)
XCTAssertEqual(a.pngData(), b.pngData()) // tests coalescing without touching internals
}
}Test Actor State Through Async Interface
Actor properties are isolated and cannot be accessed synchronously from outside the actor. Attempting to read actor state directly results in a compiler error, and using nonisolated workarounds defeats the actor's data-race protection. Always access actor state through its async interface, exactly as production code does.
Incorrect (bypasses actor isolation to read state):
actor ShoppingCart {
private(set) var items: [CartItem] = []
func add(_ item: CartItem) {
items.append(item)
}
nonisolated var itemSnapshot: [CartItem] { // breaks isolation — data race under concurrent access
[]
}
}
final class ShoppingCartTests: XCTestCase {
func testAddItem() {
let cart = ShoppingCart()
let item = CartItem(sku: "SHOE-001", quantity: 1)
Task { await cart.add(item) }
XCTAssertEqual(cart.itemSnapshot.count, 1) // race condition — task may not have completed
}
}Correct (awaits actor's async interface):
actor ShoppingCart {
private(set) var items: [CartItem] = []
func add(_ item: CartItem) {
items.append(item)
}
var itemCount: Int { items.count } // actor-isolated property — safe to await
}
final class ShoppingCartTests: XCTestCase {
func testAddItem() async {
let cart = ShoppingCart()
let item = CartItem(sku: "SHOE-001", quantity: 1)
await cart.add(item)
let count = await cart.itemCount // awaits actor hop — no data race
XCTAssertEqual(count, 1)
}
}Await Async Functions Directly in Tests
Wrapping every async call in XCTestExpectation adds 4-6 lines of ceremony that obscure the actual assertion. Marking the test function as async lets you await the result directly, producing tests that read like synchronous code with zero timeout tuning.
Incorrect (boilerplate hides the actual assertion):
final class PaymentServiceTests: XCTestCase {
func testChargeSucceeds() {
let expectation = expectation(description: "charge completes")
let service = PaymentService(gateway: MockGateway())
Task {
let result = try await service.charge(amount: 49_99, currency: .usd)
XCTAssertEqual(result.status, .captured)
expectation.fulfill() // easy to forget — test passes silently if omitted
}
wait(for: [expectation], timeout: 5.0) // arbitrary timeout masks slow or hanging tests
}
}Correct (test reads like synchronous code):
final class PaymentServiceTests: XCTestCase {
func testChargeSucceeds() async throws {
let service = PaymentService(gateway: MockGateway())
let result = try await service.charge(amount: 49_99, currency: .usd) // no expectation, no timeout
XCTAssertEqual(result.status, .captured)
}
}Use confirmation() for Callback-Based APIs
XCTestExpectation is stringly-typed and fails silently if fulfill() is never called within the timeout. Swift Testing's confirmation() provides a scoped, type-safe block that automatically fails when the expected callback count is not met, eliminating an entire class of false-positive tests.
Incorrect (stringly-typed expectation can silently pass):
final class NotificationServiceTests: XCTestCase {
func testObserverReceivesUpdate() {
let expectation = expectation(description: "observer notified")
let service = NotificationService()
service.onUpdate = { payload in
XCTAssertEqual(payload.channel, "orders")
expectation.fulfill()
}
service.broadcast(channel: "orders", message: "new order")
wait(for: [expectation], timeout: 2.0) // silently passes if onUpdate is never assigned
}
}Correct (compiler-enforced callback verification):
struct NotificationServiceTests {
@Test func observerReceivesUpdate() async {
let service = NotificationService()
await confirmation { confirm in // fails automatically if confirm is never called
service.onUpdate = { payload in
#expect(payload.channel == "orders")
confirm()
}
service.broadcast(channel: "orders", message: "new order")
}
}
}Test MainActor-Isolated Code on MainActor
Clinic architecture alignment (iOS 26 / Swift 6.2): Keep Feature modules on Domain + DesignSystem only; keep App-target DependencyContainer, route shells, and concrete coordinators as the integration point; keep Data as the only owner of SwiftData/network/sync I/O.
Calling a @MainActor-isolated method from a non-isolated test context triggers a runtime assertion or data race. Annotating the test function with @MainActor ensures the test body executes on the main actor, matching the isolation context of the code under test.
Incorrect (test runs off the main actor — runtime crash):
final class ProfileViewModelTests: XCTestCase {
func testLoadProfileUpdatesDisplayName() async throws {
let viewModel = ProfileViewModel(fetcher: MockProfileFetcher())
await viewModel.loadProfile(userId: "usr_42") // @MainActor method called from nonisolated context — potential crash
XCTAssertEqual(viewModel.displayName, "Ada Lovelace")
}
}
@MainActor
final class ProfileViewModel: ObservableObject {
@Published var displayName: String = ""
private let fetcher: ProfileFetching
init(fetcher: ProfileFetching) { self.fetcher = fetcher }
func loadProfile(userId: String) async {
let user = try? await fetcher.fetchProfile(userId: userId)
displayName = user?.fullName ?? "Unknown"
}
}Correct (test shares the same actor isolation):
final class ProfileViewModelTests: XCTestCase {
@MainActor
func testLoadProfileUpdatesDisplayName() async throws {
let viewModel = ProfileViewModel(fetcher: MockProfileFetcher())
await viewModel.loadProfile(userId: "usr_42") // same isolation context — safe access guaranteed
XCTAssertEqual(viewModel.displayName, "Ada Lovelace")
}
}
@MainActor
final class ProfileViewModel: ObservableObject {
@Published var displayName: String = ""
private let fetcher: ProfileFetching
init(fetcher: ProfileFetching) { self.fetcher = fetcher }
func loadProfile(userId: String) async {
let user = try? await fetcher.fetchProfile(userId: userId)
displayName = user?.fullName ?? "Unknown"
}
}Test Task Cancellation Paths Explicitly
Async functions that ignore Task.isCancelled or try Task.checkCancellation() continue executing after cancellation, leaking network connections, file handles, and database transactions. Explicitly testing the cancellation path verifies that cleanup runs and no work continues after the task is cancelled.
Incorrect (cancellation path is never tested):
final class ImageLoaderTests: XCTestCase {
func testLoadImage() async throws {
let loader = ImageLoader(downloader: MockDownloader())
let image = try await loader.load(url: catalogURL)
XCTAssertEqual(image.size.width, 800)
// cancellation path untested — leaked connections go unnoticed in production
}
}Correct (cancellation stops work and cleans up):
final class ImageLoaderTests: XCTestCase {
func testLoadImageStopsOnCancellation() async throws {
let downloader = MockDownloader(delay: .seconds(5))
let loader = ImageLoader(downloader: downloader)
let task = Task {
try await loader.load(url: catalogURL)
}
task.cancel() // cancel before the download completes
do {
_ = try await task.value
XCTFail("Expected CancellationError")
} catch is CancellationError {
XCTAssertTrue(downloader.wasCancelled) // verifies cleanup ran and resources were released
}
}
}Set Coverage Thresholds for Critical Paths
Without enforced thresholds, test coverage silently declines as features ship without tests. Adding a CI quality gate that checks coverage percentages for critical modules after each build catches regressions at PR time, ensuring payment, auth, and data integrity paths never drop below the team-agreed minimum.
Incorrect (no coverage enforcement, silent regression):
# .github/workflows/ci.yml
name: CI
on: [pull_request]
jobs:
test:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- name: Run tests
run: |
xcodebuild test \
-scheme App \
-destination 'platform=iOS Simulator,name=iPhone 16' \
# no -enableCodeCoverage flag — coverage not even collectedCorrect (coverage collected and enforced per module):
# .github/workflows/ci.yml
name: CI
on: [pull_request]
jobs:
test:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- name: Run tests with coverage
run: |
xcodebuild test \
-scheme App \
-destination 'platform=iOS Simulator,name=iPhone 16' \
-enableCodeCoverage YES \
-resultBundlePath TestResults.xcresult
- name: Enforce coverage thresholds
run: |
# Extract coverage per target from the result bundle
xcrun xccov view --report --json TestResults.xcresult > coverage.json
# Check critical module thresholds
check_threshold() {
local target="$1"
local minimum="$2"
local actual
actual=$(python3 -c "
import json, sys
data = json.load(open('coverage.json'))
for t in data['targets']:
if t['name'] == '${target}':
print(f\"{t['lineCoverage'] * 100:.1f}\")
sys.exit(0)
print('0.0')
")
echo "${target}: ${actual}% (minimum: ${minimum}%)"
if (( $(echo "${actual} < ${minimum}" | bc -l) )); then
echo "FAIL: ${target} coverage ${actual}% is below ${minimum}% threshold"
return 1
fi
}
failed=0
check_threshold "PaymentModule" 90 || failed=1 # payment paths must stay above 90%
check_threshold "AuthModule" 85 || failed=1
check_threshold "DataModule" 80 || failed=1
exit $failedUse Deterministic Test Data Over Random Generation
Tests that call Date(), UUID(), or .random() produce different values on every run, making failures impossible to reproduce locally. Injecting time, identifiers, and random values through protocols yields identical test runs across machines and CI environments, turning every failure into a deterministic, debuggable signal.
Incorrect (different results on every run):
struct OrderService {
func createOrder(items: [CartItem]) -> Order {
Order(
id: UUID().uuidString, // different ID every run — assertions can't match
items: items,
createdAt: Date(), // different timestamp every run
confirmationCode: String(Int.random(in: 100_000...999_999)) // non-reproducible
)
}
}
@Suite struct OrderServiceTests {
@Test func createsOrderFromCart() {
let service = OrderService()
let order = service.createOrder(items: [.stub(name: "Dog Collar", quantity: 1)])
#expect(order.id.isEmpty == false) // can only assert "not empty" — no exact match possible
#expect(order.confirmationCode.count == 6)
}
}Correct (fixed values make assertions exact and reproducible):
protocol IdentifierGenerating {
func generateId() -> String
func generateConfirmationCode() -> String
}
protocol Clock {
func now() -> Date
}
struct OrderService {
let idGenerator: IdentifierGenerating
let clock: Clock
func createOrder(items: [CartItem]) -> Order {
Order(
id: idGenerator.generateId(),
items: items,
createdAt: clock.now(),
confirmationCode: idGenerator.generateConfirmationCode()
)
}
}
struct FixedIdGenerator: IdentifierGenerating {
func generateId() -> String { "order-001" }
func generateConfirmationCode() -> String { "123456" }
}
struct FixedClock: Clock {
func now() -> Date { Date(timeIntervalSince1970: 1_700_000_000) } // 2023-11-14T22:13:20Z
}
@Suite struct OrderServiceTests {
@Test func createsOrderFromCart() {
let service = OrderService(idGenerator: FixedIdGenerator(), clock: FixedClock())
let order = service.createOrder(items: [.stub(name: "Dog Collar", quantity: 1)])
#expect(order.id == "order-001") // exact match — fails reproduce identically on any machine
#expect(order.confirmationCode == "123456")
#expect(order.createdAt == Date(timeIntervalSince1970: 1_700_000_000))
}
}Quarantine Flaky Tests Instead of Disabling Them
Commenting out or deleting a flaky test eliminates coverage with no tracking mechanism to ensure someone fixes it. Quarantining with .disabled or .enabled(if:) traits in Swift Testing -- combined with a .bug() reference -- keeps the test visible in reports, documents the known issue, and lets CI pass without silently losing coverage.
Incorrect (deleted coverage with no tracking):
@Suite struct CheckoutFlowTests {
// FIXME: flaky on CI, commented out for now
// @Test func completesCheckoutWithApplePay() async throws {
// let checkout = CheckoutService(payment: MockPaymentGateway())
// let order = try await checkout.complete(method: .applePay)
// #expect(order.status == .confirmed)
// }
@Test func completesCheckoutWithCreditCard() async throws {
let checkout = CheckoutService(payment: MockPaymentGateway())
let order = try await checkout.complete(method: .creditCard)
#expect(order.status == .confirmed) // Apple Pay path has zero coverage now
}
}Correct (quarantined with bug reference, visible in test reports):
@Suite struct CheckoutFlowTests {
@Test(.disabled("Flaky due to async timing in payment callback"),
.bug("https://jira.example.com/browse/PAY-1234")) // tracked in issue tracker
func completesCheckoutWithApplePay() async throws {
let checkout = CheckoutService(payment: MockPaymentGateway())
let order = try await checkout.complete(method: .applePay)
#expect(order.status == .confirmed) // test still compiles — catches build breaks
}
@Test func completesCheckoutWithCreditCard() async throws {
let checkout = CheckoutService(payment: MockPaymentGateway())
let order = try await checkout.complete(method: .creditCard)
#expect(order.status == .confirmed)
}
}Enable Parallel Test Execution
Serial test execution leaves most CPU cores idle while a single test runs at a time. Enabling parallel execution in XCTest distributes test classes across multiple simulator clones, cutting wall-clock time by 40-70% on multi-core CI runners. Swift Testing runs tests in parallel by default -- only serialize when tests share mutable state.
Incorrect (serial execution wastes CI capacity):
// Scheme > Test > Options: "Execute in parallel" is UNCHECKED
// CI runs ~200 tests serially — 12 minutes on 8-core runner
// XCTest: all tests run one-by-one on a single simulator
final class SearchServiceTests: XCTestCase {
func testSearchReturnsResults() async throws {
let service = SearchService(client: MockAPIClient())
let results = try await service.search(query: "london")
XCTAssertFalse(results.isEmpty)
}
func testSearchHandlesEmptyQuery() async throws {
let service = SearchService(client: MockAPIClient())
let results = try await service.search(query: "")
XCTAssertTrue(results.isEmpty)
}
}
// Swift Testing: forcing serial execution unnecessarily
@Suite(.serialized) // blocks parallel execution for no reason
struct PaymentValidationTests {
@Test func validCardAccepted() {
let validator = PaymentValidator()
#expect(validator.validate(card: .stub(number: "4242424242424242")) == .valid)
}
@Test func expiredCardRejected() {
let validator = PaymentValidator()
#expect(validator.validate(card: .stub(expiry: .distantPast)) == .expired)
}
}Correct (parallel execution maximizes CI throughput):
// Scheme > Test > Options: "Execute in parallel" is CHECKED
// CI runs ~200 tests in parallel — 4 minutes on 8-core runner
// XCTest: each test class runs on its own simulator clone
final class SearchServiceTests: XCTestCase {
func testSearchReturnsResults() async throws {
let service = SearchService(client: MockAPIClient()) // no shared state — safe to parallelize
let results = try await service.search(query: "london")
XCTAssertFalse(results.isEmpty)
}
func testSearchHandlesEmptyQuery() async throws {
let service = SearchService(client: MockAPIClient())
let results = try await service.search(query: "")
XCTAssertTrue(results.isEmpty)
}
}
// Swift Testing: parallel by default, only serialize when truly needed
@Suite(.serialized) // justified — tests share a single database instance
struct DatabaseMigrationTests {
@Test func migratesV1ToV2() async throws {
let db = try await TestDatabase.shared.reset(to: .v1)
try await db.migrate()
#expect(db.schemaVersion == 2)
}
@Test func migratesV2ToV3() async throws {
let db = try await TestDatabase.shared.reset(to: .v2)
try await db.migrate()
#expect(db.schemaVersion == 3)
}
}
// Swift Testing: stateless tests run in parallel automatically
struct PaymentValidationTests {
@Test func validCardAccepted() {
let validator = PaymentValidator()
#expect(validator.validate(card: .stub(number: "4242424242424242")) == .valid)
}
@Test func expiredCardRejected() {
let validator = PaymentValidator() // each test creates its own instance — parallel-safe
#expect(validator.validate(card: .stub(expiry: .distantPast)) == .expired)
}
}Use Xcode Test Plans for Environment Configurations
Duplicating test schemes per language or region creates a maintenance burden where every new test must be added to every scheme. Xcode Test Plans define a single set of tests with multiple configurations, so one plan runs the entire suite against every locale, environment variable set, or launch argument combination automatically.
Incorrect (duplicated schemes drift out of sync):
// Scheme: "Tests-English" — manually configured with:
// Application Language: English
// Application Region: United States
//
// Scheme: "Tests-Spanish" — identical test list, different locale:
// Application Language: Spanish
// Application Region: Spain
//
// Scheme: "Tests-Japanese" — yet another copy:
// Application Language: Japanese
// Application Region: Japan
// Each scheme must be updated whenever a test is added or removed
final class BookingFlowTests: XCTestCase {
func testBookingConfirmationShowsLocalizedDate() {
let app = XCUIApplication()
app.launch()
app.buttons["Book Now"].tap()
XCTAssertTrue(app.staticTexts["confirmationDate"].exists) // only tests one locale per scheme run
}
}Correct (one plan, multiple configurations, zero duplication):
// BookingTests.xctestplan
{
"configurations": [
{
"name": "English (US)",
"options": {
"language": "en",
"region": "US"
}
},
{
"name": "Spanish (ES)",
"options": {
"language": "es",
"region": "ES"
}
},
{
"name": "Japanese (JP)",
"options": {
"language": "ja",
"region": "JP"
}
}
],
"defaultOptions": {
"testTimeoutsEnabled": true,
"defaultTestExecutionTimeAllowance": 60
},
"testTargets": [
{
"target": {
"containerPath": "container:App.xcodeproj",
"identifier": "BookingFlowTests"
}
}
]
}// Same test runs automatically in all 3 configurations
final class BookingFlowTests: XCTestCase {
func testBookingConfirmationShowsLocalizedDate() {
let app = XCUIApplication()
app.launch() // test plan injects language/region per configuration
app.buttons["Book Now"].tap()
XCTAssertTrue(app.staticTexts["confirmationDate"].exists)
}
}Avoid Mocking Value Types and Simple Logic
Mocking value types, formatters, or pure functions adds coupling to implementation details without improving isolation. When the production code changes a formatting call or struct field, every mock must be updated even though behavior is unchanged. Use real value types and reserve mocks for I/O boundaries.
Incorrect (mocking a formatter couples tests to implementation):
protocol DateFormatting {
func string(from date: Date) -> String
}
struct MockDateFormatter: DateFormatting {
var stubbedResult: String = "Jan 1, 2025"
func string(from date: Date) -> String { stubbedResult } // unnecessary indirection for deterministic logic
}
func testEventDisplayDate() {
let formatter = MockDateFormatter()
let event = EventViewModel(date: Date(), formatter: formatter)
XCTAssertEqual(event.displayDate, "Jan 1, 2025") // tests the mock, not the real formatting
}Correct (real value types keep tests honest and resilient):
func testEventDisplayDate() {
let fixedDate = Date(timeIntervalSince1970: 1_735_689_600) // Jan 1, 2025 00:00 UTC
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.locale = Locale(identifier: "en_US")
formatter.timeZone = TimeZone(identifier: "UTC")
let event = EventViewModel(date: fixedDate, formatter: formatter) // real formatter — no mock needed
XCTAssertEqual(event.displayDate, "Jan 1, 2025") // tests actual formatting output
}Use a Dependency Container for Test Configuration
Manually injecting 5+ mocks into every test method creates verbose setup that obscures the test intent and must be updated everywhere when a new dependency is added. A dependency container centralizes all test doubles in a single configuration point, so each test only overrides the dependency it cares about.
Incorrect (every test repeats all mock injection):
func testPlaceOrder() async throws {
let viewModel = OrderViewModel(
paymentService: MockPaymentService(result: .success(.confirmed)),
inventoryChecker: MockInventoryChecker(isAvailable: true),
analytics: MockAnalytics(),
notificationSender: MockNotificationSender(),
logger: MockLogger() // 5 mocks repeated in every test — adding a 6th requires editing all tests
)
await viewModel.placeOrder(itemId: "SKU-200", quantity: 1)
XCTAssertEqual(viewModel.state, .confirmed)
}Correct (container provides defaults, test overrides only what matters):
struct TestDependencies: DependencyProviding {
var paymentService: PaymentProcessing = MockPaymentService(result: .success(.confirmed))
var inventoryChecker: InventoryChecking = MockInventoryChecker(isAvailable: true)
var analytics: AnalyticsTracking = MockAnalytics()
var notificationSender: NotificationSending = MockNotificationSender()
var logger: Logging = MockLogger() // add new dependencies here once — all tests inherit the default
}
func testPlaceOrderWhenOutOfStock() async throws {
var deps = TestDependencies()
deps.inventoryChecker = MockInventoryChecker(isAvailable: false) // override only the relevant dependency
let viewModel = OrderViewModel(dependencies: deps)
await viewModel.placeOrder(itemId: "SKU-200", quantity: 1)
XCTAssertEqual(viewModel.state, .outOfStock)
}Use In-Memory Fakes for Integration Tests
Clinic architecture alignment (iOS 26 / Swift 6.2): Keep Feature modules on Domain + DesignSystem only; keep App-target DependencyContainer, route shells, and concrete coordinators as the integration point; keep Data as the only owner of SwiftData/network/sync I/O.
Integration tests that spin up a real CoreData or SwiftData stack write to disk, require teardown, and run 5-20x slower than in-memory alternatives. An in-memory fake repository implements the same protocol as the production store, enabling full CRUD integration tests without file system dependencies or cross-test contamination.
Incorrect (real persistence stack is slow and leaks between tests):
func testSaveAndFetchBookmarks() async throws {
let container = try ModelContainer(for: Bookmark.self) // writes to disk — slow setup, requires cleanup
let context = container.mainContext
let repository = BookmarkRepository(context: context)
try await repository.save(Bookmark(title: "Swift Testing", url: "https://swift.org"))
let bookmarks = try await repository.fetchAll()
XCTAssertEqual(bookmarks.count, 1) // may fail if previous test left data behind
}Correct (in-memory fake is fast and isolated per test):
final class InMemoryBookmarkRepository: BookmarkStoring {
private var storage: [Bookmark] = [] // in-memory — zero disk I/O, no cross-test contamination
func save(_ bookmark: Bookmark) async throws {
storage.append(bookmark)
}
func fetchAll() async throws -> [Bookmark] {
return storage
}
func delete(_ bookmark: Bookmark) async throws {
storage.removeAll { $0.id == bookmark.id }
}
}
func testSaveAndFetchBookmarks() async throws {
let repository = InMemoryBookmarkRepository() // fresh state every test — runs in microseconds
try await repository.save(Bookmark(title: "Swift Testing", url: "https://swift.org"))
let bookmarks = try await repository.fetchAll()
XCTAssertEqual(bookmarks.count, 1)
XCTAssertEqual(bookmarks.first?.title, "Swift Testing")
}Create Mocks From Protocols, Not Subclasses
Subclassing concrete types like URLSession to create mocks inherits real implementation behavior that executes silently during tests, producing unpredictable side effects and false confidence. Protocol-based mocks contain only the behavior you define, guaranteeing full isolation.
Incorrect (parent class methods still execute during tests):
class MockURLSession: URLSession {
var mockData: Data?
var mockResponse: URLResponse?
override func data(from url: URL) async throws -> (Data, URLResponse) {
return (mockData!, mockResponse!) // parent class initializers and internal state still run
}
}
func testFetchProfile() async throws {
let session = MockURLSession()
session.mockData = try JSONEncoder().encode(User(name: "Alice"))
session.mockResponse = HTTPURLResponse(url: URL(string: "https://api.example.com")!,
statusCode: 200, httpVersion: nil, headerFields: nil)
let viewModel = ProfileViewModel(session: session)
try await viewModel.loadProfile(userId: "42")
#expect(viewModel.user?.name == "Alice")
}Correct (zero inherited behavior — mock controls everything):
protocol NetworkClient {
func data(from url: URL) async throws -> (Data, URLResponse)
}
extension URLSession: NetworkClient {} // production conformance, no extra code
struct MockNetworkClient: NetworkClient {
var result: Result<(Data, URLResponse), Error> // no parent class — only what you define exists
func data(from url: URL) async throws -> (Data, URLResponse) {
return try result.get()
}
}
func testFetchProfile() async throws {
let responseData = try JSONEncoder().encode(User(name: "Alice"))
let response = HTTPURLResponse(url: URL(string: "https://api.example.com")!,
statusCode: 200, httpVersion: nil, headerFields: nil)!
let client = MockNetworkClient(result: .success((responseData, response)))
let viewModel = ProfileViewModel(networkClient: client)
try await viewModel.loadProfile(userId: "42")
#expect(viewModel.user?.name == "Alice")
}Use Spies to Verify Interactions
Testing only return values misses critical side effects like analytics events, navigation calls, or persistence writes. Spies record method calls and arguments so tests verify that the correct interactions happened with the correct data, without coupling to internal ordering or implementation details.
Incorrect (only checks final state, misses whether analytics was called):
func testCompletePurchase() async throws {
let viewModel = PurchaseViewModel(
paymentService: MockPaymentService(result: .success(.confirmed)),
analytics: MockAnalytics()
)
await viewModel.completePurchase(itemId: "SKU-100", amount: 49_99)
#expect(viewModel.state == .confirmed) // passes even if analytics.track() was never called
}Correct (spy records calls for precise behavior verification):
final class SpyAnalytics: AnalyticsTracking {
private(set) var trackedEvents: [(name: String, properties: [String: String])] = [] // records every call
func track(event: String, properties: [String: String]) {
trackedEvents.append((name: event, properties: properties))
}
}
func testCompletePurchase() async throws {
let spyAnalytics = SpyAnalytics()
let viewModel = PurchaseViewModel(
paymentService: MockPaymentService(result: .success(.confirmed)),
analytics: spyAnalytics
)
await viewModel.completePurchase(itemId: "SKU-100", amount: 49_99)
#expect(viewModel.state == .confirmed)
#expect(spyAnalytics.trackedEvents.count == 1)
#expect(spyAnalytics.trackedEvents.first?.name == "purchase_completed")
#expect(spyAnalytics.trackedEvents.first?.properties["item_id"] == "SKU-100") // verifies exact argument
}Use Stubs for Deterministic Return Values
Tests that depend on live APIs or disk state produce different results across runs, environments, and CI machines. Stubs return predetermined Result values so every assertion runs against an identical, reproducible response regardless of network availability or server state.
Incorrect (test fails when API is down or response changes):
func testLoadWeatherForecast() async throws {
let service = WeatherService(apiKey: "test-key")
let forecast = try await service.fetchForecast(city: "London") // real HTTP request — flaky on CI, slow, rate-limited
#expect(!forecast.daily.isEmpty)
}Correct (stub guarantees identical response every run):
struct StubWeatherClient: WeatherFetching {
var result: Result<Forecast, Error> // predetermined outcome — no network involved
func fetchForecast(city: String) async throws -> Forecast {
return try result.get()
}
}
func testLoadWeatherForecast() async throws {
let forecast = Forecast(daily: [
.init(date: Date(), high: 18, low: 11, condition: .cloudy)
])
let stub = StubWeatherClient(result: .success(forecast))
let viewModel = WeatherViewModel(client: stub)
await viewModel.loadForecast(city: "London")
#expect(viewModel.forecast?.daily.count == 1)
#expect(viewModel.forecast?.daily.first?.condition == .cloudy) // deterministic — same result on every machine
}Snapshot Across Device Sizes and Traits
A single-device snapshot misses constraint breakage on smaller screens, truncated labels on larger type sizes, and invisible text in dark mode. Parameterizing snapshots across a device matrix catches layout and theming regressions in one test function with no additional QA effort per configuration.
Incorrect (only tests one device, misses SE/iPad/dark-mode breakage):
import Testing
import SnapshotTesting
@testable import SettingsFeature
@Suite struct SettingsViewTests {
@Test func settingsLayout() {
let controller = SettingsViewController(viewModel: .stub())
// Passes on iPhone 15 but clips text on SE and breaks in dark mode
assertSnapshot(of: controller, as: .image(on: .iPhone13))
}
}Correct (one function covers 4+ device and trait combinations):
import Testing
import SnapshotTesting
@testable import SettingsFeature
enum DeviceVariant: String, CaseIterable, Sendable {
case iPhoneSE, iPhone15, iPhone15Dark, iPad
var config: ViewImageConfig {
switch self {
case .iPhoneSE: .iPhoneSe
case .iPhone15, .iPhone15Dark: .iPhone13
case .iPad: .iPadMini
}
}
var traits: UITraitCollection {
switch self {
case .iPhone15Dark: UITraitCollection(userInterfaceStyle: .dark)
default: UITraitCollection()
}
}
}
@Suite struct SettingsViewTests {
@Test(arguments: DeviceVariant.allCases)
func settingsLayout(variant: DeviceVariant) {
let controller = SettingsViewController(viewModel: .stub())
assertSnapshot(of: controller, as: .image(on: variant.config, traits: variant.traits), named: variant.rawValue) // each config produces its own reference image
}
}Use Inline Snapshots for Non-Image Assertions
File-based snapshots for text and JSON outputs scatter reference data across __Snapshots__ directories, forcing developers to open a separate file to understand what the test expects. Inline snapshots embed the expected value directly in the test source, making assertions reviewable in a single glance and diffs visible in standard code review tools.
Incorrect (expected JSON lives in a separate reference file):
import Testing
import SnapshotTesting
@testable import Analytics
@Suite struct EventSerializerTests {
@Test func serializesAddToCartEvent() {
let event = AnalyticsEvent.addToCart(itemId: "SKU-1042", price: 29.99)
let serializer = EventSerializer()
let json = serializer.toJSON(event)
// Expected output hidden in __Snapshots__/EventSerializerTests/serializesAddToCartEvent.1.txt
assertSnapshot(of: json, as: .lines)
}
}Correct (expected value visible inline, no separate file needed):
import Testing
import InlineSnapshotTesting
@testable import Analytics
@Suite struct EventSerializerTests {
@Test func serializesAddToCartEvent() {
let event = AnalyticsEvent.addToCart(itemId: "SKU-1042", price: 29.99)
let serializer = EventSerializer()
let json = serializer.toJSON(event)
// Expected value auto-populated on first run, reviewed inline in code review
assertInlineSnapshot(of: json, as: .lines) {
"""
{
"event": "add_to_cart",
"item_id": "SKU-1042",
"price": 29.99
}
"""
}
}
}Use Named Snapshot References for Clarity
Default snapshot filenames derive from the test function name, producing opaque references like testBanner.1.png that reveal nothing about what state is captured. Adding a named: parameter produces filenames like testBanner.emptyCart.png, making failures self-documenting and reducing time to diagnose regressions in CI logs.
Incorrect (generic numbered filenames obscure which state failed):
import Testing
import SnapshotTesting
@testable import BannerFeature
@Suite struct PromoBannerTests {
@Test func bannerStates() {
let emptyController = PromoBannerViewController(viewModel: .stub(itemCount: 0))
let fullController = PromoBannerViewController(viewModel: .stub(itemCount: 5))
// Produces testBannerStates.1.png and testBannerStates.2.png — which state is which?
assertSnapshot(of: emptyController, as: .image(on: .iPhone13))
assertSnapshot(of: fullController, as: .image(on: .iPhone13))
}
}Correct (descriptive names make failures immediately identifiable):
import Testing
import SnapshotTesting
@testable import BannerFeature
@Suite struct PromoBannerTests {
@Test func bannerStates() {
let emptyController = PromoBannerViewController(viewModel: .stub(itemCount: 0))
let fullController = PromoBannerViewController(viewModel: .stub(itemCount: 5))
assertSnapshot(of: emptyController, as: .image(on: .iPhone13), named: "emptyCart") // failure file: bannerStates.emptyCart.png
assertSnapshot(of: fullController, as: .image(on: .iPhone13), named: "fiveItems")
}
}Use swift-snapshot-testing for Visual Regression
Manual visual QA is slow, inconsistent, and scales linearly with every new screen. Point-Free's swift-snapshot-testing generates pixel-accurate reference images on the first run and fails automatically when any rendered output drifts, catching regressions that unit tests and code review cannot detect.
Incorrect (relies on manual visual inspection to catch regressions):
import XCTest
@testable import ProfileFeature
final class ProfileViewTests: XCTestCase {
func testProfileRendersCorrectly() {
let viewModel = ProfileViewModel(
user: .stub(name: "Jane Doe", tier: .premium)
)
let controller = ProfileViewController(viewModel: viewModel)
controller.loadViewIfNeeded()
// No assertion on visual output — regressions discovered in QA or production
XCTAssertNotNil(controller.view)
}
}Correct (pixel-level regression caught automatically on every CI run):
import Testing
import SnapshotTesting
@testable import ProfileFeature
@Suite struct ProfileViewTests {
@Test func profileRendersCorrectly() {
let viewModel = ProfileViewModel(
user: .stub(name: "Jane Doe", tier: .premium)
)
let controller = ProfileViewController(viewModel: viewModel)
assertSnapshot(of: controller, as: .image(on: .iPhone13)) // generates reference image on first run, fails on pixel drift
}
}Test Binding Behavior With @Bindable
Testing two-way bindings by rendering a view, simulating user input, and reading the result back is slow and fragile. With @Observable models, @Bindable exposes writable properties directly — tests mutate the model property and assert the side effects without any view infrastructure.
Incorrect (rendering view hierarchy to verify binding propagation):
import XCTest
import ViewInspector
@testable import Settings
final class TemperatureSettingsTests: XCTestCase {
func testToggleUpdatesUnit() throws {
let model = TemperatureSettingsModel()
let view = TemperatureSettingsView(model: model)
let toggle = try view.inspect().find(ViewType.Toggle.self) // fragile — breaks if view hierarchy changes
try toggle.tap()
XCTAssertTrue(model.useCelsius)
}
}Correct (mutate model directly — binding contract verified without views):
import Testing
@testable import Settings
@Suite struct TemperatureSettingsModelTests {
@Test func toggleUnitRecalculatesDisplayValue() {
let model = TemperatureSettingsModel()
model.temperatureFahrenheit = 98.6
model.useCelsius = true // same mutation a @Bindable toggle would perform
#expect(model.displayValue == "37.0°C") // verify computed output updates from property change
}
}Inject Environment Dependencies for Tests
Views that reach for shared singletons directly cannot be tested with substitute implementations, forcing every test to exercise real services. Accepting dependencies through SwiftUI's environment with a custom EnvironmentKey lets tests inject protocol-based fakes without modifying view code.
Incorrect (singleton access makes substitution impossible):
struct OrderSummaryView: View {
let orderId: String
var body: some View {
AsyncContentView {
let order = try await NetworkService.shared.fetchOrder(orderId) // singleton — tests hit real API
OrderDetailCard(order: order)
}
}
}Correct (EnvironmentKey enables test substitution):
protocol OrderFetching: Sendable {
func fetchOrder(_ id: String) async throws -> Order
}
struct OrderServiceKey: EnvironmentKey {
static let defaultValue: any OrderFetching = LiveOrderService()
}
extension EnvironmentValues {
var orderService: any OrderFetching {
get { self[OrderServiceKey.self] }
set { self[OrderServiceKey.self] = newValue }
}
}
struct OrderSummaryView: View {
let orderId: String
@Environment(\.orderService) private var orderService // injected via EnvironmentKey
var body: some View {
AsyncContentView {
let order = try await orderService.fetchOrder(orderId)
OrderDetailCard(order: order)
}
}
}
// In tests:
let mock = StubOrderService(stubbedOrder: Order.sample)
OrderSummaryView(orderId: "ORD-001")
.environment(\.orderService, mock) // swap real service for deterministic fakeUse Previews as Visual Smoke Tests
A single default preview only renders the happy-path layout, leaving empty states, error banners, long text truncation, and loading skeletons invisible until they reach production. Configuring previews for each meaningful state turns the canvas into an always-visible regression surface during development.
Incorrect (single state leaves most layouts unverified):
#Preview {
PaymentStatusView(status: .success(amount: 49.99)) // only one state — errors, empty, loading never seen
}Correct (each state visible on canvas as a persistent smoke test):
#Preview("Success") {
PaymentStatusView(status: .success(amount: 49.99))
}
#Preview("Pending") {
PaymentStatusView(status: .pending)
}
#Preview("Failed - Declined") {
PaymentStatusView(status: .failed(.cardDeclined)) // error layout verified at a glance
}
#Preview("Failed - Network") {
PaymentStatusView(status: .failed(.networkUnavailable))
}
#Preview("Loading") {
PaymentStatusView(status: .loading) // skeleton layout stays visible during development
}Test @Observable Models as Plain Objects
Rendering a SwiftUI view to test model behavior adds host-app boot time, view lifecycle overhead, and fragile UI assertions for logic that has nothing to do with layout. Instantiating the @Observable model directly exercises the same state mutations at unit-test speed with deterministic control.
Incorrect (view rendering overhead to verify model logic):
import XCTest
import ViewInspector
@testable import Recipes
final class RecipeListTests: XCTestCase {
func testAddingRecipeUpdatesCount() throws {
let model = RecipeListModel()
let view = RecipeListView(model: model) // boots SwiftUI rendering pipeline to test a count
let list = try view.inspect().find(ViewType.List.self)
model.add(Recipe(name: "Margherita", servings: 4))
let updatedList = try RecipeListView(model: model).inspect().find(ViewType.List.self)
XCTAssertEqual(try updatedList.count, 1)
}
}Correct (direct model test — no rendering, no view dependency):
import Testing
@testable import Recipes
@Suite struct RecipeListModelTests {
@Test func addingRecipeUpdatesCount() {
let model = RecipeListModel() // plain object — no SwiftUI host required
model.add(Recipe(name: "Margherita", servings: 4))
#expect(model.recipes.count == 1)
#expect(model.recipes.first?.name == "Margherita")
}
}Extract Logic From Views Into Testable Models
Business logic embedded inside a View's body or action closures can only be exercised by rendering the full view hierarchy. Extracting that logic into an @Observable model makes it callable from plain unit tests with no SwiftUI dependency.
Incorrect (validation and formatting logic trapped inside the view):
struct CheckoutView: View {
@State private var items: [CartItem] = []
@State private var promoCode: String = ""
@State private var errorMessage: String?
var body: some View {
List(items) { item in
CartRowView(item: item)
}
TextField("Promo code", text: $promoCode)
Button("Apply") {
if promoCode.count < 4 || promoCode.count > 12 { // validation buried in view — untestable without rendering
errorMessage = "Code must be 4–12 characters"
} else {
let discount = items.reduce(0.0) { $0 + $1.price } * 0.15
errorMessage = nil
applyDiscount(discount)
}
}
}
}Correct (logic lives in a testable model, view only binds):
@Observable
class CheckoutModel {
var items: [CartItem] = []
var promoCode: String = ""
var errorMessage: String?
func applyPromoCode() {
guard promoCode.count >= 4, promoCode.count <= 12 else { // testable without any view
errorMessage = "Code must be 4–12 characters"
return
}
let discount = items.reduce(0.0) { $0 + $1.price } * 0.15
errorMessage = nil
applyDiscount(discount)
}
}
struct CheckoutView: View {
@State private var model = CheckoutModel()
var body: some View {
List(model.items) { item in
CartRowView(item: item)
}
TextField("Promo code", text: $model.promoCode)
Button("Apply") { model.applyPromoCode() } // view delegates, never decides
}
}Use Accessibility Identifiers for Element Queries
Querying elements by visible text couples tests to copy and localization strings. Any label change or translation update breaks every test that references it, even though the UI behavior is unchanged.
Incorrect (breaks when button text or locale changes):
func testAddToCartButton() {
let app = XCUIApplication()
app.launch()
// Breaks if text changes to "Add to Bag" or app runs in Spanish
let addButton = app.buttons["Add to Cart"]
addButton.tap()
let confirmationLabel = app.staticTexts["Item added successfully"]
XCTAssertTrue(confirmationLabel.exists)
}Correct (stable across text and localization changes):
func testAddToCartButton() {
let app = XCUIApplication()
app.launch()
// Survives copy changes and localization — identifier is developer-controlled
let addButton = app.buttons["addToCartButton"]
addButton.tap()
let confirmationLabel = app.staticTexts["cartConfirmationLabel"]
XCTAssertTrue(confirmationLabel.exists)
}Configure Test State via Launch Arguments
UI tests that depend on real API responses or pre-existing server state are slow, flaky, and impossible to run offline. Passing launch arguments lets the app switch to mock data at startup, making tests deterministic and self-contained.
Incorrect (depends on real API and existing server data):
func testOrderHistoryDisplaysRecentOrders() {
let app = XCUIApplication()
app.launch()
// Relies on real API — fails if server is down, data changes, or network is slow
let loginPage = LoginPage(app: app)
loginPage.login(email: "test@example.com", password: "livePassword")
app.tabBars.buttons["Orders"].tap()
XCTAssertTrue(app.cells["orderCell_0"].waitForExistence(timeout: 10))
}Correct (deterministic with mock data flags):
func testOrderHistoryDisplaysRecentOrders() {
let app = XCUIApplication()
// App checks these flags at startup and loads stub responses instead of calling the network
app.launchArguments += ["--uitesting", "--mock-orders"]
app.launch()
let loginPage = LoginPage(app: app)
loginPage.login(email: "test@example.com", password: "livePassword")
app.tabBars.buttons["Orders"].tap()
// Passes offline, in CI, and regardless of server state
XCTAssertTrue(app.cells["orderCell_0"].waitForExistence(timeout: 5))
}Encapsulate Screens in Page Objects
Scattering raw XCUIElement queries across test methods means a single UI change — a renamed identifier or restructured hierarchy — forces updates in every test that touches that screen. Page objects centralize element access so changes propagate from one place.
Incorrect (duplicated element queries across tests):
func testLoginShowsDashboard() {
let app = XCUIApplication()
app.launch()
app.textFields["emailField"].tap()
app.textFields["emailField"].typeText("user@example.com")
app.secureTextFields["passwordField"].tap()
app.secureTextFields["passwordField"].typeText("securePass1")
app.buttons["loginButton"].tap()
XCTAssertTrue(app.staticTexts["dashboardTitle"].waitForExistence(timeout: 5))
}
func testLoginShowsErrorForInvalidCredentials() {
let app = XCUIApplication()
app.launch()
// Same queries duplicated — if "emailField" identifier changes, both tests break
app.textFields["emailField"].tap()
app.textFields["emailField"].typeText("bad@example.com")
app.secureTextFields["passwordField"].tap()
app.secureTextFields["passwordField"].typeText("wrong")
app.buttons["loginButton"].tap()
XCTAssertTrue(app.staticTexts["loginErrorLabel"].waitForExistence(timeout: 5))
}Correct (single point of change per screen):
// Page object encapsulates all element access for the login screen
struct LoginPage {
private let app: XCUIApplication
init(app: XCUIApplication) { self.app = app }
var emailField: XCUIElement { app.textFields["emailField"] }
var passwordField: XCUIElement { app.secureTextFields["passwordField"] }
var loginButton: XCUIElement { app.buttons["loginButton"] }
var errorLabel: XCUIElement { app.staticTexts["loginErrorLabel"] }
func login(email: String, password: String) {
emailField.tap()
emailField.typeText(email)
passwordField.tap()
passwordField.typeText(password)
loginButton.tap()
}
}
func testLoginShowsDashboard() {
let app = XCUIApplication()
app.launch()
let loginPage = LoginPage(app: app)
loginPage.login(email: "user@example.com", password: "securePass1")
XCTAssertTrue(app.staticTexts["dashboardTitle"].waitForExistence(timeout: 5))
}
func testLoginShowsErrorForInvalidCredentials() {
let app = XCUIApplication()
app.launch()
let loginPage = LoginPage(app: app)
loginPage.login(email: "bad@example.com", password: "wrong")
// Identifier change only requires updating LoginPage, not every test
XCTAssertTrue(loginPage.errorLabel.waitForExistence(timeout: 5))
}Reset App State Between UI Tests
When tests share persisted state — UserDefaults, Keychain tokens, Core Data — a failure in one test corrupts the environment for every subsequent test. Resetting state on each launch ensures every test starts from a known baseline and failures stay isolated.
Incorrect (tests depend on state left by previous tests):
func testLoginSavesSession() {
let app = XCUIApplication()
app.launch()
let loginPage = LoginPage(app: app)
loginPage.login(email: "user@example.com", password: "securePass1")
// Saves session to UserDefaults — next test inherits this logged-in state
XCTAssertTrue(app.staticTexts["dashboardTitle"].waitForExistence(timeout: 5))
}
func testProfileScreenShowsUserEmail() {
let app = XCUIApplication()
app.launch()
// Assumes previous test logged in successfully — fails if run alone or in different order
app.tabBars.buttons["Profile"].tap()
XCTAssertTrue(app.staticTexts["user@example.com"].waitForExistence(timeout: 5))
}Correct (each test resets state and sets up its own preconditions):
func testLoginSavesSession() {
let app = XCUIApplication()
app.launchArguments += ["--uitesting", "--reset-state"]
app.launch()
let loginPage = LoginPage(app: app)
loginPage.login(email: "user@example.com", password: "securePass1")
XCTAssertTrue(app.staticTexts["dashboardTitle"].waitForExistence(timeout: 5))
}
func testProfileScreenShowsUserEmail() {
let app = XCUIApplication()
// Resets state then pre-seeds a session — runs correctly in any order or in isolation
app.launchArguments += ["--uitesting", "--reset-state", "--mock-logged-in-user"]
app.launch()
app.tabBars.buttons["Profile"].tap()
XCTAssertTrue(app.staticTexts["user@example.com"].waitForExistence(timeout: 5))
}Test Complete User Journeys, Not Individual Screens
Testing screens in isolation verifies that elements exist but misses broken transitions, lost state between screens, and incorrect deep-link handling. Journey tests exercise the same paths real users take, catching integration failures before they ship.
Incorrect (isolated screen checks that miss flow bugs):
func testProductDetailScreenShowsAddButton() {
let app = XCUIApplication()
app.launchArguments += ["--uitesting", "--mock-catalog"]
app.launch()
// Navigates directly — never validates the transition from browse → detail
app.tables.cells["productCell_0"].tap()
XCTAssertTrue(app.buttons["addToCartButton"].exists)
}
func testCartScreenShowsCheckoutButton() {
let app = XCUIApplication()
app.launchArguments += ["--uitesting", "--mock-cart-with-item"]
app.launch()
// Skips adding an item — never validates the add-to-cart integration
app.tabBars.buttons["Cart"].tap()
XCTAssertTrue(app.buttons["checkoutButton"].exists)
}Correct (end-to-end journey validates transitions and state):
func testBrowseToCheckoutJourney() {
let app = XCUIApplication()
app.launchArguments += ["--uitesting", "--mock-catalog"]
app.launch()
// Browse → Detail
app.tables.cells["productCell_0"].tap()
XCTAssertTrue(app.buttons["addToCartButton"].waitForExistence(timeout: 5))
// Detail → Cart (validates item actually persists across screens)
app.buttons["addToCartButton"].tap()
app.tabBars.buttons["Cart"].tap()
XCTAssertTrue(app.cells["cartItemCell_0"].waitForExistence(timeout: 5))
// Cart → Checkout
app.buttons["checkoutButton"].tap()
XCTAssertTrue(app.staticTexts["orderSummaryTitle"].waitForExistence(timeout: 5))
}Wait for Elements Instead of Using sleep()
Fixed-duration sleeps either wait too long (slowing the suite) or not long enough (causing flaky failures). Element-based waits resolve the instant the condition is met and fail with a clear timeout, making tests both faster and more reliable.
Incorrect (arbitrary delay that is too short on CI, too long locally):
func testSearchResultsAppear() {
let app = XCUIApplication()
app.launch()
app.searchFields["searchField"].tap()
app.searchFields["searchField"].typeText("running shoes")
app.buttons["searchButton"].tap()
// 3 seconds may not be enough on CI; wastes time when results load in 500ms
Thread.sleep(forTimeInterval: 3)
XCTAssertTrue(app.cells["productCell_0"].exists)
}Correct (resolves as soon as element appears, fails clearly on timeout):
func testSearchResultsAppear() {
let app = XCUIApplication()
app.launch()
app.searchFields["searchField"].tap()
app.searchFields["searchField"].typeText("running shoes")
app.buttons["searchButton"].tap()
// Returns immediately when element appears; fails with descriptive timeout error
let firstResult = app.cells["productCell_0"]
XCTAssertTrue(firstResult.waitForExistence(timeout: 10))
}Name Tests After the Behavior They Verify
Vague test names like testLogin() force developers to read the test body to understand what broke. A name that states the scenario and expected outcome turns every failure into an instant diagnosis, saving 2-5 minutes of investigation per red test in CI.
Incorrect (name describes the feature, not the behavior):
@Suite struct AuthServiceTests {
@Test func testLogin() { // which login scenario? what should happen?
let service = AuthService(client: MockClient(returning: .expiredToken))
let result = service.login(email: "user@example.com", password: "valid-password")
#expect(result == .failure(.tokenExpired))
}
@Test func testLogin2() { // numbered variants are unreadable in CI output
let service = AuthService(client: MockClient(returning: .success))
let result = service.login(email: "", password: "valid-password")
#expect(result == .failure(.invalidEmail))
}
}Correct (failure message tells you exactly what broke):
@Suite struct AuthServiceTests {
@Test("Login with expired token returns authentication error")
func loginWithExpiredToken_returnsAuthenticationError() { // display name for CI, method name for code navigation
let service = AuthService(client: MockClient(returning: .expiredToken))
let result = service.login(email: "user@example.com", password: "valid-password")
#expect(result == .failure(.tokenExpired))
}
@Test("Login with empty email returns validation error")
func loginWithEmptyEmail_returnsValidationError() {
let service = AuthService(client: MockClient(returning: .success))
let result = service.login(email: "", password: "valid-password")
#expect(result == .failure(.invalidEmail))
}
}Use #expect and #require Over XCTAssert
XCTAssert functions produce generic failure messages like "XCTAssertEqual failed: (3) is not equal to (5)" with no context about what the values represent. #expect captures the entire expression tree at the macro level, showing property names, intermediate values, and the exact subexpression that diverged, cutting average diagnosis time from minutes to seconds.
Incorrect (failure output hides what the values represent):
func testAppliesLoyaltyDiscount() {
let pricing = PricingEngine(loyaltyTier: .gold)
let quote = pricing.calculateQuote(for: cartItems)
XCTAssertEqual(quote.discount, 0.15) // failure: "XCTAssertEqual failed: (0.10) is not equal to (0.15)" — which discount? why 0.10?
XCTAssertTrue(quote.lineItems.allSatisfy { $0.discountApplied }) // failure: "XCTAssertTrue failed" — no detail on which item failed
XCTAssertGreaterThan(quote.total, 0)
}Correct (failure output shows the full expression with values):
@Test func appliesLoyaltyDiscount() {
let pricing = PricingEngine(loyaltyTier: .gold)
let quote = pricing.calculateQuote(for: cartItems)
#expect(quote.discount == 0.15) // failure: "Expectation failed: (quote.discount → 0.10) == 0.15"
#expect(quote.lineItems.allSatisfy { $0.discountApplied }) // failure shows which lineItem has discountApplied == false
#expect(quote.total > 0)
}Use Parameterized Tests for Input Variations
Copy-pasting test functions with different inputs creates maintenance debt and hides the pattern being tested. Parameterized tests declare input-output pairs once, then Swift Testing runs each combination as an independent case with its own pass/fail status, making it trivial to add new edge cases.
Incorrect (five copy-pasted functions testing the same logic):
@Suite struct CurrencyFormatterTests {
let formatter = CurrencyFormatter()
@Test func formatsUSD() {
#expect(formatter.format(amount: 1234.5, currency: .usd) == "$1,234.50")
}
@Test func formatsEUR() {
#expect(formatter.format(amount: 1234.5, currency: .eur) == "€1,234.50")
}
@Test func formatsGBP() {
#expect(formatter.format(amount: 1234.5, currency: .gbp) == "£1,234.50")
}
@Test func formatsJPY() { // each new currency = entire new function
#expect(formatter.format(amount: 1234.5, currency: .jpy) == "¥1,235")
}
@Test func formatsZeroUSD() {
#expect(formatter.format(amount: 0, currency: .usd) == "$0.00")
}
}Correct (one function covers all cases, adding a row takes one line):
@Suite struct CurrencyFormatterTests {
let formatter = CurrencyFormatter()
@Test(arguments: [
(1234.5, Currency.usd, "$1,234.50"),
(1234.5, Currency.eur, "€1,234.50"),
(1234.5, Currency.gbp, "£1,234.50"),
(1234.5, Currency.jpy, "¥1,235"),
(0, Currency.usd, "$0.00"),
])
func formatsAmount(amount: Double, currency: Currency, expected: String) {
#expect(formatter.format(amount: amount, currency: currency) == expected) // each tuple runs as independent test case
}
}Use #require for Test Preconditions
When a test depends on an optional value being non-nil, force-unwrapping crashes the entire test suite with no diagnostic context, and optional chaining silently skips assertions on nil. #require unwraps the value or immediately fails the single test with a message showing exactly which precondition was not met.
Incorrect (force-unwrap crashes the runner, optional chaining hides bugs):
@Test func displaysShippingEstimate() async {
let store = OrderStore(client: MockOrderClient())
await store.loadOrders(for: "customer-42")
let firstOrder = store.orders.first! // fatal error if empty — kills entire test process
let tracking = firstOrder.tracking
#expect(tracking?.estimatedDelivery != nil) // silently passes if tracking is nil
#expect(tracking?.carrier == "FedEx") // silently passes — nil != "FedEx" is false, but test does not fail
}Correct (unwrap-or-fail with a clear diagnostic):
@Test func displaysShippingEstimate() async throws {
let store = OrderStore(client: MockOrderClient())
await store.loadOrders(for: "customer-42")
let firstOrder = try #require(store.orders.first) // fails: "Expectation failed: store.orders.first is nil"
let tracking = try #require(firstOrder.tracking) // fails fast here instead of cascading nil through assertions
#expect(tracking.estimatedDelivery != nil)
#expect(tracking.carrier == "FedEx")
}Use Swift Testing Over XCTest for New Tests
XCTest requires class inheritance, setUp/tearDown lifecycle methods, and verbose assertion functions that obscure test intent. Swift Testing replaces this ceremony with lightweight structs, @Test attributes, and #expect macros that read like plain-language specifications and produce richer failure diagnostics.
Incorrect (boilerplate obscures the actual test logic):
import XCTest
@testable import Payments
final class PaymentValidatorTests: XCTestCase { // class inheritance required
var validator: PaymentValidator!
override func setUp() { // lifecycle method for simple init
super.setUp()
validator = PaymentValidator()
}
override func tearDown() {
validator = nil
super.tearDown()
}
func testValidatesMinimumAmount() {
let result = validator.validate(amount: 0.50, currency: .usd)
XCTAssertFalse(result.isValid) // failure message: "XCTAssertFalse failed"
XCTAssertEqual(result.error, .belowMinimum)
}
}Correct (zero boilerplate, intent-revealing structure):
import Testing
@testable import Payments
@Suite struct PaymentValidatorTests {
let validator = PaymentValidator() // init replaces setUp, no tearDown needed
@Test func validatesMinimumAmount() {
let result = validator.validate(amount: 0.50, currency: .usd)
#expect(!result.isValid) // failure shows: "Expectation failed: !(PaymentResult(isValid: false, …).isValid)"
#expect(result.error == .belowMinimum)
}
}Organize Related Tests Into Suites
Flat test files with dozens of unrelated functions make it hard to run a subset and force duplicate setup code. @Suite structs group related tests under a named scope with shared initialization through init(), and nested suites create a hierarchy that mirrors the feature structure of the production code.
Incorrect (flat functions with repeated setup in every test):
@Test func createsCartWithSingleItem() {
let catalog = StubCatalog()
let cart = ShoppingCart(catalog: catalog) // duplicated in every test
cart.add(itemId: "sku-100", quantity: 1)
#expect(cart.items.count == 1)
}
@Test func calculatesCartSubtotal() {
let catalog = StubCatalog()
let cart = ShoppingCart(catalog: catalog) // same setup, no way to run cart tests together
cart.add(itemId: "sku-100", quantity: 2)
#expect(cart.subtotal == 39.98)
}
@Test func appliesFreeShippingThreshold() {
let catalog = StubCatalog()
let cart = ShoppingCart(catalog: catalog)
cart.add(itemId: "sku-200", quantity: 3)
#expect(cart.shippingCost == 0)
}Correct (suite groups tests with shared setup via init):
@Suite("Shopping Cart")
struct ShoppingCartTests {
let cart: ShoppingCart // ShoppingCart is a final class — init() creates a fresh instance per test
init() { // Swift Testing calls init() before each @Test — replaces setUp without inheritance
let catalog = StubCatalog()
cart = ShoppingCart(catalog: catalog)
}
@Test func createsCartWithSingleItem() {
cart.add(itemId: "sku-100", quantity: 1)
#expect(cart.items.count == 1)
}
@Test func calculatesSubtotal() {
cart.add(itemId: "sku-100", quantity: 2)
#expect(cart.subtotal == 39.98)
}
@Test func appliesFreeShippingThreshold() {
cart.add(itemId: "sku-200", quantity: 3)
#expect(cart.shippingCost == 0)
}
}Use Tags to Categorize Cross-Cutting Tests
Splitting tests into separate targets for smoke, regression, and performance creates build-configuration overhead and forces each test into exactly one category. Swift Testing tags let any test belong to multiple categories and run filtered subsets from the command line or CI without extra targets.
Incorrect (separate targets that duplicate build settings and slow CI):
// SmokeTests target — separate scheme, separate build config
final class SmokeLoginTests: XCTestCase {
func testLoginScreenLoads() {
let vm = LoginViewModel(auth: MockAuth())
XCTAssertNotNil(vm) // duplicated build settings just to run this subset
}
}
// RegressionTests target — another scheme to maintain
final class RegressionLoginTests: XCTestCase {
func testLoginHandlesUnicodeEmail() {
let vm = LoginViewModel(auth: MockAuth())
let result = vm.login(email: "user@example.co.jp", password: "pass")
XCTAssertEqual(result, .success)
}
}Correct (tags on tests, filter by tag in CI):
extension Tag {
@Tag static var smoke: Self
@Tag static var regression: Self
}
@Suite struct LoginViewModelTests {
let viewModel = LoginViewModel(auth: MockAuth())
@Test(.tags(.smoke))
func screenLoads() {
#expect(viewModel != nil)
}
@Test(.tags(.regression, .smoke)) // one test can belong to multiple categories
func handlesUnicodeEmail() {
let result = viewModel.login(email: "user@example.co.jp", password: "pass")
#expect(result == .success)
}
}
// CI: swift test --filter .smoke — no extra targets neededRelated skills
FAQ
What does ios-testing do?
ios-testing: A skill for development. This provides functionality for development workflows.
When should I use ios-testing?
When you need to use ios-testing for development tasks, or when ios-testing: a skill for development. this provides functionality for development workflows.
What are the main capabilities?
ios-testing.