Skip to content

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

  1. Master TDD cycle: Red → Green → Refactor
  2. Viết unit tests, integration tests, và E2E tests
  3. Setup test infrastructure với cm-test-gate
  4. Mocking, stubbing, và test doubles
  5. 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 tests

Unit 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

TypePurposeExample
MockVerify interactionsexpect(sendEmail).toHaveBeenCalled()
StubReturn fixed datamockDb.find.mockResolvedValue(user)
SpyWatch real functionjest.spyOn(service, 'validate')
FakeSimple replacementIn-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 utilities

Configuration

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

ConceptKey Point
TDD CycleRed → Green → Refactor (never skip)
Test TypesUnit (many) → Integration (some) → E2E (few)
MockingMock external dependencies, test your logic
cm-test-gate4-layer test infrastructure setup
Coverage95%+ cho critical business logic

⏭️ Buổi tiếp theo

Buổi 09: Backend Foundation — REST API, Database, Auth 🔧

Powered by CodyMaster × VitePress