Build a URL Shortener: Testing Strategy

URL shortener của bạn hoạt động rồi. URL được shorten, redirect nhanh, analytics được track, user login được. Ship luôn?
Khoan đã. Hiện tại testing strategy duy nhất của bạn là "click lung tung trên browser rồi cầu trời không gì hỏng." Cách này ổn cho đến khi bạn refactor redirect logic, vô tình break Base62 encoding, và mọi short link trên production bắt đầu trả 404. Vào tối thứ Sáu.
Trong bài này, chúng ta sẽ build testing strategy toàn diện bao gồm unit tests cho isolated logic, integration tests cho API flows, database tests với PostgreSQL thật, và load tests để verify redirect performance dưới áp lực.
Thời gian: 2–3 giờ
Prerequisites: Phase 7: Frontend với React
Những gì chúng ta sẽ build trong bài này:
✅ Vitest setup với TypeScript và path aliases
✅ Unit tests cho Base62 encoding, URL validation và short code generation
✅ Service-layer tests với mocked dependencies
✅ Integration tests với Supertest cho full API flows
✅ Database tests với Docker test containers
✅ Redis mocking với ioredis-mock
✅ Load testing với k6 cho redirect throughput
✅ Coverage configuration và CI pipeline
Testing Pyramid
Trước khi viết bất kỳ test nào, hãy thống nhất strategy. Testing pyramid cho ta biết nên viết bao nhiêu test mỗi loại:
Với URL shortener của chúng ta, mapping như sau:
| Layer | Test cái gì | Tool | Số lượng |
|---|---|---|---|
| Unit | Base62 encoding, URL validation, short code generation, service logic | Vitest | ~30 tests |
| Integration | API endpoints, middleware, database queries, cache behavior | Vitest + Supertest | ~20 tests |
| Load | Redirect throughput, concurrent shortening, cache hit rates | k6 | ~5 scenarios |
Chúng ta skip browser E2E tests tạm thời — React frontend đủ mỏng để API integration tests cover được các critical paths.
Setup Vitest
Install testing dependencies:
npm install -D vitest @vitest/coverage-v8 supertest @types/supertest ioredis-mockTại sao chọn các packages này:
- vitest — Test runner nhanh, native Vite, hỗ trợ TypeScript ngay từ đầu
- @vitest/coverage-v8 — Code coverage qua V8 built-in instrumentation
- supertest — HTTP assertions cho Express apps mà không cần start server thật
- ioredis-mock — In-memory Redis mock implement ioredis API
Vitest Configuration
Tạo vitest.config.ts ở project root:
import { defineConfig } from 'vitest/config';
import path from 'path';
export default defineConfig({
test: {
globals: true,
environment: 'node',
setupFiles: ['./tests/setup.ts'],
include: ['tests/**/*.test.ts'],
exclude: ['tests/load/**'],
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
include: ['src/**/*.ts'],
exclude: [
'src/index.ts',
'src/**/*.d.ts',
'src/types/**',
],
thresholds: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
testTimeout: 30000,
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});Test Setup File
Tạo tests/setup.ts:
import { beforeAll, afterAll, afterEach } from 'vitest';
// Set test environment variables
process.env.NODE_ENV = 'test';
process.env.JWT_SECRET = 'test-secret-key';
process.env.BASE_URL = 'http://localhost:3000';
beforeAll(() => {
// Global test setup
});
afterEach(() => {
// Clean up mocks giữa các tests
vi.restoreAllMocks();
});
afterAll(() => {
// Global teardown
});Update package.json Scripts
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:ui": "vitest --ui",
"test:load": "k6 run tests/load/redirect.js"
}
}Test Directory Structure
tests/
├── setup.ts # Global test setup
├── unit/
│ ├── base62.test.ts # Base62 encoding/decoding
│ ├── urlValidator.test.ts # URL validation logic
│ ├── shortCode.test.ts # Short code generation
│ └── services/
│ ├── urlService.test.ts # URL service với mocks
│ └── authService.test.ts # Auth service với mocks
├── integration/
│ ├── helpers.ts # Test app factory, DB helpers
│ ├── shorten.test.ts # POST /api/shorten
│ ├── redirect.test.ts # GET /:code
│ ├── analytics.test.ts # GET /api/urls/:code/stats
│ └── auth.test.ts # Auth endpoints
├── load/
│ ├── redirect.js # k6 redirect load test
│ └── shorten.js # k6 shortening load test
└── fixtures/
└── urls.ts # Test dataUnit Tests: Pure Functions
Unit tests là nền tảng. Chúng nhanh, deterministic, và bắt logic bugs sớm. Bắt đầu với pure functions — không dependencies, không mocks, chỉ input và output.
Testing Base62 Encoding
// tests/unit/base62.test.ts
import { describe, it, expect } from 'vitest';
import { encode, decode } from '@/utils/base62';
describe('Base62', () => {
describe('encode', () => {
it('should encode 0 as "0"', () => {
expect(encode(0)).toBe('0');
});
it('should encode small numbers correctly', () => {
expect(encode(1)).toBe('1');
expect(encode(10)).toBe('a');
expect(encode(61)).toBe('Z');
});
it('should encode 62 as "10"', () => {
expect(encode(62)).toBe('10');
});
it('should encode large numbers', () => {
expect(encode(238328)).toBe('1000');
// 62^3 = 238328
});
it('should produce URL-safe characters only', () => {
const encoded = encode(999999999);
expect(encoded).toMatch(/^[0-9a-zA-Z]+$/);
});
it('should throw for negative numbers', () => {
expect(() => encode(-1)).toThrow();
});
});
describe('decode', () => {
it('should decode single characters', () => {
expect(decode('0')).toBe(0);
expect(decode('a')).toBe(10);
expect(decode('Z')).toBe(61);
});
it('should decode multi-character strings', () => {
expect(decode('10')).toBe(62);
expect(decode('1000')).toBe(238328);
});
it('should throw for invalid characters', () => {
expect(() => decode('hello!')).toThrow();
expect(() => decode('abc-def')).toThrow();
});
});
describe('encode-decode roundtrip', () => {
it('should roundtrip any positive integer', () => {
const testValues = [0, 1, 42, 100, 9999, 1000000, 2147483647];
for (const value of testValues) {
expect(decode(encode(value))).toBe(value);
}
});
});
});Testing URL Validation
// tests/unit/urlValidator.test.ts
import { describe, it, expect } from 'vitest';
import { isValidUrl, normalizeUrl } from '@/utils/urlValidator';
describe('URL Validator', () => {
describe('isValidUrl', () => {
it('should accept valid HTTP URLs', () => {
expect(isValidUrl('https://example.com')).toBe(true);
expect(isValidUrl('http://example.com')).toBe(true);
expect(isValidUrl('https://example.com/path?q=1')).toBe(true);
});
it('should accept URLs with ports', () => {
expect(isValidUrl('http://localhost:3000')).toBe(true);
expect(isValidUrl('https://example.com:8443/api')).toBe(true);
});
it('should reject non-HTTP protocols', () => {
expect(isValidUrl('ftp://example.com')).toBe(false);
expect(isValidUrl('javascript:alert(1)')).toBe(false);
expect(isValidUrl('data:text/html,<h1>Hi</h1>')).toBe(false);
});
it('should reject invalid URLs', () => {
expect(isValidUrl('')).toBe(false);
expect(isValidUrl('not a url')).toBe(false);
expect(isValidUrl('example.com')).toBe(false);
});
it('should reject URLs that point to the shortener itself', () => {
expect(isValidUrl('http://localhost:3000/abc123')).toBe(false);
});
});
describe('normalizeUrl', () => {
it('should remove trailing slashes', () => {
expect(normalizeUrl('https://example.com/')).toBe('https://example.com');
});
it('should lowercase the hostname', () => {
expect(normalizeUrl('https://EXAMPLE.COM/Path'))
.toBe('https://example.com/Path');
});
it('should preserve query parameters', () => {
expect(normalizeUrl('https://example.com/search?q=test'))
.toBe('https://example.com/search?q=test');
});
});
});Testing Short Code Generation
// tests/unit/shortCode.test.ts
import { describe, it, expect } from 'vitest';
import { generateShortCode, isValidShortCode } from '@/utils/shortCode';
describe('Short Code', () => {
describe('generateShortCode', () => {
it('should generate a code of specified length', () => {
const code = generateShortCode(7);
expect(code).toHaveLength(7);
});
it('should use default length of 7', () => {
const code = generateShortCode();
expect(code).toHaveLength(7);
});
it('should contain only URL-safe characters', () => {
for (let i = 0; i < 100; i++) {
const code = generateShortCode();
expect(code).toMatch(/^[0-9a-zA-Z]+$/);
}
});
it('should generate unique codes', () => {
const codes = new Set<string>();
for (let i = 0; i < 1000; i++) {
codes.add(generateShortCode());
}
// Với 62^7 giá trị có thể, 1000 codes phải đều unique
expect(codes.size).toBe(1000);
});
});
describe('isValidShortCode', () => {
it('should accept valid short codes', () => {
expect(isValidShortCode('abc123')).toBe(true);
expect(isValidShortCode('AbCdEfG')).toBe(true);
});
it('should reject codes that are too short', () => {
expect(isValidShortCode('ab')).toBe(false);
});
it('should reject codes that are too long', () => {
expect(isValidShortCode('a'.repeat(21))).toBe(false);
});
it('should reject codes with special characters', () => {
expect(isValidShortCode('abc-123')).toBe(false);
expect(isValidShortCode('abc_123')).toBe(false);
expect(isValidShortCode('abc 123')).toBe(false);
});
});
});Unit Tests: Services với Mocked Dependencies
Service-layer tests verify business logic mà không cần chạm database hay cache. Điều quan trọng là mock dependencies — inject fakes cho Prisma, Redis và external services.
Mocking Strategy
// tests/unit/services/urlService.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { UrlService } from '@/services/urlService';
// Tạo mock dependencies
const mockPrisma = {
url: {
create: vi.fn(),
findUnique: vi.fn(),
findMany: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
click: {
create: vi.fn(),
count: vi.fn(),
groupBy: vi.fn(),
},
};
const mockRedis = {
get: vi.fn(),
set: vi.fn(),
del: vi.fn(),
incr: vi.fn(),
};
describe('UrlService', () => {
let urlService: UrlService;
beforeEach(() => {
vi.clearAllMocks();
urlService = new UrlService(mockPrisma as any, mockRedis as any);
});
describe('shortenUrl', () => {
it('should create a shortened URL', async () => {
const mockUrl = {
id: '1',
originalUrl: 'https://example.com',
shortCode: 'abc1234',
clicks: 0,
createdAt: new Date(),
};
mockPrisma.url.findUnique.mockResolvedValue(null); // Không collision
mockPrisma.url.create.mockResolvedValue(mockUrl);
mockRedis.set.mockResolvedValue('OK');
const result = await urlService.shortenUrl('https://example.com');
expect(result.shortCode).toBe('abc1234');
expect(mockPrisma.url.create).toHaveBeenCalledOnce();
expect(mockRedis.set).toHaveBeenCalledWith(
'url:abc1234',
'https://example.com',
'EX',
expect.any(Number)
);
});
it('should retry on short code collision', async () => {
const existingUrl = { id: '1', shortCode: 'abc1234' };
const newUrl = {
id: '2',
originalUrl: 'https://example.com',
shortCode: 'xyz7890',
clicks: 0,
createdAt: new Date(),
};
// Lần đầu: collision. Lần hai: thành công.
mockPrisma.url.findUnique
.mockResolvedValueOnce(existingUrl)
.mockResolvedValueOnce(null);
mockPrisma.url.create.mockResolvedValue(newUrl);
mockRedis.set.mockResolvedValue('OK');
const result = await urlService.shortenUrl('https://example.com');
expect(result.shortCode).toBe('xyz7890');
expect(mockPrisma.url.findUnique).toHaveBeenCalledTimes(2);
});
it('should reject invalid URLs', async () => {
await expect(
urlService.shortenUrl('not-a-valid-url')
).rejects.toThrow('Invalid URL');
expect(mockPrisma.url.create).not.toHaveBeenCalled();
});
it('should use custom alias when provided', async () => {
const mockUrl = {
id: '1',
originalUrl: 'https://example.com',
shortCode: 'my-link',
clicks: 0,
createdAt: new Date(),
};
mockPrisma.url.findUnique.mockResolvedValue(null);
mockPrisma.url.create.mockResolvedValue(mockUrl);
mockRedis.set.mockResolvedValue('OK');
const result = await urlService.shortenUrl(
'https://example.com',
{ customAlias: 'my-link' }
);
expect(result.shortCode).toBe('my-link');
});
it('should reject duplicate custom alias', async () => {
mockPrisma.url.findUnique.mockResolvedValue({ shortCode: 'taken' });
await expect(
urlService.shortenUrl('https://example.com', { customAlias: 'taken' })
).rejects.toThrow('Alias already taken');
});
});
describe('resolveUrl', () => {
it('should return cached URL on Redis hit', async () => {
mockRedis.get.mockResolvedValue('https://example.com');
const result = await urlService.resolveUrl('abc1234');
expect(result).toBe('https://example.com');
expect(mockPrisma.url.findUnique).not.toHaveBeenCalled();
});
it('should fall back to database on Redis miss', async () => {
mockRedis.get.mockResolvedValue(null);
mockPrisma.url.findUnique.mockResolvedValue({
originalUrl: 'https://example.com',
shortCode: 'abc1234',
});
mockRedis.set.mockResolvedValue('OK');
const result = await urlService.resolveUrl('abc1234');
expect(result).toBe('https://example.com');
expect(mockRedis.set).toHaveBeenCalled(); // Re-cached
});
it('should return null for unknown short code', async () => {
mockRedis.get.mockResolvedValue(null);
mockPrisma.url.findUnique.mockResolvedValue(null);
const result = await urlService.resolveUrl('unknown');
expect(result).toBeNull();
});
it('should not return expired URLs', async () => {
mockRedis.get.mockResolvedValue(null);
mockPrisma.url.findUnique.mockResolvedValue({
originalUrl: 'https://example.com',
shortCode: 'abc1234',
expiresAt: new Date('2020-01-01'), // Đã expired
});
const result = await urlService.resolveUrl('abc1234');
expect(result).toBeNull();
});
});
describe('recordClick', () => {
it('should record click analytics', async () => {
mockPrisma.click.create.mockResolvedValue({ id: '1' });
await urlService.recordClick('abc1234', {
ip: '127.0.0.1',
userAgent: 'Mozilla/5.0',
referer: 'https://twitter.com',
});
expect(mockPrisma.click.create).toHaveBeenCalledWith({
data: expect.objectContaining({
urlShortCode: 'abc1234',
ip: '127.0.0.1',
userAgent: 'Mozilla/5.0',
referer: 'https://twitter.com',
}),
});
});
});
});Testing Auth Service
// tests/unit/services/authService.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { AuthService } from '@/services/authService';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
const mockPrisma = {
user: {
create: vi.fn(),
findUnique: vi.fn(),
},
};
describe('AuthService', () => {
let authService: AuthService;
beforeEach(() => {
vi.clearAllMocks();
authService = new AuthService(mockPrisma as any);
});
describe('register', () => {
it('should hash password before storing', async () => {
mockPrisma.user.findUnique.mockResolvedValue(null);
mockPrisma.user.create.mockResolvedValue({
id: '1',
email: '[email protected]',
});
await authService.register('[email protected]', 'password123');
const createCall = mockPrisma.user.create.mock.calls[0][0];
expect(createCall.data.password).not.toBe('password123');
// Verify đó là bcrypt hash hợp lệ
const isValid = await bcrypt.compare(
'password123',
createCall.data.password
);
expect(isValid).toBe(true);
});
it('should reject duplicate emails', async () => {
mockPrisma.user.findUnique.mockResolvedValue({
id: '1',
email: '[email protected]',
});
await expect(
authService.register('[email protected]', 'password123')
).rejects.toThrow('Email already registered');
});
});
describe('login', () => {
it('should return JWT token on valid credentials', async () => {
const hashedPassword = await bcrypt.hash('password123', 10);
mockPrisma.user.findUnique.mockResolvedValue({
id: '1',
email: '[email protected]',
password: hashedPassword,
});
const token = await authService.login(
'[email protected]',
'password123'
);
expect(token).toBeDefined();
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as any;
expect(decoded.userId).toBe('1');
});
it('should reject invalid password', async () => {
const hashedPassword = await bcrypt.hash('password123', 10);
mockPrisma.user.findUnique.mockResolvedValue({
id: '1',
email: '[email protected]',
password: hashedPassword,
});
await expect(
authService.login('[email protected]', 'wrong-password')
).rejects.toThrow('Invalid credentials');
});
it('should reject non-existent user', async () => {
mockPrisma.user.findUnique.mockResolvedValue(null);
await expect(
authService.login('[email protected]', 'password123')
).rejects.toThrow('Invalid credentials');
});
});
});Integration Tests: API Endpoints với Supertest
Integration tests verify rằng Express routes, middleware, validation và database queries hoạt động cùng nhau. Supertest cho phép gửi HTTP requests đến Express app mà không cần start server thật.
Test App Factory
Tạo helper build Express app mới cho mỗi test:
// tests/integration/helpers.ts
import { createApp } from '@/app';
import { PrismaClient } from '@prisma/client';
import Redis from 'ioredis-mock';
let prisma: PrismaClient;
export async function createTestApp() {
// Dùng test database (configure qua DATABASE_URL trong test env)
prisma = new PrismaClient({
datasourceUrl: process.env.TEST_DATABASE_URL,
});
const redis = new Redis();
const app = createApp({ prisma, redis });
return { app, prisma, redis };
}
export async function cleanDatabase() {
// Delete theo thứ tự để respect foreign key constraints
await prisma.click.deleteMany();
await prisma.url.deleteMany();
await prisma.user.deleteMany();
}
export async function teardown() {
await prisma.$disconnect();
}Testing Shorten Endpoint
// tests/integration/shorten.test.ts
import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import { createTestApp, cleanDatabase, teardown } from './helpers';
import type { Express } from 'express';
describe('POST /api/shorten', () => {
let app: Express;
beforeAll(async () => {
const testApp = await createTestApp();
app = testApp.app;
});
beforeEach(async () => {
await cleanDatabase();
});
afterAll(async () => {
await teardown();
});
it('should shorten a valid URL', async () => {
const response = await request(app)
.post('/api/shorten')
.send({ url: 'https://example.com/very-long-path' })
.expect(201);
expect(response.body).toMatchObject({
success: true,
data: {
originalUrl: 'https://example.com/very-long-path',
shortCode: expect.any(String),
shortUrl: expect.stringContaining('http'),
},
});
expect(response.body.data.shortCode).toHaveLength(7);
});
it('should accept custom alias', async () => {
const response = await request(app)
.post('/api/shorten')
.send({
url: 'https://example.com',
customAlias: 'my-link',
})
.expect(201);
expect(response.body.data.shortCode).toBe('my-link');
});
it('should reject duplicate custom alias', async () => {
// Tạo URL đầu tiên với alias
await request(app)
.post('/api/shorten')
.send({ url: 'https://example.com', customAlias: 'taken' })
.expect(201);
// Thử cùng alias lần nữa
const response = await request(app)
.post('/api/shorten')
.send({ url: 'https://other.com', customAlias: 'taken' })
.expect(409);
expect(response.body.error).toContain('already taken');
});
it('should reject invalid URLs', async () => {
const invalidUrls = [
'',
'not-a-url',
'ftp://example.com',
'javascript:alert(1)',
];
for (const url of invalidUrls) {
await request(app)
.post('/api/shorten')
.send({ url })
.expect(400);
}
});
it('should reject missing URL field', async () => {
await request(app)
.post('/api/shorten')
.send({})
.expect(400);
});
it('should accept expiration date', async () => {
const expiresAt = new Date(Date.now() + 86400000).toISOString(); // +24h
const response = await request(app)
.post('/api/shorten')
.send({
url: 'https://example.com',
expiresAt,
})
.expect(201);
expect(response.body.data.expiresAt).toBeDefined();
});
it('should require auth for bulk shortening', async () => {
await request(app)
.post('/api/shorten/bulk')
.send({
urls: ['https://example.com', 'https://other.com'],
})
.expect(401);
});
});Testing Redirect Endpoint
// tests/integration/redirect.test.ts
import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import { createTestApp, cleanDatabase, teardown } from './helpers';
import type { Express } from 'express';
describe('GET /:code (Redirect)', () => {
let app: Express;
beforeAll(async () => {
const testApp = await createTestApp();
app = testApp.app;
});
beforeEach(async () => {
await cleanDatabase();
});
afterAll(async () => {
await teardown();
});
it('should redirect to original URL', async () => {
// Tạo short URL trước
const createRes = await request(app)
.post('/api/shorten')
.send({ url: 'https://example.com/target' });
const { shortCode } = createRes.body.data;
// Follow redirect
const response = await request(app)
.get(`/${shortCode}`)
.expect(302);
expect(response.headers.location).toBe('https://example.com/target');
});
it('should return 404 for unknown codes', async () => {
await request(app)
.get('/nonexistent')
.expect(404);
});
it('should return 410 for expired URLs', async () => {
// Tạo URL expire ngay lập tức
const createRes = await request(app)
.post('/api/shorten')
.send({
url: 'https://example.com',
expiresAt: new Date(Date.now() - 1000).toISOString(), // Đã expired
});
const { shortCode } = createRes.body.data;
await request(app)
.get(`/${shortCode}`)
.expect(410); // Gone
});
it('should track click on redirect', async () => {
const createRes = await request(app)
.post('/api/shorten')
.send({ url: 'https://example.com' });
const { shortCode } = createRes.body.data;
// Truy cập redirect
await request(app)
.get(`/${shortCode}`)
.set('User-Agent', 'TestBot/1.0')
.set('Referer', 'https://twitter.com')
.expect(302);
// Check analytics
const statsRes = await request(app)
.get(`/api/urls/${shortCode}/stats`)
.expect(200);
expect(statsRes.body.data.totalClicks).toBe(1);
});
});Testing Auth Endpoints
// tests/integration/auth.test.ts
import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import { createTestApp, cleanDatabase, teardown } from './helpers';
import type { Express } from 'express';
describe('Auth Endpoints', () => {
let app: Express;
beforeAll(async () => {
const testApp = await createTestApp();
app = testApp.app;
});
beforeEach(async () => {
await cleanDatabase();
});
afterAll(async () => {
await teardown();
});
describe('POST /api/auth/register', () => {
it('should register a new user', async () => {
const response = await request(app)
.post('/api/auth/register')
.send({
email: '[email protected]',
password: 'securePassword123',
})
.expect(201);
expect(response.body.data.token).toBeDefined();
expect(response.body.data.user.email).toBe('[email protected]');
// Password không bao giờ nên có trong response
expect(response.body.data.user.password).toBeUndefined();
});
it('should reject weak passwords', async () => {
await request(app)
.post('/api/auth/register')
.send({
email: '[email protected]',
password: '123',
})
.expect(400);
});
});
describe('POST /api/auth/login', () => {
it('should return JWT on valid credentials', async () => {
// Register trước
await request(app)
.post('/api/auth/register')
.send({ email: '[email protected]', password: 'password123' });
// Login
const response = await request(app)
.post('/api/auth/login')
.send({ email: '[email protected]', password: 'password123' })
.expect(200);
expect(response.body.data.token).toBeDefined();
});
});
describe('Authenticated routes', () => {
it('should allow access with valid token', async () => {
// Register và lấy token
const registerRes = await request(app)
.post('/api/auth/register')
.send({ email: '[email protected]', password: 'password123' });
const token = registerRes.body.data.token;
// Access protected route
const response = await request(app)
.get('/api/urls/my')
.set('Authorization', `Bearer ${token}`)
.expect(200);
expect(response.body.data).toBeInstanceOf(Array);
});
it('should reject expired tokens', async () => {
// Tạo expired token thủ công
const jwt = await import('jsonwebtoken');
const expiredToken = jwt.sign(
{ userId: '1' },
process.env.JWT_SECRET!,
{ expiresIn: '0s' }
);
await request(app)
.get('/api/urls/my')
.set('Authorization', `Bearer ${expiredToken}`)
.expect(401);
});
});
});Database Testing với Test Containers
Database tests thật bắt được issues mà mocks bỏ sót: constraint violations, index behavior, migration problems. Dùng Docker để spin up PostgreSQL dùng rồi vứt cho tests.
Docker Test Setup
Tạo docker-compose.test.yml:
version: '3.8'
services:
test-db:
image: postgres:16-alpine
environment:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: urlshortener_test
ports:
- '5433:5432'
tmpfs:
- /var/lib/postgresql/data # RAM-backed cho tốc độTest Database Helper
// tests/integration/testDb.ts
import { execSync } from 'child_process';
import { PrismaClient } from '@prisma/client';
const TEST_DATABASE_URL =
'postgresql://test:test@localhost:5433/urlshortener_test';
export async function setupTestDatabase() {
// Start test database container
execSync('docker compose -f docker-compose.test.yml up -d', {
stdio: 'inherit',
});
// Đợi PostgreSQL sẵn sàng
let retries = 10;
while (retries > 0) {
try {
const prisma = new PrismaClient({
datasourceUrl: TEST_DATABASE_URL,
});
await prisma.$connect();
await prisma.$disconnect();
break;
} catch {
retries--;
await new Promise((r) => setTimeout(r, 1000));
}
}
if (retries === 0) {
throw new Error('Test database failed to start');
}
// Chạy migrations
execSync('npx prisma migrate deploy', {
env: { ...process.env, DATABASE_URL: TEST_DATABASE_URL },
stdio: 'inherit',
});
return TEST_DATABASE_URL;
}
export async function teardownTestDatabase() {
execSync('docker compose -f docker-compose.test.yml down -v', {
stdio: 'inherit',
});
}Global Setup với Vitest
// tests/globalSetup.ts
import { setupTestDatabase, teardownTestDatabase } from './integration/testDb';
export async function setup() {
const dbUrl = await setupTestDatabase();
process.env.TEST_DATABASE_URL = dbUrl;
}
export async function teardown() {
await teardownTestDatabase();
}Update vitest.config.ts để dùng global setup:
export default defineConfig({
test: {
// ...existing config
globalSetup: ['./tests/globalSetup.ts'],
},
});Mocking Redis với ioredis-mock
ioredis-mock cung cấp in-memory Redis implementation hỗ trợ hầu hết ioredis commands. Hoàn hảo cho tests cần cache behavior mà không cần chạy Redis server thật.
// tests/integration/cache.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import Redis from 'ioredis-mock';
import { CacheService } from '@/services/cacheService';
describe('CacheService', () => {
let redis: InstanceType<typeof Redis>;
let cacheService: CacheService;
beforeEach(() => {
redis = new Redis();
cacheService = new CacheService(redis);
});
describe('URL caching', () => {
it('should cache URL with TTL', async () => {
await cacheService.cacheUrl('abc1234', 'https://example.com', 3600);
const cached = await redis.get('url:abc1234');
expect(cached).toBe('https://example.com');
const ttl = await redis.ttl('url:abc1234');
expect(ttl).toBeGreaterThan(0);
expect(ttl).toBeLessThanOrEqual(3600);
});
it('should return null for uncached URLs', async () => {
const result = await cacheService.getCachedUrl('nonexistent');
expect(result).toBeNull();
});
it('should invalidate cache on URL update', async () => {
await cacheService.cacheUrl('abc1234', 'https://old.com', 3600);
await cacheService.invalidateUrl('abc1234');
const cached = await redis.get('url:abc1234');
expect(cached).toBeNull();
});
});
describe('Rate limiting', () => {
it('should track request counts', async () => {
const key = 'ratelimit:127.0.0.1';
for (let i = 0; i < 5; i++) {
await cacheService.incrementRateLimit(key, 60);
}
const count = await redis.get(key);
expect(Number(count)).toBe(5);
});
it('should respect window expiration', async () => {
const key = 'ratelimit:127.0.0.1';
await cacheService.incrementRateLimit(key, 60);
const ttl = await redis.ttl(key);
expect(ttl).toBeGreaterThan(0);
expect(ttl).toBeLessThanOrEqual(60);
});
});
});Tại sao không mock Redis thủ công? ioredis-mock implement đúng Redis command semantics (TTL, expiration, atomicity). Manual mocks với
vi.fn()sẽ bỏ sót subtle bugs như "quên set TTL" hoặc "INCR trên non-existent key phải return 1."
Test Fixtures và Factories
Giữ test data gọn gàng với factories:
// tests/fixtures/urls.ts
export const validUrls = [
'https://example.com',
'https://example.com/path/to/page?q=search&lang=en',
'http://subdomain.example.com:8080/api/v1/users',
'https://example.com/path#section-heading',
];
export const invalidUrls = [
'',
'not-a-url',
'ftp://example.com',
'javascript:alert(1)',
'data:text/html,<script>alert(1)</script>',
];
export function createUrlFixture(overrides = {}) {
return {
id: 'test-id-1',
originalUrl: 'https://example.com/test',
shortCode: 'abc1234',
clicks: 0,
userId: null,
expiresAt: null,
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-01'),
...overrides,
};
}
export function createUserFixture(overrides = {}) {
return {
id: 'user-id-1',
email: '[email protected]',
password: '$2b$10$hashedpassword',
createdAt: new Date('2026-01-01'),
...overrides,
};
}
export function createClickFixture(overrides = {}) {
return {
id: 'click-id-1',
urlShortCode: 'abc1234',
ip: '127.0.0.1',
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)',
referer: 'https://twitter.com',
country: 'US',
city: 'San Francisco',
device: 'desktop',
browser: 'Chrome',
createdAt: new Date('2026-01-01'),
...overrides,
};
}Load Testing với k6
Unit và integration tests verify correctness. Load tests verify performance. Redirect engine của bạn có handle được 10,000 requests per second không? Hãy tìm hiểu.
Install k6
# macOS
brew install k6
# Linux
sudo gpg -k
sudo gpg --no-default-keyring --keyring /usr/share/keyrings/k6-archive-keyring.gpg \
--keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D68
echo "deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main" \
| sudo tee /etc/apt/sources.list.d/k6.list
sudo apt-get update && sudo apt-get install k6
# Docker
docker run --rm -i grafana/k6 run -Redirect Load Test
// tests/load/redirect.js
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate, Trend } from 'k6/metrics';
// Custom metrics
const redirectSuccess = new Rate('redirect_success');
const redirectDuration = new Trend('redirect_duration', true);
export const options = {
scenarios: {
// Ramp up đến 500 concurrent users
redirect_load: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '30s', target: 100 }, // Ramp up
{ duration: '1m', target: 500 }, // Peak load
{ duration: '30s', target: 0 }, // Ramp down
],
gracefulRampDown: '10s',
},
},
thresholds: {
// 95% redirects phải hoàn thành trong 100ms
redirect_duration: ['p(95)<100'],
// 99.9% success rate
redirect_success: ['rate>0.999'],
// Overall HTTP failures dưới 0.1%
http_req_failed: ['rate<0.001'],
},
};
// Pre-created short codes (seed trước khi chạy test)
const SHORT_CODES = [
'test001', 'test002', 'test003', 'test004', 'test005',
'test006', 'test007', 'test008', 'test009', 'test010',
];
const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000';
export default function () {
// Chọn random short code
const code = SHORT_CODES[Math.floor(Math.random() * SHORT_CODES.length)];
const response = http.get(`${BASE_URL}/${code}`, {
redirects: 0, // Không follow redirects — ta đang test redirect response
});
const success = check(response, {
'status is 302': (r) => r.status === 302,
'has Location header': (r) => r.headers['Location'] !== undefined,
'response time < 100ms': (r) => r.timings.duration < 100,
});
redirectSuccess.add(success);
redirectDuration.add(response.timings.duration);
sleep(0.1); // 100ms giữa các requests mỗi VU
}
export function handleSummary(data) {
return {
'tests/load/results/redirect-summary.json': JSON.stringify(data, null, 2),
stdout: textSummary(data, { indent: ' ', enableColors: true }),
};
}URL Shortening Load Test
// tests/load/shorten.js
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate } from 'k6/metrics';
const shortenSuccess = new Rate('shorten_success');
export const options = {
scenarios: {
// Simulate concurrent URL creation
concurrent_shorten: {
executor: 'constant-arrival-rate',
rate: 100, // 100 requests mỗi giây
timeUnit: '1s',
duration: '1m',
preAllocatedVUs: 50,
maxVUs: 200,
},
},
thresholds: {
http_req_duration: ['p(95)<500'], // 95th percentile dưới 500ms
shorten_success: ['rate>0.99'], // 99% success
},
};
const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000';
let counter = 0;
export default function () {
counter++;
const payload = JSON.stringify({
url: `https://example.com/page/${counter}/${Date.now()}`,
});
const response = http.post(`${BASE_URL}/api/shorten`, payload, {
headers: { 'Content-Type': 'application/json' },
});
const success = check(response, {
'status is 201': (r) => r.status === 201,
'has shortCode': (r) => {
try {
return JSON.parse(r.body).data.shortCode !== undefined;
} catch {
return false;
}
},
'response time < 500ms': (r) => r.timings.duration < 500,
});
shortenSuccess.add(success);
sleep(0.01);
}Seed Test Data cho Load Tests
Tạo script seed short URLs trước khi chạy load tests:
// tests/load/seed.ts
import { PrismaClient } from '@prisma/client';
async function seed() {
const prisma = new PrismaClient();
const urls = Array.from({ length: 10 }, (_, i) => ({
shortCode: `test${String(i + 1).padStart(3, '0')}`,
originalUrl: `https://example.com/page-${i + 1}`,
clicks: 0,
}));
for (const url of urls) {
await prisma.url.upsert({
where: { shortCode: url.shortCode },
update: {},
create: url,
});
}
console.log(`Seeded ${urls.length} test URLs`);
await prisma.$disconnect();
}
seed();Chạy Load Tests
# 1. Start app
npm run start
# 2. Seed test data
npx tsx tests/load/seed.ts
# 3. Chạy redirect load test
k6 run tests/load/redirect.js
# 4. Chạy shortening load test
k6 run tests/load/shorten.js
# 5. Chạy với custom base URL
k6 run -e BASE_URL=https://staging.myshortener.com tests/load/redirect.jsĐọc Hiểu k6 Results
scenarios: (100.00%) 1 scenario, 500 max VUs, 2m30s max duration
✓ redirect_load
✓ status is 302............: 99.95% ✓ 149925 ✗ 75
✓ has Location header......: 99.95% ✓ 149925 ✗ 75
✓ response time < 100ms...: 97.20% ✓ 145725 ✗ 4200
redirect_duration...........: avg=12.3ms min=1.2ms p(50)=8.5ms p(95)=45.2ms p(99)=89.1ms
redirect_success............: 99.95%
http_req_duration...........: avg=12.5ms min=1.2ms p(50)=8.7ms p(95)=45.8ms p(99)=90.3ms
http_reqs...................: 150000 1000.0/sCác metrics quan trọng cần theo dõi:
- p(95) < 100ms: 95th percentile redirect latency
- http_reqs/s: Throughput — bao nhiêu redirects mỗi giây
- redirect_success rate: Nên > 99.9%
- http_req_failed rate: Nên < 0.1%
Coverage Configuration
Theo dõi code paths nào được tests cover:
# Chạy tests với coverage
npm run test:coverageOutput coverage report trong coverage/:
-----------------------|---------|----------|---------|---------|
File | % Stmts | % Branch | % Funcs | % Lines |
-----------------------|---------|----------|---------|---------|
All files | 87.5 | 82.3 | 91.2 | 87.5 |
src/services | 92.1 | 88.4 | 95.0 | 92.1 |
urlService.ts | 94.3 | 90.1 | 100.0 | 94.3 |
authService.ts | 89.2 | 85.7 | 88.9 | 89.2 |
cacheService.ts | 93.1 | 90.0 | 100.0 | 93.1 |
src/utils | 100.0 | 100.0 | 100.0 | 100.0 |
base62.ts | 100.0 | 100.0 | 100.0 | 100.0 |
urlValidator.ts | 100.0 | 100.0 | 100.0 | 100.0 |
shortCode.ts | 100.0 | 100.0 | 100.0 | 100.0 |
src/routes | 78.5 | 72.3 | 80.0 | 78.5 |
-----------------------|---------|----------|---------|---------|Coverage Targets
Set thresholds thực tế trong vitest.config.ts:
| Metric | Target | Lý do |
|---|---|---|
| Statements | 80% | Hầu hết code paths được cover |
| Branches | 80% | Error handling và edge cases |
| Functions | 80% | Không có dead code |
| Lines | 80% | Comprehensive execution |
Đừng đuổi theo 100%. Cover generated Prisma client code, type definitions và server bootstrapping chỉ tăng test maintenance cost mà không bắt được real bugs. Focus vào business logic.
CI Integration: GitHub Actions
Tự động hóa test execution mỗi lần push và pull request:
# .github/workflows/test.yml
name: Test
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Run unit tests
run: npm run test -- --reporter=verbose
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
files: coverage/lcov.info
fail_ci_if_error: false
integration-tests:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: urlshortener_test
ports:
- 5433:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
TEST_DATABASE_URL: postgresql://test:test@localhost:5433/urlshortener_test
JWT_SECRET: test-secret-for-ci
NODE_ENV: test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Run database migrations
run: npx prisma migrate deploy
- name: Run integration tests
run: npm run test -- --reporter=verbose tests/integration/
load-tests:
runs-on: ubuntu-latest
needs: [unit-tests, integration-tests]
if: github.ref == 'refs/heads/main' # Chỉ trên main branch
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: urlshortener_test
ports:
- 5433:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
DATABASE_URL: postgresql://test:test@localhost:5433/urlshortener_test
REDIS_URL: redis://localhost:6379
JWT_SECRET: test-secret-for-ci
NODE_ENV: test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- uses: grafana/setup-k6-action@v1
- run: npm ci
- run: npx prisma migrate deploy
- name: Start server
run: npm run start &
- name: Wait for server
run: npx wait-on http://localhost:3000 --timeout 30000
- name: Seed test data
run: npx tsx tests/load/seed.ts
- name: Run load tests
run: k6 run tests/load/redirect.js
- name: Upload results
uses: actions/upload-artifact@v4
with:
name: load-test-results
path: tests/load/results/Pipeline Flow
Những Testing Mistakes Thường Gặp
Cẩn thận với các patterns khiến tests fragile hoặc vô dụng:
1. Test Implementation, Không Phải Behavior
// ❌ Sai: Test implementation details bên trong
it('should call Redis.set with exact arguments', () => {
expect(mockRedis.set).toHaveBeenCalledWith(
'url:abc1234',
'https://example.com',
'EX',
3600
);
});
// ✅ Đúng: Test observable behavior
it('should cache the URL for future lookups', async () => {
await urlService.shortenUrl('https://example.com');
// Verify URL có thể lấy lại được (behavior, không phải implementation)
const cached = await urlService.resolveUrl('abc1234');
expect(cached).toBe('https://example.com');
});2. Shared Mutable State Giữa Các Tests
// ❌ Sai: Tests chia sẻ state và phụ thuộc thứ tự chạy
const urls: string[] = [];
it('should add a URL', () => {
urls.push('https://example.com');
expect(urls).toHaveLength(1);
});
it('should be empty', () => {
expect(urls).toHaveLength(0); // FAIL — test trước đã mutate shared state
});
// ✅ Đúng: State mới cho mỗi test
beforeEach(() => {
vi.clearAllMocks();
// Reset database / clear cache
});3. Không Test Error Paths
// ❌ Sai: Chỉ test happy path
describe('shortenUrl', () => {
it('should create a short URL', async () => { /* ... */ });
// Xong? Chưa!
});
// ✅ Đúng: Error paths là nơi hầu hết production bugs sống
describe('shortenUrl', () => {
it('should create a short URL', async () => { /* ... */ });
it('should reject invalid URLs', async () => { /* ... */ });
it('should handle database connection failures', async () => { /* ... */ });
it('should retry on collision', async () => { /* ... */ });
it('should fail after max collision retries', async () => { /* ... */ });
it('should handle Redis connection failures gracefully', async () => { /* ... */ });
});4. Bỏ Qua Async Edge Cases
// ❌ Sai: Thiếu await — test luôn pass dù assertion fail
it('should reject invalid URL', () => {
expect(urlService.shortenUrl('invalid')).rejects.toThrow();
// Test này pass bất kể vì promise rejection không bao giờ được await
});
// ✅ Đúng: Luôn await async assertions
it('should reject invalid URL', async () => {
await expect(urlService.shortenUrl('invalid')).rejects.toThrow('Invalid URL');
});5. Flaky Time-Dependent Tests
// ❌ Sai: Phụ thuộc timing chính xác
it('should expire after 1 second', async () => {
await cacheService.set('key', 'value', 1); // 1 giây TTL
await new Promise(r => setTimeout(r, 1000));
expect(await cacheService.get('key')).toBeNull(); // Flaky!
});
// ✅ Đúng: Mock time thay vì chờ thật
it('should expire after TTL', async () => {
vi.useFakeTimers();
await cacheService.set('key', 'value', 1);
vi.advanceTimersByTime(1500);
expect(await cacheService.get('key')).toBeNull();
vi.useRealTimers();
});Chạy Test Suite
Đây là workflow đầy đủ:
# Chạy tất cả unit tests
npm test
# Chạy watch mode khi develop
npm run test:watch
# Chạy file test cụ thể
npx vitest run tests/unit/base62.test.ts
# Chạy tests match pattern
npx vitest run --reporter=verbose -t "should reject invalid"
# Chạy với coverage report
npm run test:coverage
# Chạy integration tests (cần test database)
docker compose -f docker-compose.test.yml up -d
npx vitest run tests/integration/
# Chạy load tests
k6 run tests/load/redirect.js
k6 run tests/load/shorten.jsTest Output Mẫu
✓ tests/unit/base62.test.ts (10 tests) 3ms
✓ tests/unit/urlValidator.test.ts (9 tests) 2ms
✓ tests/unit/shortCode.test.ts (6 tests) 5ms
✓ tests/unit/services/urlService.test.ts (8 tests) 15ms
✓ tests/unit/services/authService.test.ts (5 tests) 12ms
✓ tests/integration/shorten.test.ts (7 tests) 245ms
✓ tests/integration/redirect.test.ts (4 tests) 189ms
✓ tests/integration/auth.test.ts (5 tests) 312ms
✓ tests/integration/cache.test.ts (5 tests) 8ms
Test Files 9 passed (9)
Tests 59 passed (59)
Start at 10:30:15
Duration 1.24sTiếp Theo
URL shortener của bạn giờ đã có testing strategy vững chắc: unit tests bắt logic bugs nhanh, integration tests verify API contracts, và load tests chứng minh redirect engine handle được production traffic. Tiếp theo, chúng ta sẽ deploy mọi thứ lên production.
Trong Phase 9: Deployment & Production, chúng ta sẽ cover:
- Docker multi-stage builds cho production
- Environment configuration và secrets management
- CI/CD pipeline với GitHub Actions
- Health checks và monitoring setup
- SSL/TLS và reverse proxy configuration
Series: Build a URL Shortener
Previous: Phase 7: Frontend với React
Next: Phase 9: Deployment & Production
📬 Đăng ký nhận bản tin
Nhận những bài viết mới nhất qua email mỗi tuần. Không spam, hủy đăng ký bất cứ lúc nào.
Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất cứ lúc nào.
💬 Comments
Đăng nhập để bình luận
Chúng tôi sẽ không đăng bất cứ điều gì mà không có sự cho phép của bạn.