Buổi 08: Test-Driven Development — Code Tự Tin 100% 🧪
Thành quả: TDD hoàn chỉnh cho 1 CRUD service: 15+ tests, 95%+ coverage
🎯 Mục Tiêu
- Master TDD cycle: Red → Green → Refactor
- Viết unit tests, integration tests, và E2E tests
- Setup test infrastructure với cm-test-gate
- Mocking, stubbing, và test doubles
- Code coverage và quality metrics
📖 Phần 1: TDD Mindset
Tại Sao Test Trước?
❌ WITHOUT TDD:
Write code → (maybe) write tests → Find bugs in production → Panic fix
"It works on my machine" 🤷
✅ WITH TDD:
Write test (RED) → Write minimal code (GREEN) → Improve code (REFACTOR) → Repeat
"It works everywhere, I can prove it" 💪The Cycle
┌──────────┐
│ RED │ ← Write a failing test
│ (fail) │ Đặc tả behavior TRƯỚC
└────┬─────┘
│
┌────▼─────┐
│ GREEN │ ← Write minimum code to pass
│ (pass) │ Không optimize, chỉ pass test
└────┬─────┘
│
┌────▼─────┐
│ REFACTOR │ ← Improve code quality
│ (clean) │ Tests protect you from breaking
└────┬─────┘
│
└──→ Back to RED (next test)📖 Phần 2: Test Types
Testing Pyramid
╱╲
╱E2E╲ Few, slow, expensive
╱──────╲ Browser/API tests
╱Integr. ╲ Medium amount
╱──────────╲ Service + DB tests
╱ Unit Tests╲ Many, fast, cheap
╱──────────────╲ Pure function testsUnit Test Example
typescript
// user.service.test.ts
import { UserService } from './user.service';
describe('UserService', () => {
let service: UserService;
beforeEach(() => {
service = new UserService(mockRepo);
});
describe('createUser', () => {
it('should create user with valid data', async () => {
// Arrange
const input = { name: 'John', email: 'john@test.com', password: 'Pass123!' };
mockRepo.findByEmail.mockResolvedValue(null);
mockRepo.create.mockResolvedValue({ id: '1', ...input });
// Act
const result = await service.createUser(input);
// Assert
expect(result.id).toBeDefined();
expect(result.name).toBe('John');
expect(mockRepo.create).toHaveBeenCalledTimes(1);
});
it('should throw if email already exists', async () => {
mockRepo.findByEmail.mockResolvedValue({ id: '1' });
await expect(
service.createUser({ name: 'John', email: 'john@test.com', password: 'Pass123!' })
).rejects.toThrow('Email already registered');
});
it('should hash password before saving', async () => {
mockRepo.findByEmail.mockResolvedValue(null);
mockRepo.create.mockResolvedValue({ id: '1' });
await service.createUser({ name: 'John', email: 'john@test.com', password: 'Pass123!' });
const savedUser = mockRepo.create.mock.calls[0][0];
expect(savedUser.password).not.toBe('Pass123!');
expect(savedUser.password).toMatch(/^\$2[aby]?\$/); // bcrypt hash
});
});
});Integration Test
typescript
// user.integration.test.ts
import request from 'supertest';
import { app } from './app';
import { prisma } from './db';
describe('POST /api/users', () => {
beforeEach(async () => {
await prisma.user.deleteMany();
});
it('should create user and return 201', async () => {
const res = await request(app)
.post('/api/users')
.send({ name: 'John', email: 'john@test.com', password: 'Pass123!' });
expect(res.status).toBe(201);
expect(res.body.data.name).toBe('John');
expect(res.body.data.password).toBeUndefined(); // Never return password!
});
it('should return 400 for invalid email', async () => {
const res = await request(app)
.post('/api/users')
.send({ name: 'John', email: 'not-email', password: 'Pass123!' });
expect(res.status).toBe(400);
expect(res.body.errors).toContainEqual(
expect.objectContaining({ field: 'email' })
);
});
it('should return 409 for duplicate email', async () => {
await prisma.user.create({ data: { name: 'Jane', email: 'john@test.com', password: 'x' } });
const res = await request(app)
.post('/api/users')
.send({ name: 'John', email: 'john@test.com', password: 'Pass123!' });
expect(res.status).toBe(409);
});
});📖 Phần 3: Mocking & Test Doubles
Types
| Type | Purpose | Example |
|---|---|---|
| Mock | Verify interactions | expect(sendEmail).toHaveBeenCalled() |
| Stub | Return fixed data | mockDb.find.mockResolvedValue(user) |
| Spy | Watch real function | jest.spyOn(service, 'validate') |
| Fake | Simple replacement | In-memory database for integration test |
Mocking Example
typescript
// Mock external service
const mockEmailService = {
send: jest.fn().mockResolvedValue({ success: true }),
};
// Mock database
const mockUserRepo = {
findById: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
};
// Inject mocks
const service = new UserService(mockUserRepo, mockEmailService);📖 Phần 4: cm-test-gate — Test Infrastructure
4-Layer Test Setup
bash
# cm-test-gate auto-generates:
tests/
├── unit/ # Pure function tests (fast)
├── integration/ # API + DB tests (medium)
├── e2e/ # Browser tests (slow)
├── security/ # Security scans
├── setup.ts # Global test setup
└── helpers/ # Test utilitiesConfiguration
json
// package.json scripts
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:unit": "vitest run tests/unit",
"test:integration": "vitest run tests/integration",
"test:e2e": "playwright test",
"test:gate": "npm run test:unit && npm run test:integration && npm run test:security"
}
}VibeCoding TDD Workflow
1. cm-planning: Define feature spec
2. cm-tdd: Write failing test (RED)
3. AI: Generate minimal code (GREEN)
4. Human: Review code quality
5. cm-tdd: Refactor → run tests (REFACTOR)
6. Repeat until feature complete
7. cm-quality-gate: Final verification📖 Phần 5: Test Patterns
Test Pattern: AAA (Arrange-Act-Assert)
typescript
it('should calculate order total with discount', () => {
// Arrange — Setup
const items = [
{ name: 'Widget', price: 100, quantity: 3 },
{ name: 'Gadget', price: 50, quantity: 2 },
];
// Act — Execute
const total = calculateOrderTotal(items, { discountPercent: 10 });
// Assert — Verify
expect(total).toBe(360); // (300 + 100) * 0.9
});Test Naming Convention
describe('[Module]', () => {
describe('[method]', () => {
it('should [expected behavior] when [condition]', () => {});
it('should throw [ErrorType] when [invalid condition]', () => {});
});
});
// Examples:
it('should return user when valid ID provided')
it('should throw NotFoundError when user ID does not exist')
it('should hash password before saving to database')
it('should return paginated results with default limit of 20')🧪 Lab: TDD CRUD Service
Task: Build UserService test-first (60 min)
Round 1 (RED): Write tests for createUser
→ 4 tests: valid create, duplicate email, invalid password, missing fields
Round 2 (GREEN): Implement createUser to pass all tests
Round 3 (RED): Write tests for getUserById
→ 3 tests: found, not found, invalid ID format
Round 4 (GREEN): Implement getUserById
Round 5 (RED): Write tests for updateUser
→ 4 tests: valid update, not found, email conflict, partial update
Round 6 (GREEN): Implement updateUser
Round 7 (REFACTOR): Extract shared validation, DRY up test setup
Final: npm run test:coverage → Expect 95%+🎓 Tóm Tắt
| Concept | Key Point |
|---|---|
| TDD Cycle | Red → Green → Refactor (never skip) |
| Test Types | Unit (many) → Integration (some) → E2E (few) |
| Mocking | Mock external dependencies, test your logic |
| cm-test-gate | 4-layer test infrastructure setup |
| Coverage | 95%+ cho critical business logic |
⏭️ Buổi tiếp theo
Buổi 09: Backend Foundation — REST API, Database, Auth 🔧