What is the test pyramid for an iOS app, and what does each layer cost and catch?

22.1.1 · L2 · Interview

Predict first

A team has 20 unit tests and 400 UI tests; the suite takes 50 minutes and fails randomly. What is the most useful change?

// A: add more UI tests to be safe
// B: move logic checks down into unit and integration tests and keep a few critical UI journeys
// C: retry every failing test three times
  • A
  • B
  • C

Mental model. Checking a building: inspect each brick quickly and often (unit), check that walls meet (integration), and walk through the finished building only a few times because it takes all day (UI).

The pyramid is a rule of thumb about proportion: many fast, narrow tests at the bottom, fewer broad ones above. Unit tests exercise one type or function in isolation (a reducer, a formatter, a view model) with collaborators replaced; they run in milliseconds, need no simulator UI, and point at the exact line that broke. Integration tests wire real pieces together (a repository over an in-memory database, a service over a stubbed network) and catch the mistakes that live between components. Snapshot tests compare rendered output with a stored reference and catch visual regressions cheaply, at the cost of maintenance. UI tests drive the real app through the accessibility layer, so they catch whole-journey and wiring bugs but are the slowest, the most fragile and the hardest to diagnose.

The reason for the shape is feedback time and reliability. A failure in a unit test tells you what broke in seconds; a failure in a UI test says “the login journey failed” after minutes, and may be a timing flake, not a bug. Inverting the pyramid (an “ice cream cone” of mostly UI tests) gives a suite that is slow, flaky and expensive. Design the app so logic can be tested low: keep decisions in plain Swift types, put UIKit and SwiftUI behind thin layers, and inject the things that make tests slow or random (network, clock, storage). Keep a small, deliberate set of UI tests for the journeys that must never break (launch, sign-in, purchase). Do not chase the picture literally: some teams find a “trophy” shape with more integration tests gives the best return, because they exercise real wiring cheaply. What matters is knowing what each layer buys.

Retrying hides flakes and more UI tests make it worse; the cure is to test each thing at the cheapest layer that can catch it.

SpeedCatchesWeakness
Unitmillisecondslogic errors in one unitblind to wiring and real behaviour
Integrationtens to hundreds of msmistakes between components, data and mappingharder to set up and to localise
Snapshotfast to rununintended visual changesbrittle references, rendering differences
UIseconds to minutes eachwhole-journey and wiring failuresslow, flaky, hard to debug

Common traps

  • Testing everything through the UI. The suite takes an hour, fails randomly and nobody trusts it.
  • Only unit tests with everything mocked. Each part passes while the app fails because the parts were never connected.

They might follow up with

  • Where does a snapshot test sit? Between unit and UI: fast to run and good for visual regressions, but costly to keep accurate.
  • How many UI tests is right? Few: the critical journeys, written to be stable, with everything else tested lower.

Related questions

  • 22.1.2 What should you not test, and how do you decide whether a test is worth having?
  • 22.2.1 How do XCTest and Swift Testing differ, and when do you still use XCTest?
  • 22.5.1 How do snapshot tests work, and what are their pitfalls: rendering differences, dynamic content, size, and…
  • 22.7.2 How do you design a quality system for a large team: test tiers, ownership, coverage, flake budgets and…

What should you not test, and how do you decide whether a test is… →