Development
Test-Driven Development
Drives development with tests. Use when implementing any logic, fixing any bug, or changing any behavior. Prove that code works.
Write a failing test before writing the code that makes it pass. Tests are proof — "seems right" is not done.
When to Use
- Implementing any new logic or behavior
- Fixing any bug (the Prove-It Pattern)
- Modifying existing functionality
- Adding edge case handling
When NOT to use: Pure configuration changes, documentation updates, or static content changes.
The TDD Cycle
RED GREEN REFACTOR
Write a test Write minimal code Clean up the
that fails ──→ to make it pass ──→ implementation
Step 1: RED — Write a Failing Test
// RED: This test fails because createTask doesn't exist yet
describe('TaskService', () => {
it('creates a task with title and default status', async () => {
const task = await taskService.createTask({ title: 'Buy groceries' });
expect(task.id).toBeDefined();
expect(task.title).toBe('Buy groceries');
expect(task.status).toBe('pending');
expect(task.createdAt).toBeInstanceOf(Date);
});
});
Step 2: GREEN — Make It Pass
// GREEN: Minimal implementation
export async function createTask(input: { title: string }): Promise<Task> {
const task = {
id: generateId(),
title: input.title,
status: 'pending' as const,
createdAt: new Date(),
};
await db.tasks.insert(task);
return task;
}
Step 3: REFACTOR — Clean Up
With tests green, improve the code without changing behavior:
- Extract shared logic
- Improve naming
- Remove duplication
- Optimize if necessary
The Prove-It Pattern (Bug Fixes)
When a bug is reported, do not start by trying to fix it. Start by writing a test that reproduces it.
// Step 1: Write the reproduction test (it should FAIL)
it('sets completedAt when task is completed', async () => {
const task = await taskService.createTask({ title: 'Test' });
const completed = await taskService.completeTask(task.id);
expect(completed.status).toBe('completed');
expect(completed.completedAt).toBeInstanceOf(Date); // This fails → bug confirmed
});
// Step 2: Fix the bug
export async function completeTask(id: string): Promise<Task> {
return db.tasks.update(id, {
status: 'completed',
completedAt: new Date(), // This was missing
});
}
// Step 3: Test passes → bug fixed, regression guarded
The Test Pyramid
Invest testing effort according to the pyramid:
╱╲
╱ ╲ E2E Tests (~5%)
╱ ╲ Full user flows, real browser
╱──────╲
╱ ╲ Integration Tests (~15%)
╱ ╲ Component interactions, API boundaries
╱────────────╲
╱ ╲ Unit Tests (~80%)
╱ ╲ Pure logic, isolated, milliseconds each
╱──────────────────╲
Test Sizes
| Size | Constraints | Speed | Example |
|---|---|---|---|
| Small | Single process, no I/O, no network | Milliseconds | Pure function tests |
| Medium | Multi-process OK, localhost only | Seconds | API tests with test DB |
| Large | Multi-machine OK, external services | Minutes | E2E tests |
Writing Good Tests
Test State, Not Interactions
// Good: Tests what the function does (state-based)
it('returns tasks sorted by creation date', async () => {
const tasks = await listTasks({ sortBy: 'createdAt', sortOrder: 'desc' });
expect(tasks[0].createdAt.getTime())
.toBeGreaterThan(tasks[1].createdAt.getTime());
});
// Bad: Tests how the function works internally
it('calls db.query with ORDER BY created_at DESC', async () => {
await listTasks({ sortBy: 'createdAt', sortOrder: 'desc' });
expect(db.query).toHaveBeenCalledWith(
expect.stringContaining('ORDER BY created_at DESC')
);
});
DAMP Over DRY in Tests
In tests, DAMP (Descriptive And Meaningful Phrases) is better than DRY. Each test should tell a complete story.
// DAMP: Each test is self-contained and readable
it('rejects tasks with empty titles', () => {
const input = { title: '', assignee: 'user-1' };
expect(() => createTask(input)).toThrow('Title is required');
});
it('trims whitespace from titles', () => {
const input = { title: ' Buy groceries ', assignee: 'user-1' };
const task = createTask(input);
expect(task.title).toBe('Buy groceries');
});
Prefer Real Implementations Over Mocks
Preference order (most to least preferred):
1. Real implementation → Highest confidence
2. Fake → In-memory version
3. Stub → Returns canned data
4. Mock (interaction) → Verifies method calls — use sparingly
Use Arrange-Act-Assert Pattern
it('marks overdue tasks when deadline has passed', () => {
// Arrange: Set up the test scenario
const task = createTask({
title: 'Test',
deadline: new Date('2025-01-01'),
});
// Act: Perform the action being tested
const result = checkOverdue(task, new Date('2025-01-02'));
// Assert: Verify the outcome
expect(result.isOverdue).toBe(true);
});
Test Anti-Patterns to Avoid
| Anti-Pattern | Problem | Fix |
|---|---|---|
| Testing implementation details | Tests break when refactoring | Test inputs and outputs |
| Flaky tests | Erode trust in the suite | Use deterministic assertions |
| Testing framework code | Wastes time | Only test YOUR code |
| Snapshot abuse | Large snapshots nobody reviews | Use sparingly and review |
| No test isolation | Tests fail together | Each test sets up its own state |
| Mocking everything | Tests pass but production breaks | Prefer real implementations |
Verification
After completing any implementation:
- Every new behavior has a corresponding test
- All tests pass:
npm test - Bug fixes include a reproduction test
- Test names describe the behavior being verified
- No tests were skipped or disabled
- Coverage hasn't decreased (if tracked)