Testing
What a unit test asserts, test doubles, testing async and actor-isolated code, the Swift Testing framework, snapshots, and what coverage does not tell you.
10 questions
MiddleCodeVery commonExtract a protocol from URLSession.shared so a view controller becomes testable
Extract a protocol from URLSession.shared so a view controller becomes testable
Define a DataFetching protocol with the method the controller needs, make URLSession conform, and inject a DataFetching, defaulting to .shared. Tests pass a FakeFetcher returning canned data or an error, so no network is touched.
Common mistakes
- ✗Trying to subclass or override
URLSessioninstead of hiding it behind a protocol - ✗Reassigning the global
sharedsingleton rather than injecting the dependency - ✗Leaving the controller reaching for
URLSession.sharedinternally after the refactor
Follow-up questions
- →Why is protocol injection cleaner than subclassing
URLSession? - →How does defaulting the init parameter keep production call sites unchanged?
MiddleCodeVery commonTest an async throws function and assert it threw the right error
Test an async throws function and assert it threw the right error
Await the call in an async test and assert the concrete error case, not just that some error threw. Use await #expect(throws: LoaderError.notFound) { ... }, or an XCTest do/catch that fails on success and asserts .notFound.
Common mistakes
- ✗Asserting only that some error was thrown, not which concrete case
- ✗Blocking with a semaphore instead of writing an async test
- ✗Forgetting to fail the test when the call unexpectedly returns a value
Follow-up questions
- →How does
#expect(throws:)compare the thrown error to the expected case? - →Why must the test fail if the call returns instead of throwing?
JuniorTheoryCommonHow do unit, integration, and UI tests differ in scope and cost, and how many of each do you want?
How do unit, integration, and UI tests differ in scope and cost, and how many of each do you want?
A unit test checks one component in isolation — fast, cheap. An integration test checks pieces working together, e.g. a repository and its store — slower. A UI test drives the running app — slowest, most brittle. Want many unit, fewer integration, few UI.
Common mistakes
- ✗Thinking UI tests are cheap enough to form the bulk of the suite
- ✗Assuming an integration test is just a unit test with a different framework
- ✗Believing more UI tests always means better coverage
Follow-up questions
- →Why are UI tests the most brittle of the three?
- →What belongs in an integration test that a unit test cannot cover?
JuniorTheoryCommonWhat does a unit test buy you, and what is the Arrange-Act-Assert structure?
What does a unit test buy you, and what is the Arrange-Act-Assert structure?
A unit test checks one small piece of logic in isolation — a fast, repeatable guard against regressions. Arrange-Act-Assert structures it: arrange the inputs, act by calling the code, then assert the result matches your expectation.
Common mistakes
- ✗Believing a unit test must touch real collaborators like the network or disk
- ✗Confusing a unit test with an end-to-end UI test that drives the whole app
- ✗Treating Arrange-Act-Assert as optional ceremony rather than a readability structure
Follow-up questions
- →Why should a unit test avoid real network or disk access?
- →What makes one clear assertion per test easier to diagnose?
MiddleDesignCommonYour suite reports 85% line coverage yet bugs keep shipping. Explain what line coverage actually measures, why a high number can still miss real defects, and what you would add or change to catch more. Consider that coverage counts executed lines rather than verified behaviour, that a test can run a line without asserting anything meaningful about it, and that whole categories of defects — wrong branch conditions, unhandled edge cases, concurrency races, and integration seams — may never be exercised. Say what coverage is genuinely useful for, what target you would set, and which extra signals or test kinds you would introduce to raise real confidence.
Your suite reports 85% line coverage yet bugs keep shipping. Explain what line coverage actually measures, why a high number can still miss real defects, and what you would add or change to catch more. Consider that coverage counts executed lines rather than verified behaviour, that a test can run a line without asserting anything meaningful about it, and that whole categories of defects — wrong branch conditions, unhandled edge cases, concurrency races, and integration seams — may never be exercised. Say what coverage is genuinely useful for, what target you would set, and which extra signals or test kinds you would introduce to raise real confidence.
Line coverage counts which lines ran, not whether an assertion checked them, so a line can run yet verify nothing. A high number misses wrong branches, edge cases, and races. It is a gap-finder, not a target — add branch coverage, mutation testing, and edge-case tests.
Common mistakes
- ✗Treating a coverage percentage as a measure of behaviour actually verified
- ✗Believing 100% line coverage means the code is fully tested
- ✗Adding lines-executed tests with weak or absent assertions to lift the number
Follow-up questions
- →How does branch coverage catch defects that line coverage misses?
- →What does mutation testing reveal that coverage numbers cannot?
MiddleTheoryCommonTest doubles — how do a dummy, stub, spy, mock, and fake differ, and which do you need most?
Test doubles — how do a dummy, stub, spy, mock, and fake differ, and which do you need most?
A dummy fills a slot and is never used. A stub returns canned values. A spy is a stub that also records its calls. A mock has preset expectations and fails if unmet. A fake is a lightweight working implementation, e.g. an in-memory store. You reach for stubs (and fakes) most.
Common mistakes
- ✗Using "mock" as a catch-all label for every kind of test double
- ✗Confusing a spy, which records calls, with a mock, which asserts expectations
- ✗Reaching for a heavyweight mock when a simple stub would do
Follow-up questions
- →When does a mock's built-in expectation make a test brittle?
- →Why is an in-memory fake often better than a stub for a repository?
MiddleCodeOccasionalTest time-dependent code (a debounce) without Thread.sleep by injecting a clock
Test time-dependent code (a debounce) without Thread.sleep by injecting a clock
Make time a dependency, not a global call. Inject an abstraction — a Scheduler protocol or Swift's Clock — and schedule through it, defaulting to the real one in production. The test passes a virtual scheduler and advances it, firing the action without Thread.sleep.
Common mistakes
- ✗Waiting real wall-clock time with
Thread.sleepinstead of virtual time - ✗Leaning on an expectation timeout to paper over nondeterministic timing
- ✗Calling
DispatchQueuetimers directly instead of through an injected scheduler
Follow-up questions
- →How does a virtual scheduler advance time without actually waiting?
- →Why does zeroing the delay change the behaviour you meant to test?
MiddleTheoryOccasionalHow does Apple's @Test-based Swift Testing framework differ from XCTest, and can both coexist in one target?
How does Apple's @Test-based Swift Testing framework differ from XCTest, and can both coexist in one target?
Swift Testing uses @Test with #expect/#require macros, parameterized tests, and traits, parallel by default; XCTest uses XCTestCase and XCTAssert. #require aborts, #expect continues. Both coexist in one target; UI tests stay XCUITest.
Common mistakes
- ✗Assuming Swift Testing replaces and removes XCTest rather than coexisting with it
- ✗Thinking
#expectaborts the test the way#requiredoes - ✗Forgetting that UI tests still rely on XCUITest, not Swift Testing
Follow-up questions
- →When would you reach for
#requireinstead of#expect? - →How do parameterized
@Testcases cut duplicated test code?
MiddleDesignOccasionalYou lead testing for a feed screen and must decide what to unit-test, what to snapshot-test, and what to cover with UI tests without inverting the test pyramid. The feed has a view model that formats timestamps and paginates, custom cells with several layout states (loading, error, loaded, empty), and a navigation flow from a cell tap to a detail screen. State which layer owns each concern, why snapshot tests fit the cell layouts, why end-to-end UI tests should stay few, and what signals tell you the pyramid is inverting toward too many slow, brittle tests.
You lead testing for a feed screen and must decide what to unit-test, what to snapshot-test, and what to cover with UI tests without inverting the test pyramid. The feed has a view model that formats timestamps and paginates, custom cells with several layout states (loading, error, loaded, empty), and a navigation flow from a cell tap to a detail screen. State which layer owns each concern, why snapshot tests fit the cell layouts, why end-to-end UI tests should stay few, and what signals tell you the pyramid is inverting toward too many slow, brittle tests.
Unit-test the view model's pure logic — formatting, pagination, state transitions. Snapshot-test the cell's layout states for cheap visual-regression cover. Reserve UI tests for a few flows like cell-tap-to-detail. A rising count of slow, flaky UI tests signals the pyramid inverting.
Common mistakes
- ✗Pushing end-to-end UI tests to cover logic that a unit test owns
- ✗Snapshot-testing business logic instead of the visual layout states
- ✗Reading a high UI-test count as thoroughness rather than a warning sign
Follow-up questions
- →Which cell states are worth a snapshot, and which just add noise?
- →What makes UI tests slower and flakier than unit tests?
MiddleTheoryOccasionalCompletion-handler tests are flaky — what does XCTest's XCTestExpectation wait on, and how do you de-flake them?
Completion-handler tests are flaky — what does XCTest's XCTestExpectation wait on, and how do you de-flake them?
XCTestExpectation is fulfilled in the callback; wait(for:timeout:) blocks until it fulfills or expires. Flakiness is from a short timeout, a missing or double fulfill(), or a race. Fix with a realistic timeout, one fulfill() per path, and an injected scheduler.
Common mistakes
- ✗Fixing flakiness by inflating the timeout instead of removing the race
- ✗Forgetting to call
fulfill(), or calling it more than once - ✗Using
Thread.sleepin place of an expectation to wait for a callback
Follow-up questions
- →Why does inflating the timeout hide rather than fix a race?
- →How does an injected scheduler make asynchronous timing deterministic?