TL;DR
TDD made me faster, not slower. The key: test behavior (not implementation), inject dependencies for determinism, and write tests first for complex logic. Here’s what that looked like across 5 shipped projects.
Introduction
I was skeptical of TDD. Write tests before the code exists? Sounds like extra work.
Then I tried it on Poker’s hand evaluation logic. 50+ edge cases. Kickers, ties, split pots. The kind of complexity where bugs hide.
Tests first changed everything.
The RED-GREEN-REFACTOR Cycle
The Process
1. RED - Write a failing test
2. GREEN - Write minimum code to pass
3. REFACTOR - Clean up without changing behavior
In Practice (Poker)
# Commit 1: RED
test: add failing test for flush detection
# Commit 2: GREEN
feat: implement flush detection
# Commit 3: REFACTOR
refactor: extract card suit grouping helperEach commit is atomic. Each can be reverted independently. The git history tells a story.
When TDD Helps Most
Complex Logic
Hand evaluation has 50+ test cases:
func testFlushBeatsStrait() {
let flush = makeHand(.flush, highCard: .king)
let straight = makeHand(.straight, highCard: .ace)
XCTAssertTrue(flush > straight)
}
func testFlushWithHigherKickerWins() {
let flushA = makeHand(.flush, kickers: [.ace, .king, .queen, .jack, .nine])
let flushB = makeHand(.flush, kickers: [.ace, .king, .queen, .jack, .eight])
XCTAssertTrue(flushA > flushB)
}Without tests, this logic would have shipped with bugs. Guaranteed.
Edge Cases
func testSplitPotWithIdenticalHands() {
let hand1 = evaluate([ace, king, queen, jack, ten].map { hearts($0) })
let hand2 = evaluate([ace, king, queen, jack, ten].map { spades($0) })
XCTAssertEqual(hand1, hand2) // Royal flushes are equal regardless of suit
}I didn’t think of this case until I wrote the tests.
When TDD Helps Less
UI Code
Don’t TDD SwiftUI views. Instead:
- Test the ViewModel
- Use previews for visual verification
- Integration test critical flows
Simple CRUD
If you’re just passing data through, skip the ceremony.
Deterministic Testing
The Problem
// This test is flaky
it('should select a random response', () => {
const engine = createEngine(persona);
const response = engine.process('hello');
expect(responses).toContain(response); // Always passes, proves nothing
});The Solution
Inject the randomness:
function makeRng(values: number[]): () => number {
let i = 0;
return () => values[i++ % values.length];
}
it('should select first response when rng returns 0', () => {
const engine = createEngine(persona, { rng: makeRng([0]) });
expect(engine.process('hello')).toBe(responses[0]);
});
it('should select last response when rng returns 0.99', () => {
const engine = createEngine(persona, { rng: makeRng([0.99]) });
expect(engine.process('hello')).toBe(responses[responses.length - 1]);
});Now the tests are deterministic and actually verify behavior.
Test Helpers
Swift
// Factory for test hands
func makeHand(_ rank: HandRank, highCard: Rank = .ace) -> EvaluatedHand {
// Returns a hand with the specified rank
}
// Factory for specific cards
func hearts(_ rank: Rank) -> Card {
Card(rank: rank, suit: .hearts)
}TypeScript
// Factory for test personas
function makePersona(rules: Rule[]): Persona {
return {
name: 'test',
rules,
greeting: 'Hello',
goodbye: 'Goodbye',
};
}Helpers make tests readable and reduce duplication.
Test Organization
File Structure
# Swift
Tests/
└── ProjectTests/
├── HandEvaluatorTests.swift
├── BettingRoundTests.swift
└── ShowdownTests.swift
# TypeScript
tests/
├── engine.test.ts
├── rules.test.ts
└── helpers/
└── factories.ts
Within Files
final class HandEvaluatorTests: XCTestCase {
// MARK: - Hand Rank Tests
func testHandRankOrdering() { ... }
// MARK: - Flush Tests
func testFlushDetection() { ... }
// MARK: - Edge Cases
func testEmptyHand() { ... }
}Group related tests with MARK sections.
What to Test
Test This
- Business logic - Hand evaluation, pattern matching, state transitions
- Edge cases - Empty inputs, boundaries, invalid states
- Regressions - Bugs that were found and fixed
Skip This
- Framework behavior - SwiftUI rendering, React lifecycle
- Simple getters - If it’s trivial, don’t test it
- Third-party code - Trust your dependencies
The Velocity Impact
Before TDD
- Write code
- Manually test
- Find bug
- Debug
- Fix
- Manually test again
- Find another bug
- Repeat
After TDD
- Write failing test
- Write code
- Test passes
- Done
The upfront investment pays off immediately for complex logic.
Practical Advice
Start Small
Don’t try to TDD everything. Start with:
- Complex calculations
- State machines
- Input validation
Write Tests You Trust
If you don’t trust the test, you’ll ignore it. Make tests:
- Fast (run in milliseconds)
- Isolated (no shared state)
- Readable (intent is clear)
Don’t Over-Mock
// Too much mocking
it('should call service', () => {
const mockService = jest.fn();
component.doThing(mockService);
expect(mockService).toHaveBeenCalled(); // Tests nothing useful
});
// Better: test actual behavior
it('should return transformed data', () => {
const result = transform(inputData);
expect(result).toEqual(expectedOutput);
});Tools
Swift
- XCTest - Built-in, fast, reliable
- Code Coverage - Xcode’s built-in coverage
TypeScript
- Vitest - Fast, ESM-native, great DX
- Testing Library - For React components
Coming Up
Final post: The GAS framework—the planning system that enabled 51 plans at 1.6 hours average.
This post is part of the “Building Apps in Public” series. See the overview for context.