A unit test checks one type in isolation with its dependencies faked and runs in milliseconds; an integration test runs several real pieces together; a UI test drives the built app through its screens the way a user does.
Unit: a view model, a parser, a pricing rule. Runs in the test process with @testable import, hundreds per second, so it forms the base of the suite.
Integration: real collaborators wired together: a repository over an in-memory SwiftData store, an API client against URLProtocol stubs. It catches the mismatch between pieces that each pass alone.
UI (XCUITest): launches the app in a separate process and taps through it. Slow and more brittle, so keep a handful for the critical journeys: sign-in, checkout, purchase.
The usual shape is the test pyramid (many unit tests, fewer integration tests, a few UI tests), because cost and flakiness grow toward the top. The payoff: refactoring without fear, regressions caught before QA or App Review, and design pressure, since code that is hard to test is usually too coupled.
iOSMiddle
iOS Testing
XCTest, Swift Testing, XCUITest, test doubles, async tests, snapshots, coverage, flaky tests
Questions closed
0 / 25
- 0Review
- 2Asked often
Questions
A good unit test checks one behaviour, is fast and deterministic, and reads in three blocks: Arrange the inputs and fakes, Act by calling the one method under test, Assert on the observable result.
One reason to fail: the name states the behaviour: @Test("a coupon below its minimum spend is ignored") or test_applyCoupon_belowMinimum_keepsTotal.
FIRST: Fast, Independent of other tests and their order, Repeatable (no real network, clock or randomness), Self-validating (it asserts, it does not print), Timely (written with the code).
Assert outcomes, not internals: check the returned value or the state change, not that some private helper was called, or every refactor breaks the test.
No logic in the test: no if and no loop computing the expected value; use a parameterised test instead.
@testable import MyApp lets the test target use the module's internal declarations as if they were public, so you can test without widening access in production code. It only works if the module was compiled with testing enabled.
Build setting: ENABLE_TESTABILITY = YES (the -enable-testing compiler flag) is on by default in Debug and off in Release. Testing a Release build fails with "module 'MyApp' was not compiled for testing".
What it opens: internal members become visible, and public classes and methods can be subclassed and overridden from tests. private and fileprivate stay hidden: test through the internal API instead.
It is a compiler feature, so Swift Testing uses the same @testable import; swift test in a package builds with testing enabled automatically.
XCTest creates a separate instance of your XCTestCase subclass for every test method and calls setUp before and tearDown after each one, so instance properties always start fresh.
The order
class func setUp() runs once, before the first test of the class; class func tearDown() runs once, after the last.
setUp(), setUpWithError() or setUp() async throws runs before each test. Throwing XCTSkip("no network") there skips the test cleanly.
After each test, blocks registered with addTeardownBlock run first, last-registered first, then tearDown().
What a fresh instance does not reset
static and global variables, singletons such as UserDefaults.standard, the Keychain and files on disk. A test that passes alone and fails after a sibling is reading one of these.
Observers: a NotificationCenter observer added in one test keeps firing in the next unless teardown removes it.
Memory: XCTest builds all instances up front and keeps them until the run ends, so set heavy properties (UIWindow, ModelContainer, large fixtures) to nil in tearDown.
load calls its completion later, asynchronously, so the test method returns before the closure runs; XCTest considers the test finished and passed at that moment, and the assertions inside the closure are never checked for this test.
The closure fires afterwards on some queue, or never if nothing keeps the work alive. If it fires while a later test is running, the failure can land on the wrong test.
Fix in XCTest: create an expectation, fulfil it inside the closure, then wait(for:timeout:) in a synchronous test or await fulfillment(of:timeout:) in an async one.
Better fix: give load an async version and simply await it, with no expectation at all.
Habit worth having: see every new test fail once, for example by breaking the fake. A test you have never seen red proves nothing.
#expect records a failure and lets the test continue; try #require throws and stops the test, which is right for a precondition such as unwrapping an optional. Both are macros, so a failure shows the value of every sub-expression.
#expect(cart.total == 42) fails with something like (cart.total → 25) == 42. XCTAssertTrue(cart.total == 42) only ever receives a Bool, so all it can say is "XCTAssertTrue failed".
let user = try #require(store.currentUser) replaces XCTUnwrap. Use #require whenever the following lines would be meaningless or would crash.
Errors: #expect(throws: LoadError.notFound) { try sut.load() } checks a specific error, #expect(throws: Never.self) { ... } asserts nothing is thrown, and since Swift 6.1 #expect(throws:) returns the caught error for further checks.
The trailing string is a comment shown on failure: #expect(balance > 0, "overdraft is not allowed here").
A UI test runs in its own runner process and drives the installed app from outside through the accessibility layer, like a user; it cannot call your code or read its state. It only finds elements, interacts with them and checks what is on screen.
XCUIApplication().launch() starts the app. Queries such as app.buttons["checkout.pay"] return XCUIElement proxies that are resolved against the accessibility tree when you use them.
There is no @testable import: the test sees only what the screen exposes. State gets in through launchArguments and launchEnvironment.
exists checks once; waitForExistence(timeout:) polls until the element appears. XCUITest waits for the app to go idle before each action, but knows nothing about your network calls.
Each test costs seconds (launch, animations), so cover critical journeys and leave rules to unit tests. Xcode's record button generates a first draft of the queries.
Swift Testing replaces XCTestCase subclasses and the XCTAssert family with @Test functions, the #expect and #require macros and traits, and runs tests in parallel inside one process by default. UI tests and measure performance tests have no equivalent and stay in XCTest.
Structure: any type, usually a struct, holding @Test functions; @Suite adds a name or traits. setUp becomes init(), which may be async throws; teardown is deinit on a class or actor suite, or a defer in the test. No test prefix is needed.
Parameterised tests: @Test(arguments:) turns a loop over inputs into separate cases, each reported and re-runnable on its own.
Traits: .disabled("…"), .enabled(if:), .bug(id: "FB12345"), .timeLimit(.minutes(1)), .tags(.checkout), .serialized, on one test or a whole suite.
Parallelism: XCTest spreads classes across processes; Swift Testing runs tests concurrently in one process, so shared mutable state that used to "work" now races. .serialized opts a suite out.
Async: tests are plain async functions and confirmation replaces expectations; there is no wait.
Both frameworks live in the same target, so migrate unit tests file by file, but keep each test in one framework: do not call XCTAssert inside a @Test or #expect inside an XCTestCase.
The first runs 9 cases, because two argument collections produce every combination (3 × 3); the second runs 3, because zip pairs elements up. Each case is reported separately, and a shorter list in zip silently drops the extra cases.
Combinations: @Test accepts at most two collections for a cartesian product. For more dimensions, pass one collection of tuples or small structs.
**The zip trap**: add a fourth plan to Plan and forget its price, and zip just stops at the shorter list: the new plan is never tested and nothing turns red. An array of tuples, [(Plan.free, 0), (.pro, 9), (.team, 29)], keeps each input next to its expectation.
Independent cases: unlike a for loop in one test, a failing case does not hide the others, and the cases run in parallel, so they must not share mutable state.
Re-running one case: Xcode can re-run a single argument when it can encode it, for example when Plan has a String raw value or is Codable.
All four stand in for a real dependency: a stub returns canned answers, a spy also records its calls, a mock verifies expectations set in advance, and a fake is a working lightweight implementation. Swift has nothing that creates them at run time, so you write or generate them.
The four, in practice
Stub: StubProfileLoader(result: .success(.stub)). Use it when the test is about what the code does with the answer.
Spy: a stub that records: private(set) var trackedEvents: [Event]. Use it when the call itself is the behaviour: analytics, "never charge twice".
Mock: told in advance what to expect and fails on verify(). Swift code usually writes spies instead; mocks mostly come from generators.
Fake: real behaviour, cheap: an in-memory repository, a URLProtocol stub server, an in-memory ModelContainer. A dummy fills a parameter and is never used.
Why no Mockito, and what to do instead
Mockito builds proxies at run time; Swift dispatches protocol calls through static witness tables and cannot subclass structs or final classes, so there is nothing to intercept.
Hand-written: a type conforming to the protocol. The right default for the few collaborators a screen has.
Generated: Sourcery's AutoMockable, Mockolo or a macro library such as Mockable, once protocols are big or doubles are reused widely.
Struct of closures: the dependency is a struct of closures with a live instance; a test overrides just the closure it needs.
Do not wrap every type in a protocol: extract one at real boundaries (network, clock, storage, analytics), and prefer asserting state over verifying calls.
AnalyticsSpy is a struct, so handing it to CheckoutViewModel copies it: confirm() records "purchase" in the view model's copy, while the test's spy keeps an empty events array. The test fails although the production code is correct.
Value semantics: init(analytics:) stores its own copy in a var, and mutating func track changes that copy. The test and the SUT never share storage, so nothing the SUT records is visible to the assertion.
The fix: make the spy a final class: both sides hold a reference to the same object, so events is shared. Declaring the protocol AnyObject and dropping mutating turns a struct double into a compile error, and lets the view model keep the dependency in a let.
The mirror trap: the same copy breaks a struct stub: setting stub.result = .failure(.offline) after injecting it changes nothing inside the SUT, so the test silently exercises the default path and may pass for the wrong reason.
How to catch it: doubles that record calls or are configured after injection are reference types by convention; a mutating requirement on a service protocol is itself a review smell, since real analytics or network clients are classes or actors. Watch a new test fail for the right reason before trusting it.
wait(for:timeout:) blocks the thread it runs on, and in an async test that thread belongs to the cooperative pool the awaited work needs, so the test can deadlock; in an async test use await fulfillment(of:timeout:), which suspends instead.
Xcode flags wait(for:timeout:) as unavailable from asynchronous contexts for exactly this reason: a warning in Swift 5 mode, an error in Swift 6 mode.
If the API is already async, you need no expectation: let items = try await sut.load(), then assert.
Expectations are for work that reports back later through a callback, a delegate, a notification or a Combine publisher. Bridging a one-shot callback once with withCheckedContinuation often removes them entirely.
expectedFulfillmentCount = 3 waits for three calls and assertForOverFulfill = true fails on a fourth; enforceOrder: true checks the order of several expectations.
Swift Testing's equivalent is await confirmation(expectedCount:) { confirm in ... }: the count is checked when the body returns, with no timeout to tune.
A timeout is a failure mechanism, not synchronisation: a one-second timeout that never fires is fine, a Task.sleep in the test body is a future flake.
load() almost certainly starts a Task and returns immediately, so the assertion runs before the task body has executed even once. Make the work awaitable and await it, instead of sleeping or yielding.
Marking the test @MainActor (@MainActor final class VMTests: XCTestCase, or @Test @MainActor func) lets you create and read the view model synchronously, which Swift 6 requires. It does not wait for anything.
Best fix: put the work in an async method, func run() async, and start it from the view with .task { await model.run() }. The test then just await sut.run().
If a fire-and-forget Task must stay, store its handle so the test can await sut.loadTask?.value.
Otherwise observe the effect: confirmation in Swift Testing, or an expectation fulfilled on the state change.
await Task.yield() is a coin flip: it gives up the actor once, which is enough for a task with no suspension points and not for one awaiting a fake network call.
Rule: never assert across a boundary you did not await.
Usually you do not wait at all: inject a spy for the analytics sink, drive the code to a point you can await, and assert the spy recorded nothing, which is exact and instant.
A spy only records: final class AnalyticsSpy: AnalyticsTracking with private(set) var events: [Event]. Once await sut.submit() returns, #expect(spy.events.isEmpty) is a complete proof.
When the absence really is asynchronous (a debounce, a delayed retry), use an inverted expectation: exp.isInverted = true makes fulfilment the failure and the timeout the success. It always costs the full timeout, so set it just above the code's own delay (0.4 s for a 0.3 s debounce, not 5 s).
Swift Testing: await confirmation(expectedCount: 0) { confirm in ... } fails if confirm() is called while the body runs.
XCTWaiter().wait(for:timeout:) returns .completed or .timedOut instead of failing, for a test that must branch on the outcome.
The trap: a negative test against an event that was never wired up passes forever. Pair it with a positive test proving the same spy does record the event when it should.
It checks for a memory leak: once the test method has returned, nothing but the weak capture should point at sut, so it must be nil. It is not, because the view model owns service and service.onUpdate captures self strongly, which makes a retain cycle.
[weak sut] does not keep the object alive, and teardown blocks run after the test body, when the local sut is already gone. Anything still holding it is a leak.
The fix is in production code: service.onUpdate = { [weak self] posts in self?.items = posts }.
Wrap the check in a helper such as trackForMemoryLeaks(_ instance: AnyObject) and call it from every test factory for view models, presenters and coordinators. It catches closure cycles, delegates declared strong and Combine sink closures capturing self.
Swift Testing has no addTeardownBlock: keep a weak var outside a do { } scope that owns the object, and #expect(weakSUT == nil) after the scope ends.
It only sees cycles reachable from that object during that test; the Memory Graph debugger and Instruments' Leaks find the rest.
Move the decisions out of the view into a model and unit-test that; then check pixels with snapshot tests and behaviour with XCUITest. Apple ships no API that hands a unit test the rendered SwiftUI view tree.
The model: var canBuy: Bool { !items.isEmpty && !isSubmitting } is a two-line unit test, and the view becomes .disabled(!model.canBuy). A view that only wires state into modifiers has little left worth asserting.
Snapshot tests: host the view in a UIHostingController and diff the render against a recorded image with swift-snapshot-testing. The only in-process way to see real layout, colour and Dynamic Type; the price is images to review and drift between OS versions.
ViewInspector: reflects over SwiftUI's private storage to walk body, as in try sut.inspect().find(button: "Buy").isDisabled(). Widely used, and always one Xcode release away from breaking.
XCUITest: the supported way to ask the running app: XCTAssertFalse(app.buttons["cart.buy"].isEnabled). A launch per test, so spend it on journeys.
Previews are not tests: nothing fails when a preview looks wrong.
A snapshot test renders a view and compares it with a reference image or text dump committed to the repo, catching visual regressions no assertion would. It fails on CI because the rendering depends on the simulator, OS and machine that produced the reference.
Worth it for design-system components and screens with many states: empty, error, dark mode, the largest Dynamic Type, right-to-left. Not for screens redesigned every sprint, where people start re-recording without looking.
What moves pixels: simulator model and OS version (scale, safe areas, font metrics), the host Mac (antialiasing differs between macOS versions and CPU architectures), and inputs the test forgot to fix: locale, appearance, Date(), network images.
Pin it: one simulator and OS for recording and verifying, ideally recorded on the CI image itself.
Tolerate antialiasing: .image(precision: 0.99, perceptualPrecision: 0.98) in swift-snapshot-testing forgives invisible noise and still catches a moved button.
Freeze inputs: inject a fixed date, stub image loading, set traits explicitly.
Never auto-record on CI: run with withSnapshotTesting(record: .never) so CI never writes or overwrites references and a missing one is a plain failure.
They were matching the label, which is the translated, user-visible text. Queries should match an accessibilityIdentifier, which is never localised and never spoken by VoiceOver.
app.buttons["Continue"] matches the string against the element's identifier, label and similar attributes. With no identifier set it falls back to the label, so the suite passes for the wrong reason until the language changes.
Set the identifier where the element is built: .accessibilityIdentifier("checkout.pay") in SwiftUI, button.accessibilityIdentifier = "checkout.pay" in UIKit.
Find by identifier, assert on the label: XCTAssertEqual(app.staticTexts["cart.total"].label, "12,00 €") deliberately checks the copy.
What still hides an identifier: .accessibilityElement(children: .combine) merges children into one element, an identifier put on the inner Text instead of the Button, and .accessibilityHidden(true).
When a query finds nothing, print app.debugDescription in the debugger: it dumps the accessibility tree the test can actually see.
Pass a scenario in at launch and let the app build itself from stubs: the UI test runs in a different process, so launchArguments and launchEnvironment are the main channel into the app.
They arrive as ProcessInfo.processInfo.arguments and .environment. Arguments starting with - also land in UserDefaults' argument domain, which is how -AppleLanguages "(de)" works.
Branch once, in the composition root: under -uiTesting, build the object graph with an in-memory store, an already signed-in session and a stubbed network client. Never scatter if isUITesting through feature code.
Stub the backend inside the app process, for example with a URLProtocol subclass in the session's protocolClasses. A stub installed from the test bundle never sees the app's traffic.
Wipe what survives a relaunch (UserDefaults, files in Application Support, Keychain items) and reset permissions with app.resetAuthorizationStatus(for: .location).
Wrap the whole test-only branch in #if DEBUG: a release build that a launch argument can sign in is a security bug.
It measures which code regions ran at least once during the tests, and nothing about whether anything was asserted, or which combinations of inputs ran. 87% means 13% is certainly untested, not that the rest is tested.
Xcode uses LLVM source-based coverage: the compiler adds counters per region of code, and Xcode reports the share of lines that ran at least once.
No assertions needed: a test that calls a method and asserts nothing raises coverage as much as a good one. One UI test launch drags most of the app over the line.
No combinations: both branches of an if having run does not mean the pair of conditions that triggers the bug ever ran together.
No values: the empty array, the nil, the off-by-one all live inside regions already counted as covered.
No missing code: the error case nobody handled has no region, so it cannot lower the number.
Make it useful: enable coverage only for the targets you mean in the test plan, read the red gutter rather than the percentage, gate uncovered lines in the pull request's diff instead of a global 80%, and extract numbers in CI with xcrun xccov view --report --json. For critical branches ask: if I invert this condition, does any test fail?
Test-driven development means writing a failing test first, then the minimum code that makes it pass, then refactoring with the tests green: red, green, refactor. It pays off most on logic with clear inputs and outputs, and least on layout and exploratory UI.
The three rules: write no production code except to make a failing test pass; write no more of a test than is enough to fail (not compiling counts); write no more code than is enough to pass.
Where it shines: parsers, pricing and validation rules, view-model state machines, and every bug fix: reproduce the bug as a failing test first, and it can never quietly come back.
Where it struggles: SwiftUI layout, animation, prototypes whose shape is still unknown. Write snapshot or UI tests after the design settles.
What it buys: design pressure toward injected dependencies and small types, and no code that was never seen to fail.
What it costs: a slower start, and churn if tests pin implementation details instead of behaviour.
An honest interview answer names where you use it and where you write tests after, rather than claiming 100% TDD.
Run the fast, deterministic suites on every pull request (unit and integration tests under a Smoke test plan) and move the slow ones (full UI, snapshots, performance) to merge or nightly.
On every pull request: xcodebuild test -testPlan Smoke on a pinned Xcode version and a pinned simulator, so a red build means the code changed, not the machine.
Results kept: -resultBundlePath saves the .xcresult as an artifact, coverage on the changed lines is posted to the pull request, failure screenshots included.
On merge or nightly: the Full test plan: UI, snapshot and performance suites, which are slower and more sensitive to the machine.
Flaky tests: quarantine them with a ticket instead of re-running until green, so the pull-request signal stays trusted.
Your tests stopped having the machine to themselves: XCTest distributes test classes across cloned processes and simulators, and Swift Testing also runs tests concurrently inside one process. Tests that relied on shared state or a fixed order now fail by timing.
How each runner parallelises
XCTest: classes are spread over several runner processes, each on its own simulator clone. A singleton is no longer shared between tests that used to see each other's writes, so order-dependent tests break first. -maximum-parallel-testing-workers N caps the clones, each of which costs real RAM.
Swift Testing: suites and @Test functions run as concurrent tasks in one process by default. A static var really is shared and really does race. .serialized makes one suite or test sequential.
What breaks, and the fix
Mutable globals: a static var current, a URLProtocol stub registered globally with URLProtocol.registerClass, a cached singleton. Inject per instance; that is the only fix that survives both runners.
One fixed path on disk or one defaults suite: give each test its own directory under FileManager.default.temporaryDirectory named by a UUID, and its own UserDefaults(suiteName:).
A shared backend account or port: two workers signing in as the same user.
Where it is configured
A .xctestplan holds per-configuration settings: environment, language, sanitizers, coverage, randomised execution order, repetitions and whether a target runs in parallel. Keep a Smoke plan for pull requests and a Full plan for nightly (xcodebuild -testPlan Smoke), and turn on random order there so hidden dependencies show up before parallelism exposes them.
A test that fails only in parallel was reading state it did not create.
Make it fail on demand first, then change one thing: if a longer wait fixes it, it was synchronisation; if it still fails with a 30-second timeout, the app reached the wrong state and you have a real race.
Reproduce before you theorise
xcodebuild test -only-testing:UITests/CheckoutTests/testPay -test-iterations 100 -run-tests-until-failure, or the same repetition policy in a test plan. Run it on the CI machine or a loaded simulator: the flake lives in the slow tail of the timing.
Keep evidence automatically: the .xcresult bundle as a build artifact, a screenshot attached in tearDown with lifetime = .keepAlways, and xcrun simctl io booted recordVideo when one frame is not enough.
Missing wait or real race
XCUITest waits for the app to go idle before each action, and waitForExistence(timeout:) polls on top. Neither knows about your network call, a Task you started, asyncAfter, or an image decoding into a cell. Those gaps are missing waits; fix them with an explicit condition: waitForExistence, an NSPredicate expectation on isHittable, or XCTWaiter for several events.
A real race looks different in the screenshot: a stale row, a second alert, a spinner that never stopped. That is state leaked from a previous test, a stub answering out of order or a timer-driven animation; fix the app or the fixture, not the test.
What not to do
sleep(2) is a constant fighting a variable: too short on a busy runner, wasted time everywhere else, and against a real race it only narrows the window.
Retrying until green hides the bug. Quarantine the test with a ticket and an owner instead.
Cut a seam at each of those four calls and change nothing else: the goal is an object you can construct in a test without globals, not a rewritten screen.
The steps
Pin the behaviour first: a snapshot or a coarse test through the public entry points, so the extraction is provably behaviour-preserving. Refactoring untested code without a characterisation test is just editing.
Protocol over the existing type: protocol Analytics { func track(_ event: Event) } plus extension AnalyticsManager: Analytics {}. The singleton itself does not change.
Inject with default arguments: init(analytics: Analytics = AnalyticsManager.shared). Every call site keeps compiling, the diff stays small, and the test passes a spy.
Use the seams frameworks already have: no URLSession protocol needed: build a session with a URLProtocol stub in protocolClasses. UserDefaults(suiteName:) is a throwaway store. Date() becomes an injected () -> Date or a Clock.
The trap
Making shared a var so tests can swap it. It looks cheaper, and works until tests share a process (Swift Testing runs them concurrently by default) and one test's stub leaks into another's assertion. Per-instance injection has no such window.
How far is far enough
Stop when the decisions live in a plain object with no UIKit import, constructible in one line, and the controller keeps only outlets, layout and forwarding. Do not build an app-wide DI container this week or touch screens nobody is changing; the next ticket in that file takes the next seam.