I Shipped Bugs While "Moving Fast"
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."
Reversing the Order Changed Everything
TDD (Test Driven Development) completely reverses how you write code.
Normal Development:
- Create
calculator.ts. - Write
add(a, b)function. - Run
console.log(add(2, 3))to verify it returns 5.
TDD:
- Write the test first (even though
adddoesn't exist yet). - Test fails (Red 🔴).
- Write just enough code to pass the test (Green 🟢).
- Clean up the code (Refactor ♻️).
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.
TDD in Action: Building a Calculator
Let me show you TDD with a real example. We'll build an add function using Jest.
Step 1: Red 🔴 (Write a Failing Test)
// 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'
Step 2: Green 🟢 (Write Minimum Code to Pass)
// 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)
Step 3: Add Another Test to Break the Hardcoding
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.
Step 4: Refactor ♻️ (Clean Up)
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.
Testing API Calls: The World of Test Doubles
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:
- Mock: Verifies calls and arguments. "Was this function called exactly once?"
- Stub: Returns predefined values. "If API succeeds, return this JSON."
- Spy: Calls the real function but records invocation data.
- Fake: Simplified implementation (e.g., in-memory array instead of real database).
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.
Test Layers: Pyramid vs Trophy
Tests fall into three categories:
- Unit Test: Tests a single function or class. Fast and cheap.
- Integration Test: Tests multiple modules working together. Includes DB, API integration.
- E2E Test: Tests entire user scenarios in a real browser. Slow and expensive.
The traditional Testing Pyramid says:
- Write mostly Unit Tests (70%).
- Some Integration Tests (20%).
- Few E2E Tests (10%).
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.
Two Schools of TDD: Detroit vs London
The TDD community splits into two camps.
Detroit School (Classicist):
- Use real objects as much as possible.
- Mock only external dependencies (API, DB).
- "Let's see if real code works together!"
London School (Mockist):
- Mock all dependencies.
- Test each class in complete isolation.
- "Test only one unit at a time!"
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.
BDD: Writing Tests Like English Sentences
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.
Code Coverage Trap: 100% Means Nothing
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.
When TDD Helps, When It Doesn't
TDD shines when:
- Complex business logic (discount calculations, tax rules).
- Frequently changing requirements (startup pivots).
- Refactoring legacy code (tests are safety nets).
TDD is overhead when:
- Prototyping UI (design changes 10 times a day).
- Exploratory development (don't know what you're building yet).
- Simple CRUD (tests are longer than code).
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.
Snapshot Testing: UI Testing Shortcut
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.
Testing Frameworks: Jest, Vitest, Mocha, pytest
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.
Closing: TDD is Investment, Not Insurance
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.