Skip to content

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ì)

typescript
// ❌ 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

typescript
// ✅ 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

typescript
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

typescript
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:

bash
npm test path/to/test.test.ts

Confirm:

  • 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

typescript
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

typescript
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.

bash
npm test path/to/test.test.ts

Confirm:

  • 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

QualityGoodBad
MinimalOne thing. "and" in name? Split it.test('validates email and domain and whitespace')
ClearName describes behaviortest('test1')
Shows intentDemonstrates desired APIObscures 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.

typescript
// ❌ 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 === false

Rationalization #2: "I'll test after"

Reality: Tests passing immediately prove nothing.

typescript
// ❌ 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.

typescript
// ❌ 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 after

Solution: Throw away exploration. Start fresh with TDD.

Rationalization #7-11: Remaining Excuses

ExcuseReality
"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:

  1. Code before test ← The Iron Law violation
  2. Test after implementation
  3. Test passes immediately ← Chứng minh không gì
  4. Can't explain why test failed ← Test confused
  5. Tests added "later"
  6. Rationalizing "just this once" ← Bắt đầu của con dốc
  7. "I already manually tested it" ← Ad-hoc, không systematic
  8. "Tests after achieve the same purpose" ← False equivalence
  9. "It's about spirit not ritual" ← Ritual matters
  10. "Keep as reference" ← You'll adapt it anyway
  11. "Already spent X hours" ← Sunk cost fallacy
  12. "TDD is dogmatic, I'm being pragmatic" ← TDD IS pragmatic
  13. "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

bash
cd /path/to/taskflow
npm install
npm run test  # Vitest in watch mode

TaskFlow 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 here

Exercise 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:

typescript
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

bash
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:

javascript
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

bash
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:

javascript
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:

typescript
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

bash
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:

javascript
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

bash
npm test -- test/business-logic.test.ts

# Expected:
# ✓ createTask rejects empty title
# ✓ createTask rejects invalid priority
# ✓ createTask accepts valid priority
# All pass

Step 5: REFACTOR

Combine validation:

javascript
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:

typescript
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

bash
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 200

Test FAILS because DELETE always returns 200.

Step 3: GREEN - Add Existence Check

Edit src/routes/tasks.js:

javascript
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

bash
npm test -- test/api-routes.test.ts

# Expected:
# ✓ DELETE /tasks/9999 returns 404 for non-existent task

Test PASSES.

Step 5: REFACTOR

Extract helper:

javascript
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)

ProblemSolution
Don't know how to testWrite wished-for API. Write assertion first. Ask partner.
Test too complicatedDesign too complicated. Simplify interface.
Must mock everythingCode too coupled. Use dependency injection.
Test setup hugeExtract 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ầnThời gianFocus
1. Nguyên tắc cơ bản15 phútIron Law, Why Order Matters
2. Red-Green-Refactor30 phút5 steps, Good Tests
3. Rationalizations15 phútRecognize excuses
4. Red Flags10 phútStop & restart
5. Practice (3 exercises)50 phútBug fix, new feature, API bug
6. Checklist5 phútVerification
7. Stuck?5 phútDebugging tips

Homework - Tuần Sau

  1. Fix remaining bugs in TaskFlow sử dụng TDD
  2. Add test cho update & list operations
  3. 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

Powered by CodyMaster × VitePress