Testing không phải việc “làm sau” — nó là một phần của quy trình phát triển. Code không test là code không hoạt động (bạn chỉ chưa biết thôi).
Pyramid Test#
╱ E2E ╲ ← Chậm, đắt, ít
╱─────────╲
╱Integration╲ ← Trung bình
╱─────────────╲
╱ Unit Test ╲ ← Nhanh, rẻ, nhiều nhất
╱─────────────────╲txt- Unit Test: Test function/class riêng lẻ, mock dependencies
- Integration Test: Test nhiều module cùng nhau, DB thật
- E2E Test: Test flow user hoàn chỉnh, browser thật
Unit Test với Vitest#
// utils/format.ts
export function formatCurrency(amount: number, currency: string): string {
return new Intl.NumberFormat('vi-VN', {
style: 'currency',
currency,
}).format(amount);
}
// utils/format.test.ts
import { describe, it, expect } from 'vitest';
import { formatCurrency } from './format';
describe('formatCurrency', () => {
it('formats VND correctly', () => {
expect(formatCurrency(100000, 'VND')).toBe('100.000 ₫');
});
it('formats USD correctly', () => {
expect(formatCurrency(99.99, 'USD')).toBe('99,99 $');
});
it('handles zero', () => {
expect(formatCurrency(0, 'VND')).toBe('0 ₫');
});
it('handles negative amounts', () => {
expect(formatCurrency(-50000, 'VND')).toBe('-50.000 ₫');
});
});typescriptMock API#
import { vi, it, expect } from 'vitest';
import { fetchUser } from './api';
vi.mock('./api', () => ({
fetchUser: vi.fn(),
}));
it('returns user when API succeeds', async () => {
(fetchUser as Mock).mockResolvedValue({ id: 1, name: 'Alice' });
const result = await fetchUser(1);
expect(result.name).toBe('Alice');
});typescriptIntegration Test với Supertest#
import request from 'supertest';
import app from '../app';
import { db } from '../db';
describe('POST /api/users', () => {
beforeAll(async () => {
await db.migrate();
});
afterAll(async () => {
await db.destroy();
});
it('creates a new user', async () => {
const res = await request(app)
.post('/api/users')
.send({ name: 'Alice', email: 'alice@example.com' })
.expect(201);
expect(res.body.data).toMatchObject({
name: 'Alice',
email: 'alice@example.com',
});
});
it('rejects duplicate email', async () => {
await request(app)
.post('/api/users')
.send({ name: 'Bob', email: 'alice@example.com' })
.expect(409);
});
it('validates required fields', async () => {
await request(app)
.post('/api/users')
.send({})
.expect(400);
});
});typescriptComponent Test với Testing Library#
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';
describe('LoginForm', () => {
it('shows error when email is invalid', async () => {
render(<LoginForm />);
const emailInput = screen.getByLabelText('Email');
await userEvent.type(emailInput, 'invalid');
await userEvent.tab();
expect(screen.getByText('Please enter a valid email')).toBeInTheDocument();
});
it('calls onSubmit with form data', async () => {
const onSubmit = vi.fn();
render(<LoginForm onSubmit={onSubmit} />);
await userEvent.type(screen.getByLabelText('Email'), 'alice@test.com');
await userEvent.type(screen.getByLabelText('Password'), 'password123');
await userEvent.click(screen.getByRole('button', { name: 'Login' }));
expect(onSubmit).toHaveBeenCalledWith({
email: 'alice@test.com',
password: 'password123',
});
});
});tsxE2E Test với Playwright#
import { test, expect } from '@playwright/test';
test('user can sign up and create a post', async ({ page }) => {
// 1. Visit home page
await page.goto('/');
// 2. Navigate to sign up
await page.click('text=Sign Up');
await page.fill('[name=email]', 'test@example.com');
await page.fill('[name=password]', 'password123');
await page.click('button[type=submit]');
// 3. Verify redirect
await expect(page).toHaveURL('/dashboard');
// 4. Create a post
await page.click('text=New Post');
await page.fill('[name=title]', 'My First Post');
await page.fill('[name=content]', 'Hello World!');
await page.click('button:has-text("Publish")');
// 5. Post appears
await expect(page.locator('text=My First Post')).toBeVisible();
});typescriptCoverage#
# Vitest
npx vitest --coverage
# output:
# ------------------------|---------|---------|---------|---------|
# File | % Stmts | % Branch| % Funcs | % Lines |
# ------------------------|---------|---------|---------|---------|
# src/utils/ | 95.45 | 88.89 | 100.00 | 95.45 |
# src/api/ | 92.31 | 100.00 | 85.71 | 92.31 |
# src/components/ | 88.57 | 78.95 | 91.67 | 88.57 |
# ------------------------|---------|---------|---------|---------|bashMục tiêu: > 80% coverage. Nhưng nhớ: coverage % không đồng nghĩa code quality.
Mock Database Cho Integration Test#
// docker-compose.test.yml
services:
postgres-test:
image: postgres:16-alpine
ports:
- "5433:5432"
environment:
POSTGRES_PASSWORD: test
// vitest.config.ts
export default defineConfig({
test: {
globalSetup: './test/setup.ts',
globalTeardown: './test/teardown.ts',
},
});typescriptCI — Test Trong Pipeline#
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_PASSWORD: test
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm test
env:
DATABASE_URL: postgres://postgres:test@localhost:5432/postgresyamlTips#
- Test behavior, không test implementation — đừng test internal state
- Mỗi test một concept — nếu test fail, bạn biết ngay cái gì hỏng
- AAA Pattern: Arrange (setup) → Act (hành động) → Assert (kiểm tra)
- Fake trước, mock sau — dùng in-memory DB thay vì mock DB
- Snapshot cho UI — nhưng dùng có chọn lọc, snapshot dễ hỏng
Kết Luận#
Viết test không làm bạn chậm — nó làm bạn nhanh hơn về dài hạn. Bắt đầu với unit test cho logic business, thêm integration test cho API, và E2E cho critical user flow. 80% coverage là đủ — chasing 100% thường lãng phí.