Lesson 09
Red, Green, Refactor — let tests drive your design
Use /agile-code-tdd to scaffold a TDD session. The command prompts you for the feature requirements and guides you through the Red-Green-Refactor cycle. It pairs naturally with /agile-code-branch (create the feature branch first) and /agile-code-ci (verify all tests pass before committing).
TDD is a discipline: you write the test before the code. Every feature starts as a failing test. The cycle is simple and repeats continuously.
Never write production code without a failing test that demands it. The test comes first — always.
| Level | Count | Speed | Scope | Example |
|---|---|---|---|---|
| Unit | Hundreds | Milliseconds | Single function/class | calculateDiscount returns correct value |
| Integration | Dozens | Seconds | Module boundaries | OrderService saves to database correctly |
| E2E | Handful | Minutes | Full user journey | User can sign up, browse, and checkout |
Teams that rely mostly on E2E tests have slow CI pipelines, flaky results, and painful debugging. Invest heavily in unit tests. Use E2E tests sparingly for critical user flows.
A test name should describe the behavior, not the implementation. Use the pattern:
should_[expected behavior]_when_[condition]
// Clear and descriptive
should_return_zero_when_cart_is_empty()
should_apply_discount_when_coupon_is_valid()
should_throw_error_when_email_is_invalid()
should_send_notification_when_order_is_placed()
// NOT this:
test1()
testCalculate()
testUserStuff()
Every test follows three distinct phases. Keep them visually separated.
describe('PriceCalculator', () => {
it('should_apply_percentage_discount_when_coupon_is_valid', () => {
// Arrange — set up the test scenario
const calculator = new PriceCalculator();
const items = [
{ name: 'Widget', price: 100, quantity: 2 },
{ name: 'Gadget', price: 50, quantity: 1 },
];
const coupon = { type: 'percentage', value: 10 };
// Act — perform the action under test
const total = calculator.calculateTotal(items, coupon);
// Assert — verify the outcome
expect(total).toBe(225); // (200 + 50) - 10% = 225
});
});
Each test should verify one behavior. Multiple unrelated assertions in a single test make failures hard to diagnose. If a test name uses "and," split it into two tests.
Mock at the edges of your system — databases, APIs, file systems. Never mock the class under test or its internal collaborators.
// Mocking internal helpers defeats the purpose of testing
jest.mock('./calculateTax');
jest.mock('./formatCurrency');
jest.mock('./validateInput');
// You're testing nothing but mock wiring
// Mock only external dependencies
const mockDatabase = { find: jest.fn(), save: jest.fn() };
const mockEmailService = { send: jest.fn() };
const service = new OrderService(mockDatabase, mockEmailService);
it('should_save_order_and_notify_user', async () => {
mockDatabase.save.mockResolvedValue({ id: 'order-1' });
await service.placeOrder(orderData);
expect(mockDatabase.save).toHaveBeenCalledWith(orderData);
expect(mockEmailService.send).toHaveBeenCalled();
});
TDD naturally creates two types of commits. Keep them separate to enable safe reverts and clear code review.
| Commit Type | Prefix | Contains |
|---|---|---|
| Behavioral | feat: / fix: | New functionality + its tests |
| Structural | refactor: | Code reorganization, no behavior change |
A refactor: commit should pass the exact same tests as before. A feat: commit should add new tests. Mixing them makes it impossible to tell if a regression came from the refactor or the feature.
TDD fits into the complete story-to-code workflow. Each command prompts you for the required inputs:
/agile-code-branch → /agile-code-tdd → /agile-code-ci → /agile-code-commit → /agile-code-pr → /agile-code-pr-review → /agile-code-merge → /agile-story-dod → /agile-story-accept
Let's walk through TDD step by step, building a simple feature.
describe('PasswordValidator', () => {
it('should_reject_when_password_is_shorter_than_8_chars', () => {
const validator = new PasswordValidator();
const result = validator.validate('abc');
expect(result.valid).toBe(false);
expect(result.errors).toContain('Must be at least 8 characters');
});
});
// Run: FAIL ✘ — PasswordValidator is not defined
class PasswordValidator {
validate(password) {
const errors = [];
if (password.length < 8) {
errors.push('Must be at least 8 characters');
}
return { valid: errors.length === 0, errors };
}
}
// Run: PASS ✔ — Test passes!
it('should_reject_when_password_has_no_uppercase', () => {
const validator = new PasswordValidator();
const result = validator.validate('abcdefgh');
expect(result.valid).toBe(false);
expect(result.errors).toContain('Must contain an uppercase letter');
});
// Run: FAIL ✘ — No uppercase check yet
validate(password) {
const errors = [];
if (password.length < 8)
errors.push('Must be at least 8 characters');
if (!/[A-Z]/.test(password))
errors.push('Must contain an uppercase letter');
return { valid: errors.length === 0, errors };
}
// Run: PASS ✔ — Both tests pass!
class PasswordValidator {
#rules = [
{ test: pw => pw.length >= 8, msg: 'Must be at least 8 characters' },
{ test: pw => /[A-Z]/.test(pw), msg: 'Must contain an uppercase letter' },
];
validate(password) {
const errors = this.#rules
.filter(rule => !rule.test(password))
.map(rule => rule.msg);
return { valid: errors.length === 0, errors };
}
}
// Run: PASS ✔ — All tests still pass! Now it's extensible.
Notice how the refactor made the code open for extension (adding rules) without modifying the validate method. TDD naturally leads to SOLID code. The tests gave us the confidence to restructure safely.
In TDD, what do you write first?