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
| Concept | Context | Why it matters | Common confusion |
|---|---|---|---|
| Red | Write a failing test | Forces interface design first | Skipping → write tests after, lose the design pressure |
| Green | Minimum code to pass | Resist the urge to over-implement | Writing the whole feature at once |
| Refactor | Clean up with green tests | Safety net for cleanup | Refactoring while still red |
| Triangulation | Add tests to force generalization | Avoids hardcoded return 42 | Writing the general code without the second test |
| Test list | Brainstorm test cases up front | Stay focused, avoid yak-shaving | Trying 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:
- Brainstorm a short test list (5–10 cases).
- Pick the simplest case. Write the test. Run it. See it fail.
- Write the minimum code (even
return 0) to make it pass. - Add the next test that forces you to generalize.
- 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
- “TDD slows you down.” First week, yes. After the muscle memory forms, total time-to-shippable is faster because you spend less time debugging.
- “TDD doesn’t work for UI code.” Correct — don’t TDD your
Viewbodies. TDD theViewModel/Reducerinstead, which is where the logic lives. - “You need to TDD every line.” No. TDD the logic. Skip TDD for thin glue, framework calls, and obvious mappings.
- “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.
- “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.