8.1 — Testing Philosophy
Opening scenario
Your team just shipped a release that broke the checkout flow on iPad. The bug? A ViewModel returned the wrong currency formatter when the locale was Swiss German. There were 412 unit tests. None of them exercised non-English locales. Coverage was 87%. The CTO wants to know how this happened “with all that testing.”
Coverage isn’t correctness. Tests measure what you decided was worth measuring. Most iOS teams test the wrong layer.
Context taxonomy
| Concept | Context | Why it matters | Common confusion |
|---|---|---|---|
| Unit test | Single function/type, no I/O | Fastest signal; runs in millis | “I tested my ViewController” → that’s integration |
| Integration test | 2+ types together, real boundaries (DB, network) | Catches wiring bugs | Slow, flaky, expensive — keep few |
| UI test | Whole app, simulated taps | Catches “doesn’t launch” regressions | Brittle, slowest tier, run in CI smoke |
| Snapshot test | Rendered view → image diff | Catches visual regressions cheaply | Wildly different across Xcode versions |
| Test pyramid | Many unit, fewer integration, fewest UI | Inverted = slow + flaky suite | “Testing trophy” is a competing model |
Concept → Why → How → Code
Concept: tests exist on a spectrum from “pure logic in a function” to “the whole app on a real device.” Each level costs more and breaks for more reasons. Build a portfolio of tests biased toward the cheap, fast end.
Why: a 30-second test suite gets run before every commit. A 30-minute suite gets run by CI only — and disabled when it goes red on a flaky day. Speed and reliability are correctness multipliers.
How: identify the layers in your app (Models, ViewModels, Services, Views) and write the bulk of tests where logic lives (ViewModels and Services). Don’t test SwiftUI body — Apple already tested that. Don’t test thin pass-through functions — they have no logic to verify.
Code — anatomy of a layered test strategy:
// ✅ Unit test — pure logic, no I/O, fast
func test_cartTotal_includesDiscount() {
let cart = Cart(items: [Item(price: 100)], discountPct: 10)
XCTAssertEqual(cart.total, 90)
}
// ✅ Integration test — ViewModel + mocked Service
func test_loadProducts_populatesItems() async throws {
let vm = ProductListViewModel(api: MockAPI(stub: [Product(id: "1")]))
await vm.load()
XCTAssertEqual(vm.items.count, 1)
}
// ⚠️ UI test — keep few, gate on critical user paths only
func test_userCanCompleteCheckout() {
let app = XCUIApplication(); app.launch()
app.buttons["Buy"].tap()
XCTAssertTrue(app.staticTexts["Order confirmed"].waitForExistence(timeout: 5))
}
What to test, what to skip
Test:
- ViewModels (state transitions, business logic)
- Services (parsing, validation, retry logic)
- Pure functions (formatters, calculators)
- Critical user flows (checkout, sign-in) — one UI test each
Skip:
- SwiftUI
body(Apple’s job) - Trivial getters/setters
- Generated code
descriptionoverrides nobody reads- Single-line wrappers around UIKit
In the wild
- Apple’s WWDC sample code has shockingly little test coverage — sample code is documentation, not production.
- Airbnb publicly stated they killed most of their UI tests because the maintenance cost exceeded the value.
- Square runs 5,000+ unit tests in under 90 seconds on their iOS codebase.
- Spotify maintains a strict pyramid: ~10k unit, ~500 integration, ~50 UI.
Common misconceptions
- “100% coverage = no bugs.” False. Coverage means “this line executed during a test”; it says nothing about whether you asserted the right thing.
- “More tests are always better.” A flaky test is worse than no test — it teaches your team to ignore red builds.
- “You should write tests for every PR.” Test what changed; refactoring untouched code to make it testable inflates PR diffs and reviewer fatigue.
- “UI tests replace QA.” They catch regressions in known paths only. Exploratory testing finds the bugs UI tests can’t.
- “Testing slows you down.” Untested code makes you afraid to refactor, which slows you down far more over a year.
Seasoned engineer’s take
The number that matters isn’t coverage — it’s how often you ship a change with confidence in under an hour. Teams obsessed with 90%+ coverage usually have brittle, mocked-to-oblivion tests that lock the implementation in place. Teams with 60% coverage on the right layers ship faster and break less. Aim for ViewModels and Services at near-100%; let Views drift toward 0%.
[!TIP] Run your test suite right now and time it. If it’s over 60 seconds, find your slowest 5 tests — they’re almost certainly doing real I/O that should be mocked.
[!WARNING] Tests that use real network calls, real timers, real file I/O, or
Task.sleepare not unit tests. They’re integration tests pretending to be unit tests, and they will eventually flake on CI.
Interview corner
Junior — “What’s the difference between a unit test and an integration test?” A unit test exercises one type in isolation with no I/O — typically a function or method on a struct. An integration test exercises multiple types together, possibly hitting a database, network, or filesystem. Unit tests are fast and deterministic; integration tests are slower and verify wiring.
Mid — “Your app has 90% coverage but you still ship bugs. What’s wrong?”
Coverage measures execution, not assertion strength. The team might be writing tests that exercise code paths without asserting the correct outcomes, or they might be testing trivial code (getters, generated code) while missing edge cases in business logic. Coverage on ViewModels and Services matters; coverage on View.body doesn’t.
Senior — “Design a testing strategy for a new payments feature.”
Pyramid first. ViewModels for the cart, the payment method picker, and the receipt — 100% unit coverage including locale and currency edge cases. Service layer (PaymentAPI, ReceiptValidator) — unit tested with mocked URL sessions, plus 2–3 integration tests against a sandbox endpoint behind a feature flag. One end-to-end XCUITest for the happy path. Snapshot tests for the receipt view at 3 dynamic type sizes. CI gate: full suite must run in under 5 minutes; UI tests separated into a nightly job. I’d also consider running the integration tier against a recorded HTTP fixture to keep PR builds offline.
Red flag — “I write integration tests for everything because they catch more bugs.” This person hasn’t felt the pain of a 40-minute CI build that flakes 30% of the time.
Lab preview
Lab 8.1 (TDD Feature) drives this home: you’ll write tests before the implementation for a NetworkClient, and feel firsthand how testability shapes design.
Next: XCTest Unit Testing