A useful test suite does not try to test every implementation detail. It protects the behavior that would be expensive to break: business rules, state transitions, integrations and critical user journeys.

Start with risk, not coverage

Coverage is a signal, not a goal. Prioritize the parts of the app where a defect would affect money, identity, reservations, medical information or a core conversion flow.

Map each high-risk behavior to the cheapest test that can verify it reliably. Pure business rules belong in fast unit tests. Integration boundaries need contract-focused tests. A few critical journeys deserve UI automation.

Test domain rules as pure behavior

Use cases and domain services should be testable without SwiftUI, networking or persistence. This is where you verify pricing, eligibility, validation and state-transition rules.

func test_walletFullyCoversOrder_returnsWalletOnlyPayment() async throws {
    let useCase = ResolvePaymentOptionsUseCase()
    let result = useCase.execute(total: 500, walletBalance: 700)

    XCTAssertEqual(result, [.wallet])
}

These tests should be fast, deterministic and expressive enough to read like product requirements.

Test view models through observable state

A view-model test should exercise inputs and assert visible state, not private methods. Inject use cases, clocks, UUID generators and schedulers so the test controls every external influence.

@MainActor
func test_load_success_updatesLoadedState() async {
    let repository = TripsRepositoryStub(result: .success([.fixture]))
    let viewModel = TripsViewModel(repository: repository)

    await viewModel.load()

    XCTAssertEqual(viewModel.state, .loaded([.fixture]))
}

Also test repeated actions, retry behavior, cancellation and stale responses. Those are common production failures in async interfaces.

Prefer fakes with behavior

A useful fake does more than return a constant value. It can record calls, simulate latency, emit failures and allow the test to release suspended work.

  • Use stubs for simple fixed responses.
  • Use spies when call verification matters.
  • Use fakes when realistic stateful behavior improves confidence.
  • Avoid mocking every concrete implementation detail.

Keep SwiftUI views thin enough to trust

Views become easier to validate when they render explicit state and send user intent back to a view model. Complex pricing or eligibility logic should not live inside a chain of view modifiers.

Previews are useful for visual development, but they are not automated tests. Snapshot testing can protect important visual states when the team accepts the maintenance cost, especially for design-system components and high-value screens.

Automate critical UI journeys

UI tests are slower and more fragile, so use them selectively. Protect the paths that connect multiple layers and would be difficult to verify otherwise.

  1. Successful sign-in.
  2. Search to selection.
  3. Checkout and payment handoff.
  4. Reservation confirmation.
  5. Recovery from a meaningful failure.

Use stable accessibility identifiers and launch arguments that switch the app to deterministic test data.

Run tests as part of delivery

Tests provide value when they run consistently. Keep fast unit tests on every pull request, run integration and UI suites at an appropriate cadence, and publish failures in a way the team can act on quickly.

Quarantine is not a permanent strategy. A flaky test should be fixed, redesigned or removed because ignored failures train the team not to trust the pipeline.

Testing checklist

  • Business rules are verified without UI frameworks.
  • View-model tests assert public state and user intent.
  • Time, randomness and external services are controllable.
  • Critical journeys have a small number of stable UI tests.
  • Tests cover cancellation, retries and repeated actions.
  • The suite runs automatically and failures are actionable.
AE

Amgad ElNezamy

iOS engineer working across SwiftUI, UIKit, architecture, payments and production mobile product delivery.

Back to all articles