8.3 — TDD in Swift

Opening scenario

You join a team that swears by TDD. Your first ticket is a PriceCalculator that supports tax, discounts, and bulk pricing. Your tech lead says “write the failing test first.” You stare at the cursor. You don’t know what API the calculator should have yet. How do you write a test for code that doesn’t exist?

That hesitation is the point of TDD. It forces you to design the interface before the implementation, from the consumer’s perspective.

Context taxonomy

ConceptContextWhy it mattersCommon confusion
RedWrite a failing testForces interface design firstSkipping → write tests after, lose the design pressure
GreenMinimum code to passResist the urge to over-implementWriting the whole feature at once
RefactorClean up with green testsSafety net for cleanupRefactoring while still red
TriangulationAdd tests to force generalizationAvoids hardcoded return 42Writing the general code without the second test
Test listBrainstorm test cases up frontStay focused, avoid yak-shavingTrying to write all tests at once

Concept → Why → How → Code

Concept: Test-Driven Development is a discipline, not a religion. Write a failing test, write the smallest code to make it pass, refactor with the test as a safety net. Repeat in cycles of seconds to minutes.

Why: TDD pressures you to write small, testable units. Tightly-coupled designs are physically painful to test, so TDD organically pushes you toward dependency injection, single-responsibility, and clean boundaries. The tests themselves are a side effect — the real product is better architecture.

How:

  1. Brainstorm a short test list (5–10 cases).
  2. Pick the simplest case. Write the test. Run it. See it fail.
  3. Write the minimum code (even return 0) to make it pass.
  4. Add the next test that forces you to generalize.
  5. Refactor with confidence.

Code — TDD walkthrough of a PriceCalculator:

Red 1 — simplest case

func test_total_emptyCart_returnsZero() {
    let calc = PriceCalculator()
    XCTAssertEqual(calc.total(items: []), 0)
}

This won’t compile — PriceCalculator doesn’t exist. That’s red.

Green 1 — minimum to compile + pass

struct PriceCalculator {
    func total(items: [Int]) -> Int { 0 }
}

Hard-coding 0 is correct. Don’t generalize until a second test forces you to.

Red 2 — force generalization

func test_total_singleItem_returnsItemPrice() {
    let calc = PriceCalculator()
    XCTAssertEqual(calc.total(items: [100]), 100)
}

Green 2

struct PriceCalculator {
    func total(items: [Int]) -> Int { items.reduce(0, +) }
}

Red 3 — add discount behavior

func test_total_withDiscount_appliesPercentage() {
    let calc = PriceCalculator()
    XCTAssertEqual(calc.total(items: [100], discountPct: 10), 90)
}

Green 3

struct PriceCalculator {
    func total(items: [Int], discountPct: Int = 0) -> Int {
        let raw = items.reduce(0, +)
        return raw - (raw * discountPct / 100)
    }
}

Refactor

Three tests pass. Now make the API nicer:

struct PriceCalculator {
    var discountPct: Int = 0

    func total(items: [Int]) -> Int {
        let raw = items.reduce(0, +)
        return raw - (raw * discountPct / 100)
    }
}

Update the tests to use the new shape, run them, all green. Move to the next test on the list.

TDD for ViewModels (the iOS sweet spot)

@MainActor
final class LoginViewModelTests: XCTestCase {
    func test_initialState_buttonDisabled() {
        let vm = LoginViewModel(api: MockAuthAPI())
        XCTAssertFalse(vm.canSubmit)
    }

    func test_validEmailAndPassword_enablesButton() {
        let vm = LoginViewModel(api: MockAuthAPI())
        vm.email = "a@b.com"
        vm.password = "longpass1"
        XCTAssertTrue(vm.canSubmit)
    }

    func test_login_success_setsAuthenticated() async {
        let vm = LoginViewModel(api: MockAuthAPI(stub: .success))
        vm.email = "a@b.com"; vm.password = "longpass1"
        await vm.submit()
        XCTAssertTrue(vm.isAuthenticated)
    }
}

Notice how testing forced the ViewModel to accept its API as a dependency (init(api:)), which is exactly the right design even without TDD.

In the wild

  • Kent Beck invented TDD, wrote “Test-Driven Development by Example” (2003) — required reading.
  • Uncle Bob’s “Three Rules of TDD” — controversial purist take: never write a line of production code without a failing test. Most working engineers treat this as aspirational, not literal.
  • GitHub Copilot has made the green step trivially fast — but you still need to write the failing test yourself. Don’t let the AI design your API for you.

Common misconceptions

  1. “TDD slows you down.” First week, yes. After the muscle memory forms, total time-to-shippable is faster because you spend less time debugging.
  2. “TDD doesn’t work for UI code.” Correct — don’t TDD your View bodies. TDD the ViewModel/Reducer instead, which is where the logic lives.
  3. “You need to TDD every line.” No. TDD the logic. Skip TDD for thin glue, framework calls, and obvious mappings.
  4. “Tests written first are higher quality.” They’re higher quality at driving design. The assertion strength is the same as tests written after — what matters is whether you’re testing observable behavior.
  5. “TDD = 100% coverage.” TDD gives you ~90% on the code you wrote with it. The remaining 10% is glue, error paths you didn’t drive, and integration points.

Seasoned engineer’s take

TDD is most valuable when you’re uncertain about the API. For familiar code (one more endpoint added to your network layer), tests-after is fine and faster. For unfamiliar problems (a brand new domain object, a tricky algorithm), TDD pays for itself in three commits because the design pressure stops you from coding into a corner.

[!TIP] Keep the cycle small. If your red phase takes more than 90 seconds, the test you wrote is too big — split it into smaller cases.

[!WARNING] Don’t refactor while the bar is red. Refactoring requires green tests as a safety net; if you’re red, you’re flying blind on two fronts.

Interview corner

Junior — “What does red-green-refactor mean?” The TDD cycle. Red: write a test that fails. Green: write the minimum code to make it pass. Refactor: clean up with the passing test protecting you. Repeat in short cycles, usually under 10 minutes.

Mid — “Show me TDD on email validation.” Start with test_empty_isInvalid returning false. Write func isValid(_ s: String) -> Bool { false }. Add test_simpleAt_isValid for "a@b" returning true. Implement s.contains("@"). Add edge cases (no domain, multiple @, whitespace) one at a time, generalizing the function each step. Done in 5 tests, 5 minutes.

Senior — “When does TDD hurt productivity?” TDD struggles when the API surface is dictated by an external system you don’t understand yet — fighting Core Animation, integrating an opaque third-party SDK, or exploring a new framework. In those cases, write a spike (untested prototype), throw it away, then TDD the cleaned-up version once you know the shape. TDD also pays poorly for thin orchestration code where there’s no logic to drive — pure pass-throughs to other services don’t benefit from test-first. I’d also consider that TDD doesn’t replace exploratory testing or property-based testing; those find different bugs.

Red flag — “I don’t write tests because TDD is too slow.” This person hasn’t tried it long enough to feel the payoff.

Lab preview

Lab 8.1 puts you through a full TDD cycle on a NetworkClient. You’ll write the tests with no implementation, watch them all fail, then implement piece by piece.


Next: Mocking & Dependency Injection