
TDD: No Test, No Code
Writing test BEFORE code. Red -> Green -> Refactor. The Double-Entry Bookkeeping of Programming.

Writing test BEFORE code. Red -> Green -> Refactor. The Double-Entry Bookkeeping of Programming.
Why does my server crash? OS's desperate struggle to manage limited memory. War against Fragmentation.

Two ways to escape a maze. Spread out wide (BFS) or dig deep (DFS)? Who finds the shortest path?

Fast by name. Partitioning around a Pivot. Why is it the standard library choice despite O(N²) worst case?

Establishing TCP connection is expensive. Reuse it for multiple requests.

In the early days of my startup, I was obsessed with speed. "Ship now, fix later." I built a user authentication system in three hours. Felt productive. Next morning, Slack exploded with 50 messages: "Can't log in." Turns out I treated user@Example.com and user@example.com as different accounts. Email case sensitivity bug.
I patched it. Deployed. Now OAuth login broke. The lowercase conversion logic interfered with social login flow. Fixing one bug created another. Whack-a-mole debugging. The three-hour feature took two days to stabilize.
My CTO mentor said: "You're building a skyscraper without laying foundation. Learn TDD."
TDD (Test Driven Development) completely reverses how you write code.
Normal Development:
calculator.ts.add(a, b) function.console.log(add(2, 3)) to verify it returns 5.TDD:
add doesn't exist yet).This is the Red-Green-Refactor cycle. At first, I thought "Why write code twice?" Then I remembered double-entry bookkeeping. Accountants record every transaction twice—once as debit, once as credit. If you only record once, mistakes slip through. If the two sides don't match, you catch errors immediately.
TDD works the same way. You write Intent (Test) and Implementation (Code) separately. They verify each other. If they don't match, there's a bug.
Let me show you TDD with a real example. We'll build an add function using Jest.
// calculator.test.ts
import { add } from './calculator';
describe('Calculator', () => {
it('should add two positive numbers', () => {
expect(add(2, 3)).toBe(5);
});
});
This fails. calculator.ts doesn't exist. add doesn't exist. Seeing the error is the goal.
Cannot find module './calculator'
// calculator.ts
export function add(a: number, b: number): number {
return 5; // Hardcoded!
}
You might think "This is insane!" Correct. But this is the essence of TDD. Think only about the current test. The test passes:
✓ should add two positive numbers (2 ms)
it('should add two negative numbers', () => {
expect(add(-1, -4)).toBe(-5);
});
Now hardcoding won't work. We need the real implementation.
export function add(a: number, b: number): number {
return a + b;
}
All tests pass.
The code is simple now, so there's nothing to refactor. But in real projects, this is where you rename variables, eliminate duplication, and improve structure. Tests make refactoring safe. If you break something, tests fail immediately.
In real projects, you often call external APIs. Problem: real API calls make tests slow, expensive, and flaky. What if the network drops? What if the API is down for maintenance? Tests break.
That's where Test Doubles come in. Like stunt doubles in movies replace actors for dangerous scenes, test doubles replace real objects in tests.
Types of test doubles:
Here's a real example. Testing a function that fetches user data:
// userService.ts
export async function getUserName(userId: string): Promise<string> {
const response = await fetch(`https://api.example.com/users/${userId}`);
const data = await response.json();
return data.name;
}
We can't hit the real API in tests. Let's use a Mock.
// userService.test.ts
import { getUserName } from './userService';
// Replace fetch with a Mock
global.fetch = jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve({ name: 'Alice' }),
})
) as jest.Mock;
describe('getUserName', () => {
it('should fetch user name from API', async () => {
const name = await getUserName('123');
expect(name).toBe('Alice');
expect(fetch).toHaveBeenCalledWith('https://api.example.com/users/123');
expect(fetch).toHaveBeenCalledTimes(1); // Mock power: verify call count
});
it('should handle different user IDs', async () => {
(fetch as jest.Mock).mockResolvedValueOnce({
json: () => Promise.resolve({ name: 'Bob' }),
});
const name = await getUserName('456');
expect(name).toBe('Bob');
});
});
Now tests run without network. A 0.5-second test becomes 5 milliseconds.
Tests fall into three categories:
The traditional Testing Pyramid says:
Why? Fast tests mean fast development.
But Kent C. Dodds proposed the Testing Trophy. In modern web development, focus on Integration Tests. Unit tests depend too much on implementation details. Every refactor breaks them. Integration tests verify real user scenarios like "User clicks login button and reaches dashboard." More valuable.
I use both. Complex business logic gets Unit Tests. User flows get Integration Tests.
The TDD community splits into two camps.
Detroit School (Classicist):
London School (Mockist):
I prefer Detroit style. Too many mocks lead to "mocks of mocks," and you miss real integration bugs. But I do mock expensive operations like API calls or payment processing.
Doing TDD, test names often get weird:
it('test_add_function', () => { ... });
You forget what this tests later. BDD (Behavior Driven Development) solves this by writing tests like natural language.
describe('Calculator', () => {
describe('when adding two numbers', () => {
it('should return the sum', () => {
expect(add(2, 3)).toBe(5);
});
it('should handle negative numbers', () => {
expect(add(-2, -3)).toBe(-5);
});
});
});
Gherkin syntax makes it even clearer:
Feature: User Login
Scenario: User logs in with valid credentials
Given the user is on the login page
When they enter valid email and password
Then they should see the dashboard
Now non-developers (PMs, designers) can read tests. Tools like Cucumber turn this syntax into executable code.
Many companies target "80% code coverage." Coverage measures percentage of code executed by tests. 100% coverage means every line was tested.
But this becomes a numbers game.
function divide(a: number, b: number): number {
return a / b; // 100% coverage
}
it('should divide numbers', () => {
expect(divide(10, 2)).toBe(5);
});
This test doesn't check divide(10, 0). Dividing by zero returns Infinity. Coverage is 100%, but there's a bug.
I treat coverage as a directional indicator. Below 30%? "Need more tests." Above 90%? "Good enough." Chasing 100% creates meaningless tests.
TDD shines when:
TDD is overhead when:
I skip TDD in early product stages. Need to ship fast and get user feedback. But once the product stabilizes and the team grows, development is impossible without TDD. When 10 people modify code simultaneously without tests, bugs explode daily.
Testing React components by checking "Does button exist?" and "Is text correct?" gets tedious. Snapshot Testing automates this.
import { render } from '@testing-library/react';
import { Button } from './Button';
it('should match snapshot', () => {
const { container } = render(<Button>Click me</Button>);
expect(container).toMatchSnapshot();
});
First run saves an HTML snapshot. Future runs compare current output to the snapshot. If different, test fails.
Pros: Catches UI changes quickly. Cons: Fails on unintended changes (single space difference). Don't blindly trust snapshots.
I use snapshot tests only for regression prevention. Critical UI logic gets separate tests.
Starting TDD, I struggled with tool choice.
Jest: React ecosystem standard. Zero config. Powerful mocking. Vitest: Vite-based. 10x faster than Jest. Native ES Module support. Mocha: Old tool. Flexible but complex setup. pytest (Python): Python standard. Excellent fixture system.
For TypeScript projects, I use Vitest. Speed is overwhelming. For Python, obviously pytest.
Learning TDD, I thought "Isn't this wasting time?" Could build another feature instead of writing tests. But six months later, code without tests became legacy. A minefield nobody dared touch.
TDD isn't slow, it's fast. Looks slow now, but saves days of debugging later. No more deploying at 11 PM and rolling back at 3 AM because the server exploded.
Lesson learned: Don't deploy without tests. Tests aren't insurance—they're investment that speeds up development. Keep running the Red-Green-Refactor cycle, and you'll find yourself writing bug-free code without realizing it.