Buổi 05: TDD Red-Green-Refactor
Mục tiêu bài học
Sau buổi này, bạn sẽ:
- Hiểu và áp dụng chu trình Red-Green-Refactor
- Viết test TRƯỚC implementation code
- Cố định lỗi trong TaskFlow app bằng TDD
- Nhận biết các rationalizations để bỏ qua TDD
Phần 1: Nguyên tắc Cơ bản (15 phút)
The Iron Law of TDD
NO PRODUCTION CODE WITHOUT A FAILING TEST FIRST.
Write code before the test? Delete it. Start over.Không có ngoại lệ:
- Đừng giữ nó làm "reference"
- Đừng "adapt" nó khi viết test
- Đừng nhìn vào nó
- Delete nghĩa là delete
Implement lại từ đầu từ tests. Period.
Tại sao Order Matters?
Tests Written AFTER = Passing Immediately (Chứng minh không gì)
// ❌ BAD: Viết test sau implementation
function validateEmail(email) {
return email.includes('@');
}
// Rồi viết test
test('validates email', () => {
expect(validateEmail('user@example.com')).toBe(true);
// Test này PASS NGAY LẬP TỨC vì code đã tồn tại
// Bạn KHÔNG BUNG GỠI test này bao giờ catch bug
});Tests Written FIRST = Fail, Prove Tests Work
// ✅ GOOD: Viết test trước
test('validates email', () => {
expect(validateEmail('user@example.com')).toBe(true);
});
// RUN: FAIL (function không tồn tại)
// Bạn THẤY nó fail → biết test này là REAL
function validateEmail(email) {
return email.includes('@');
}
// RUN: PASS (và bạn tin test này)Step 0: Check Working Memory
Trước khi viết TEST bất kỳ, kiểm tra .cm/CONTINUITY.md:
- "Mistakes & Learnings" → Có edge cases nào đã biết không?
- "Working Context" → Patterns/conventions nào được follow?
- "Key Decisions" → Architecture choices nào ảnh hưởng?
Tiết kiệm token + tăng quality bằng cách biết past failures.
Phần 2: Red-Green-Refactor Cycle (30 phút)
Step 1: RED - Viết Failing Test
Viết một test tối thiểu show cái gì SHOULD happen.
Requirements:
- One behavior (một hành động duy nhất)
- Clear name (tên mô tả hành động)
- Real code (không mock nếu tránh được)
Good Test
test('createTask rejects empty title', () => {
expect(() => createTask({ title: '' })).toThrow('Title is required');
});- Tên clear: mô tả đúng hành động
- Một việc: reject empty title
- Real code: gọi function thực
Bad Test
test('validation works', () => {
const mock = jest.fn();
createTask({ title: '' });
expect(mock).toHaveBeenCalled();
});- Tên mơ hồ
- Test mock, không test code
- "and" ẩn (validation works = nhiều thứ)
Step 2: Verify RED - Watch It Fail
MANDATORY. Đừng bỏ qua.
Chạy test:
npm test path/to/test.test.tsConfirm:
- Test fails (không error)
- Failure message expected
- Fails vì feature missing (không phải typo)
Nếu test PASS → bạn test existing behavior. Fix test. Nếu test ERROR → fix error, run lại đến khi fail correctly.
Step 3: GREEN - Minimal Code
Viết simplest code để pass test.
Good: Minimal
function createTask(task) {
if (!task.title || !task.title.trim()) {
throw new Error('Title is required');
}
return { id: Date.now(), ...task };
}- Just enough để pass test
- Không add features
- Không refactor unrelated code
Bad: Over-engineered
function createTask(task, options = {}) {
const {
validateEmail = true,
validatePhone = true,
maxTitleLength = 255,
minTitleLength = 1,
sanitize = false,
normalize = false,
} = options;
if (!task.title?.trim()) {
throw new Error('Title is required');
}
// YAGNI (You Aren't Gonna Need It)
// Thêm logic không cần thiết
}Step 4: Verify GREEN - Watch It Pass
MANDATORY.
npm test path/to/test.test.tsConfirm:
- Test passes
- Other tests still pass
- Output pristine (no errors, warnings)
Nếu test fail → fix code, NOT test. Nếu other tests fail → fix ngay.
Step 5: REFACTOR - Clean Up (After GREEN)
Chỉ sau GREEN:
- Remove duplication
- Improve names
- Extract helpers
Keep tests green. Don't add behavior.
Good Tests Checklist
| Quality | Good | Bad |
|---|---|---|
| Minimal | One thing. "and" in name? Split it. | test('validates email and domain and whitespace') |
| Clear | Name describes behavior | test('test1') |
| Shows intent | Demonstrates desired API | Obscures what code should do |
Phần 3: Common Rationalizations (15 phút)
Rationalization #1: "Too simple to test"
Reality: Simple code breaks. Test takes 30 seconds.
// ❌ Bỏ qua test vì "quá đơn giản"
function isPositive(n) { return n > 0; }
// Rồi sau đó...
isPositive(0) // "đơn giản quá" nhưng BUG: trả về false, expected true?
isPositive(-0) // Bug: -0 > 0 === falseRationalization #2: "I'll test after"
Reality: Tests passing immediately prove nothing.
// ❌ Test sau code
const result = calculateTotal([1, 2, 3]); // code sẵn
test('calculates total', () => {
expect(calculateTotal([1, 2, 3])).toBe(6); // PASS NGAY
// Bạn chưa bao giờ thấy test fail
// Test không prove gì cả
});Rationalization #3: "Tests after achieve same goals"
Reality: Tests-after = "what does this do?" | Tests-first = "what SHOULD this do?"
- Tests-after: Biased by implementation. Test cái bạn BUILD.
- Tests-first: Discover edge cases BEFORE implementing.
Rationalization #4: "Already manually tested"
Reality: Ad-hoc ≠ systematic.
Manual testing:
- No record of what tested
- Can't re-run when code changes
- Easy to forget cases under pressure
Automated tests = systematic, reproducible.
Rationalization #5: "Deleting X hours is wasteful"
Reality: Sunk cost fallacy.
Thời gian đã qua rồi. Lựa chọn hiện tại:
1. Delete & rewrite with TDD: X more hours, HIGH confidence
2. Keep & test after: 30 min, LOW confidence, likely bugs
"Waste" = keeping code you can't trust = technical debt.Rationalization #6: "Keep as reference, write tests first"
Reality: You'll adapt it. Delete means delete.
// ❌ Viết draft code
function processOrder(order) {
// ... complex logic ...
}
// Sau đó: "Tôi sẽ dùng nó làm reference"
// Sự thật: Bạn sẽ adapt nó → đó là testing afterSolution: Throw away exploration. Start fresh with TDD.
Rationalization #7-11: Remaining Excuses
| Excuse | Reality |
|---|---|
| "Need to explore first" | Fine. Throw away exploration, start with TDD. |
| "Test hard = design unclear" | Listen to test. Hard to test = hard to use. |
| "TDD will slow me down" | TDD faster than debugging. |
| "Manual test faster" | Manual doesn't prove edge cases. |
| "Existing code has no tests" | You're improving it. Add tests. |
Phần 4: Red Flags - STOP & Start Over (10 phút)
Nếu bạn thấy bất kỳ cái này, hãy DELETE code & START OVER:
- Code before test ← The Iron Law violation
- Test after implementation
- Test passes immediately ← Chứng minh không gì
- Can't explain why test failed ← Test confused
- Tests added "later"
- Rationalizing "just this once" ← Bắt đầu của con dốc
- "I already manually tested it" ← Ad-hoc, không systematic
- "Tests after achieve the same purpose" ← False equivalence
- "It's about spirit not ritual" ← Ritual matters
- "Keep as reference" ← You'll adapt it anyway
- "Already spent X hours" ← Sunk cost fallacy
- "TDD is dogmatic, I'm being pragmatic" ← TDD IS pragmatic
- "This is different because..." ← No, it's not
All = Delete code. Start over with TDD.
Phần 5: Practice - 3 Exercises on TaskFlow (50 phút)
Setup TaskFlow App
cd /path/to/taskflow
npm install
npm run test # Vitest in watch modeTaskFlow structure:
src/
models/task.js # Business logic ← BUG #1
routes/tasks.js # API routes ← BUG #2
test/
business-logic.test.ts # Write Exercise 1 & 2 here
api-routes.test.ts # Write Exercise 3 hereExercise 1: Bug Fix - Empty Title Validation (Bug #1)
Bug: createTask() accepts empty title. Should reject.
Step 1: RED - Write Failing Test
Tạo file test/business-logic.test.ts:
import { describe, it, expect } from 'vitest';
import { createTask } from '../src/models/task';
describe('Task Model', () => {
it('createTask rejects empty title', () => {
expect(() => createTask({ title: '' })).toThrow('Title is required');
});
});Step 2: Verify RED
cd /path/to/taskflow
npm test -- test/business-logic.test.ts
# Expected output:
# FAIL test/business-logic.test.ts
# ✗ createTask rejects empty title
# TypeError: createTask is not a function
# (or: Expected error to be thrown)Confirm: Test FAILS vì feature missing.
Step 3: GREEN - Add Validation
Edit src/models/task.js:
export function createTask(task) {
// Add validation
if (!task.title || !task.title.trim()) {
throw new Error('Title is required');
}
// Keep existing code
return {
id: Date.now(),
title: task.title.trim(),
description: task.description || '',
completed: false,
createdAt: new Date(),
};
}Step 4: Verify GREEN
npm test -- test/business-logic.test.ts
# Expected output:
# PASS test/business-logic.test.ts
# ✓ createTask rejects empty title (2ms)Confirm: Test PASSES, all other tests still pass.
Step 5: REFACTOR
Extract validation helper:
function validateTitle(title) {
if (!title || !title.trim()) {
throw new Error('Title is required');
}
}
export function createTask(task) {
validateTitle(task.title);
return {
id: Date.now(),
title: task.title.trim(),
description: task.description || '',
completed: false,
createdAt: new Date(),
};
}Verify: Test still passes.
Exercise 2: New Feature - Priority Validation
Feature: Validate priority field. Only accept: 'low', 'medium', 'high'.
Step 1: RED - Write Failing Test
Add to test/business-logic.test.ts:
it('createTask rejects invalid priority', () => {
expect(() => createTask({
title: 'Valid Title',
priority: 'urgent' // Invalid
})).toThrow('Priority must be: low, medium, or high');
});
it('createTask accepts valid priority', () => {
const task = createTask({
title: 'Valid Title',
priority: 'high'
});
expect(task.priority).toBe('high');
});Step 2: Verify RED
npm test -- test/business-logic.test.ts
# Expected:
# ✗ createTask rejects invalid priority
# ✗ createTask accepts valid priority
# (Expected error to be thrown / undefined !== 'high')Both tests FAIL.
Step 3: GREEN - Add Priority Validation
Edit src/models/task.js:
const VALID_PRIORITIES = ['low', 'medium', 'high'];
function validatePriority(priority) {
if (priority && !VALID_PRIORITIES.includes(priority)) {
throw new Error('Priority must be: low, medium, or high');
}
}
export function createTask(task) {
validateTitle(task.title);
validatePriority(task.priority);
return {
id: Date.now(),
title: task.title.trim(),
priority: task.priority || 'medium',
description: task.description || '',
completed: false,
createdAt: new Date(),
};
}Step 4: Verify GREEN
npm test -- test/business-logic.test.ts
# Expected:
# ✓ createTask rejects empty title
# ✓ createTask rejects invalid priority
# ✓ createTask accepts valid priority
# All passStep 5: REFACTOR
Combine validation:
function validateTask(task) {
validateTitle(task.title);
validatePriority(task.priority);
}
export function createTask(task) {
validateTask(task);
return {
id: Date.now(),
title: task.title.trim(),
priority: task.priority || 'medium',
description: task.description || '',
completed: false,
createdAt: new Date(),
};
}Exercise 3: API Bug - DELETE Non-existent Task (Bug #2)
Bug: DELETE /tasks/9999 returns 200. Should return 404.
Step 1: RED - Write Failing Test
Tạo file test/api-routes.test.ts:
import { describe, it, expect, beforeEach } from 'vitest';
import request from 'supertest';
import app from '../src/app'; // Express app
describe('Tasks API Routes', () => {
it('DELETE /tasks/9999 returns 404 for non-existent task', async () => {
const response = await request(app)
.delete('/tasks/9999')
.expect(404);
expect(response.body.error).toBe('Task not found');
});
});Step 2: Verify RED
npm install --save-dev supertest # If not installed
npm test -- test/api-routes.test.ts
# Expected:
# FAIL test/api-routes.test.ts
# ✗ DELETE /tasks/9999 returns 404 for non-existent task
# Expected status 404, got 200Test FAILS because DELETE always returns 200.
Step 3: GREEN - Add Existence Check
Edit src/routes/tasks.js:
router.delete('/:id', (req, res) => {
const { id } = req.params;
// ADD THIS: Check if task exists
const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
if (!task) {
return res.status(404).json({ error: 'Task not found' });
}
// Delete task
db.prepare('DELETE FROM tasks WHERE id = ?').run(id);
res.status(200).json({ success: true });
});Step 4: Verify GREEN
npm test -- test/api-routes.test.ts
# Expected:
# ✓ DELETE /tasks/9999 returns 404 for non-existent taskTest PASSES.
Step 5: REFACTOR
Extract helper:
function findTaskOrFail(id, res) {
const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
if (!task) {
res.status(404).json({ error: 'Task not found' });
return null;
}
return task;
}
router.delete('/:id', (req, res) => {
const { id } = req.params;
if (!findTaskOrFail(id, res)) return;
db.prepare('DELETE FROM tasks WHERE id = ?').run(id);
res.status(200).json({ success: true });
});Verify: Test still passes.
Phần 6: Verification Checklist (5 phút)
Trước khi kết thúc, check:
- [ ] Mọi test mới viết
- [ ] Watched mỗi test fail TRƯỚC implementing
- [ ] Mỗi test failed vì reason expected (feature missing, NOT typo)
- [ ] Wrote minimal code để pass each test
- [ ] ALL tests pass
- [ ] Output pristine (no errors, warnings)
- [ ] Tests use real code (mocks only if unavoidable)
- [ ] Edge cases covered
Không check hết? Bạn bỏ qua TDD. Start over.
Phần 7: When Stuck (5 phút)
| Problem | Solution |
|---|---|
| Don't know how to test | Write wished-for API. Write assertion first. Ask partner. |
| Test too complicated | Design too complicated. Simplify interface. |
| Must mock everything | Code too coupled. Use dependency injection. |
| Test setup huge | Extract helpers. Still complex? Simplify design. |
The Final Rule
Production code → test exists and failed first
Otherwise → not TDD
No exceptions without your human partner's permission.Tổng kết - 120 phút
| Phần | Thời gian | Focus |
|---|---|---|
| 1. Nguyên tắc cơ bản | 15 phút | Iron Law, Why Order Matters |
| 2. Red-Green-Refactor | 30 phút | 5 steps, Good Tests |
| 3. Rationalizations | 15 phút | Recognize excuses |
| 4. Red Flags | 10 phút | Stop & restart |
| 5. Practice (3 exercises) | 50 phút | Bug fix, new feature, API bug |
| 6. Checklist | 5 phút | Verification |
| 7. Stuck? | 5 phút | Debugging tips |
Homework - Tuần Sau
- Fix remaining bugs in TaskFlow sử dụng TDD
- Add test cho update & list operations
- Write tests FIRST cho bất kỳ new code nào
Key reminder: If you didn't watch the test fail, you don't know if it tests the right thing.
Resources
- cm-tdd Skill: Use ALWAYS when implementing anything
- TaskFlow App: Node.js + Express + SQLite + Vitest
- Working Memory: .cm/CONTINUITY.md before writing tests