在 Day 21 我們建立了測試框架的基礎設施,使用 Node.js 內建測試執行器來撰寫單元測試。今天我們要更進一步,實作整合測試 (Integration Tests) 和 E2E 測試 (End-to-End Tests),確保整個 OTP 服務的各個模組能夠正確協作。
在開始實作前,讓我們先了解測試金字塔的概念:
/\
/ \ E2E Tests (10%)
/ E2E\ - 測試完整用戶流程
/______\ - 最慢但最接近真實場景
/ \
/Integration\ Integration Tests (30%)
/____________\ - 測試模組間協作
/ \ - 包含資料庫、Redis 等外部依賴
/ Unit Tests \ Unit Tests (60%)
/________________\ - 快速、隔離、大量
// test/strategy.md
/**
* Kyo-OTP-Service 測試策略
*
* 1. 單元測試 (Unit Tests) - 60%
* - 純函數邏輯
* - Zod schema 驗證
* - 工具函數
* - Mock 所有外部依賴
*
* 2. 整合測試 (Integration Tests) - 30%
* - API 路由測試
* - 資料庫操作
* - Redis 快取
* - JWT 驗證流程
* - 使用真實的 Redis/PostgreSQL
*
* 3. E2E 測試 (End-to-End Tests) - 10%
* - 完整的 OTP 發送流程
* - 多租戶切換
* - 錯誤處理與重試
* - 使用完整的應用程式堆疊
*/
首先建立資料庫測試的基礎設施:
// test/integration/database.test.ts
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { Pool } from 'pg';
import { createTestApp } from '../setup.js';
describe('Database Integration Tests', () => {
let testPool: Pool;
before(async () => {
// 建立測試資料庫連線
testPool = new Pool({
host: process.env.TEST_DB_HOST || 'localhost',
port: parseInt(process.env.TEST_DB_PORT || '5432'),
database: process.env.TEST_DB_NAME || 'kyo_test',
user: process.env.TEST_DB_USER || 'postgres',
password: process.env.TEST_DB_PASSWORD || 'postgres',
});
// 執行資料庫遷移
await runMigrations(testPool);
});
after(async () => {
// 清理測試資料
await testPool.query('TRUNCATE TABLE tenants CASCADE');
await testPool.end();
});
describe('Tenant Management', () => {
test('should create a new tenant', async () => {
const result = await testPool.query(
'INSERT INTO tenants (name, slug, plan) VALUES ($1, $2, $3) RETURNING *',
['Test Gym', 'test-gym', 'basic']
);
assert.equal(result.rows.length, 1);
assert.equal(result.rows[0].name, 'Test Gym');
assert.equal(result.rows[0].slug, 'test-gym');
assert.ok(result.rows[0].id);
});
test('should enforce unique slug constraint', async () => {
// 先插入一筆資料
await testPool.query(
'INSERT INTO tenants (name, slug, plan) VALUES ($1, $2, $3)',
['Gym A', 'unique-gym', 'basic']
);
// 嘗試插入相同 slug
await assert.rejects(
async () => {
await testPool.query(
'INSERT INTO tenants (name, slug, plan) VALUES ($1, $2, $3)',
['Gym B', 'unique-gym', 'premium']
);
},
{
name: 'error',
message: /duplicate key value violates unique constraint/,
}
);
});
test('should retrieve tenant by slug', async () => {
const slug = 'find-me-gym';
await testPool.query(
'INSERT INTO tenants (name, slug, plan) VALUES ($1, $2, $3)',
['Find Me Gym', slug, 'basic']
);
const result = await testPool.query(
'SELECT * FROM tenants WHERE slug = $1',
[slug]
);
assert.equal(result.rows.length, 1);
assert.equal(result.rows[0].slug, slug);
});
});
describe('OTP Records', () => {
let tenantId: string;
before(async () => {
// 建立測試租戶
const result = await testPool.query(
'INSERT INTO tenants (name, slug, plan) VALUES ($1, $2, $3) RETURNING id',
['OTP Test Gym', 'otp-test-gym', 'basic']
);
tenantId = result.rows[0].id;
});
test('should create OTP record with expiration', async () => {
const phone = '0912345678';
const code = '123456';
const expiresAt = new Date(Date.now() + 5 * 60 * 1000); // 5分鐘後過期
const result = await testPool.query(
`INSERT INTO otp_records (tenant_id, phone, code, expires_at, status)
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
[tenantId, phone, code, expiresAt, 'pending']
);
assert.equal(result.rows.length, 1);
assert.equal(result.rows[0].phone, phone);
assert.equal(result.rows[0].code, code);
assert.equal(result.rows[0].status, 'pending');
});
test('should verify OTP code within expiration', async () => {
const phone = '0987654321';
const code = '654321';
const expiresAt = new Date(Date.now() + 5 * 60 * 1000);
// 建立 OTP 記錄
await testPool.query(
`INSERT INTO otp_records (tenant_id, phone, code, expires_at, status)
VALUES ($1, $2, $3, $4, $5)`,
[tenantId, phone, code, expiresAt, 'pending']
);
// 驗證 OTP
const result = await testPool.query(
`SELECT * FROM otp_records
WHERE tenant_id = $1 AND phone = $2 AND code = $3
AND expires_at > NOW() AND status = 'pending'
LIMIT 1`,
[tenantId, phone, code]
);
assert.equal(result.rows.length, 1);
// 更新狀態為已驗證
await testPool.query(
`UPDATE otp_records SET status = 'verified', verified_at = NOW()
WHERE id = $1`,
[result.rows[0].id]
);
// 確認更新成功
const verified = await testPool.query(
'SELECT * FROM otp_records WHERE id = $1',
[result.rows[0].id]
);
assert.equal(verified.rows[0].status, 'verified');
assert.ok(verified.rows[0].verified_at);
});
test('should reject expired OTP', async () => {
const phone = '0911111111';
const code = '111111';
const expiresAt = new Date(Date.now() - 1000); // 已過期
await testPool.query(
`INSERT INTO otp_records (tenant_id, phone, code, expires_at, status)
VALUES ($1, $2, $3, $4, $5)`,
[tenantId, phone, code, expiresAt, 'pending']
);
const result = await testPool.query(
`SELECT * FROM otp_records
WHERE tenant_id = $1 AND phone = $2 AND code = $3
AND expires_at > NOW() AND status = 'pending'`,
[tenantId, phone, code]
);
assert.equal(result.rows.length, 0, 'Expired OTP should not be found');
});
});
});
// 測試工具函數
async function runMigrations(pool: Pool): Promise<void> {
// 建立 tenants 表
await pool.query(`
CREATE TABLE IF NOT EXISTS tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
slug VARCHAR(100) UNIQUE NOT NULL,
plan VARCHAR(50) NOT NULL,
database_url TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
// 建立 otp_records 表
await pool.query(`
CREATE TABLE IF NOT EXISTS otp_records (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
phone VARCHAR(20) NOT NULL,
code VARCHAR(10) NOT NULL,
expires_at TIMESTAMP NOT NULL,
status VARCHAR(20) DEFAULT 'pending',
verified_at TIMESTAMP,
attempts INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_otp_lookup (tenant_id, phone, code, status)
)
`);
}
// test/integration/redis.test.ts
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import Redis from 'ioredis';
describe('Redis Integration Tests', () => {
let redis: Redis;
before(async () => {
redis = new Redis({
host: process.env.TEST_REDIS_HOST || 'localhost',
port: parseInt(process.env.TEST_REDIS_PORT || '6379'),
db: parseInt(process.env.TEST_REDIS_DB || '1'), // 使用測試專用 DB
});
// 清空測試資料庫
await redis.flushdb();
});
after(async () => {
await redis.flushdb();
await redis.quit();
});
describe('Rate Limiting', () => {
test('should enforce rate limit for OTP requests', async () => {
const phone = '0912345678';
const key = `ratelimit:otp:${phone}`;
const limit = 3;
const window = 60; // 60秒
// 模擬多次請求
for (let i = 0; i < limit; i++) {
const count = await redis.incr(key);
if (count === 1) {
await redis.expire(key, window);
}
assert.ok(count <= limit);
}
// 第四次請求應該被拒絕
const count = await redis.incr(key);
assert.ok(count > limit, 'Should exceed rate limit');
// 檢查 TTL
const ttl = await redis.ttl(key);
assert.ok(ttl > 0 && ttl <= window);
});
test('should reset rate limit after window expires', async () => {
const phone = '0999999999';
const key = `ratelimit:otp:${phone}`;
const window = 2; // 2秒測試窗口
// 達到限制
await redis.incr(key);
await redis.expire(key, window);
// 等待過期
await new Promise(resolve => setTimeout(resolve, window * 1000 + 100));
// 確認已重置
const count = await redis.get(key);
assert.equal(count, null, 'Rate limit should be reset');
});
});
describe('OTP Caching', () => {
test('should cache OTP for verification', async () => {
const phone = '0911111111';
const code = '123456';
const key = `otp:${phone}`;
const ttl = 300; // 5分鐘
// 儲存 OTP
await redis.setex(key, ttl, code);
// 驗證 OTP
const cached = await redis.get(key);
assert.equal(cached, code);
// 檢查過期時間
const remaining = await redis.ttl(key);
assert.ok(remaining > 0 && remaining <= ttl);
});
test('should delete OTP after successful verification', async () => {
const phone = '0922222222';
const code = '654321';
const key = `otp:${phone}`;
await redis.setex(key, 300, code);
// 驗證成功後刪除
const verified = await redis.get(key);
assert.equal(verified, code);
await redis.del(key);
// 確認已刪除
const deleted = await redis.get(key);
assert.equal(deleted, null);
});
});
describe('Session Management', () => {
test('should store user session with TTL', async () => {
const sessionId = 'sess_abc123';
const userId = 'user_xyz789';
const key = `session:${sessionId}`;
const ttl = 3600; // 1小時
const sessionData = JSON.stringify({
userId,
tenantId: 'tenant_123',
createdAt: Date.now(),
});
await redis.setex(key, ttl, sessionData);
const retrieved = await redis.get(key);
assert.ok(retrieved);
const parsed = JSON.parse(retrieved);
assert.equal(parsed.userId, userId);
});
test('should extend session on activity', async () => {
const sessionId = 'sess_extend';
const key = `session:${sessionId}`;
const initialTtl = 10;
const extendedTtl = 3600;
await redis.setex(key, initialTtl, 'session_data');
// 等待一下
await new Promise(resolve => setTimeout(resolve, 2000));
// 延長 session
await redis.expire(key, extendedTtl);
const ttl = await redis.ttl(key);
assert.ok(ttl > initialTtl);
});
});
});
// test/integration/api-routes.test.ts
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { FastifyInstance } from 'fastify';
import { createTestApp, closeTestApp } from '../setup.js';
describe('API Routes Integration Tests', () => {
let app: FastifyInstance;
before(async () => {
app = await createTestApp();
});
after(async () => {
await closeTestApp(app);
});
describe('POST /api/otp/send', () => {
test('should send OTP successfully', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/otp/send',
headers: {
'Content-Type': 'application/json',
'X-Tenant-ID': 'test-tenant',
},
payload: {
phone: '0912345678',
template: 'verification',
},
});
assert.equal(response.statusCode, 200);
const body = JSON.parse(response.body);
assert.ok(body.success);
assert.ok(body.messageId);
assert.ok(body.expiresAt);
});
test('should reject invalid phone number', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/otp/send',
headers: {
'Content-Type': 'application/json',
'X-Tenant-ID': 'test-tenant',
},
payload: {
phone: '123', // 無效電話
template: 'verification',
},
});
assert.equal(response.statusCode, 400);
const body = JSON.parse(response.body);
assert.ok(body.error);
assert.match(body.message, /invalid.*phone/i);
});
test('should enforce rate limiting', async () => {
const phone = '0999999999';
const requests = [];
// 發送多個請求
for (let i = 0; i < 5; i++) {
requests.push(
app.inject({
method: 'POST',
url: '/api/otp/send',
headers: {
'Content-Type': 'application/json',
'X-Tenant-ID': 'test-tenant',
},
payload: {
phone,
template: 'verification',
},
})
);
}
const responses = await Promise.all(requests);
// 應該有一些請求被 rate limit 拒絕
const rateLimited = responses.some(r => r.statusCode === 429);
assert.ok(rateLimited, 'Should have rate limited requests');
});
});
describe('POST /api/otp/verify', () => {
test('should verify correct OTP code', async () => {
const phone = '0911111111';
// 先發送 OTP
const sendResponse = await app.inject({
method: 'POST',
url: '/api/otp/send',
headers: {
'Content-Type': 'application/json',
'X-Tenant-ID': 'test-tenant',
},
payload: {
phone,
template: 'verification',
},
});
assert.equal(sendResponse.statusCode, 200);
// 在測試環境中,我們可以從回應中取得 code (生產環境不會回傳)
const sendBody = JSON.parse(sendResponse.body);
const code = sendBody.code || '123456'; // fallback for production
// 驗證 OTP
const verifyResponse = await app.inject({
method: 'POST',
url: '/api/otp/verify',
headers: {
'Content-Type': 'application/json',
'X-Tenant-ID': 'test-tenant',
},
payload: {
phone,
code,
},
});
assert.equal(verifyResponse.statusCode, 200);
const verifyBody = JSON.parse(verifyResponse.body);
assert.ok(verifyBody.success);
assert.ok(verifyBody.verified);
});
test('should reject incorrect OTP code', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/otp/verify',
headers: {
'Content-Type': 'application/json',
'X-Tenant-ID': 'test-tenant',
},
payload: {
phone: '0922222222',
code: 'wrong-code',
},
});
assert.equal(response.statusCode, 400);
const body = JSON.parse(response.body);
assert.ok(!body.verified);
assert.match(body.message, /invalid.*code/i);
});
test('should track failed verification attempts', async () => {
const phone = '0933333333';
const maxAttempts = 3;
for (let i = 0; i < maxAttempts + 1; i++) {
const response = await app.inject({
method: 'POST',
url: '/api/otp/verify',
headers: {
'Content-Type': 'application/json',
'X-Tenant-ID': 'test-tenant',
},
payload: {
phone,
code: 'wrong-code',
},
});
if (i < maxAttempts) {
assert.equal(response.statusCode, 400);
} else {
// 超過嘗試次數應該被鎖定
assert.equal(response.statusCode, 429);
const body = JSON.parse(response.body);
assert.match(body.message, /too many attempts/i);
}
}
});
});
describe('Multi-tenant Isolation', () => {
test('should isolate OTP between different tenants', async () => {
const phone = '0944444444';
const tenant1 = 'tenant-a';
const tenant2 = 'tenant-b';
// Tenant A 發送 OTP
const send1 = await app.inject({
method: 'POST',
url: '/api/otp/send',
headers: {
'Content-Type': 'application/json',
'X-Tenant-ID': tenant1,
},
payload: {
phone,
template: 'verification',
},
});
assert.equal(send1.statusCode, 200);
const code1 = JSON.parse(send1.body).code;
// Tenant B 嘗試用 Tenant A 的 code 驗證
const verify2 = await app.inject({
method: 'POST',
url: '/api/otp/verify',
headers: {
'Content-Type': 'application/json',
'X-Tenant-ID': tenant2,
},
payload: {
phone,
code: code1,
},
});
// 應該驗證失敗
assert.equal(verify2.statusCode, 400);
const body = JSON.parse(verify2.body);
assert.ok(!body.verified);
});
});
});
E2E 測試模擬真實用戶的完整操作流程:
// test/e2e/otp-flow.test.ts
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { FastifyInstance } from 'fastify';
import { createTestApp, closeTestApp } from '../setup.js';
describe('OTP Complete Flow E2E Tests', () => {
let app: FastifyInstance;
before(async () => {
app = await createTestApp();
// 確保測試資料庫和 Redis 都是乾淨的
await cleanupTestData();
});
after(async () => {
await cleanupTestData();
await closeTestApp(app);
});
test('Complete user verification flow', async () => {
const phone = '0912345678';
const tenantId = 'gym-tenant';
// Step 1: 用戶請求發送 OTP
const sendResponse = await app.inject({
method: 'POST',
url: '/api/otp/send',
headers: {
'Content-Type': 'application/json',
'X-Tenant-ID': tenantId,
},
payload: {
phone,
template: 'login',
},
});
assert.equal(sendResponse.statusCode, 200);
const { messageId, expiresAt, code } = JSON.parse(sendResponse.body);
assert.ok(messageId);
assert.ok(expiresAt);
// Step 2: 用戶輸入錯誤的 OTP (模擬打錯)
const wrongVerifyResponse = await app.inject({
method: 'POST',
url: '/api/otp/verify',
headers: {
'Content-Type': 'application/json',
'X-Tenant-ID': tenantId,
},
payload: {
phone,
code: '000000', // 錯誤的 code
},
});
assert.equal(wrongVerifyResponse.statusCode, 400);
const wrongBody = JSON.parse(wrongVerifyResponse.body);
assert.ok(!wrongBody.verified);
// Step 3: 用戶輸入正確的 OTP
const correctVerifyResponse = await app.inject({
method: 'POST',
url: '/api/otp/verify',
headers: {
'Content-Type': 'application/json',
'X-Tenant-ID': tenantId,
},
payload: {
phone,
code,
},
});
assert.equal(correctVerifyResponse.statusCode, 200);
const correctBody = JSON.parse(correctVerifyResponse.body);
assert.ok(correctBody.verified);
assert.ok(correctBody.token); // JWT token
// Step 4: 使用取得的 token 存取受保護的 API
const protectedResponse = await app.inject({
method: 'GET',
url: '/api/user/profile',
headers: {
'Authorization': `Bearer ${correctBody.token}`,
'X-Tenant-ID': tenantId,
},
});
assert.equal(protectedResponse.statusCode, 200);
const profileBody = JSON.parse(protectedResponse.body);
assert.equal(profileBody.phone, phone);
// Step 5: 確認 OTP 已被標記為已使用,不能重複驗證
const reuseResponse = await app.inject({
method: 'POST',
url: '/api/otp/verify',
headers: {
'Content-Type': 'application/json',
'X-Tenant-ID': tenantId,
},
payload: {
phone,
code,
},
});
assert.equal(reuseResponse.statusCode, 400);
const reuseBody = JSON.parse(reuseResponse.body);
assert.ok(!reuseBody.verified);
assert.match(reuseBody.message, /already used|expired/i);
});
test('Rate limiting flow', async () => {
const phone = '0987654321';
const tenantId = 'test-gym';
const maxRequests = 3;
// 快速發送多個 OTP 請求
const results = [];
for (let i = 0; i < maxRequests + 2; i++) {
const response = await app.inject({
method: 'POST',
url: '/api/otp/send',
headers: {
'Content-Type': 'application/json',
'X-Tenant-ID': tenantId,
},
payload: {
phone,
template: 'verification',
},
});
results.push(response.statusCode);
}
// 前 3 個應該成功,後面的應該被限制
assert.ok(
results.slice(0, maxRequests).every(code => code === 200),
'First requests should succeed'
);
assert.ok(
results.slice(maxRequests).some(code => code === 429),
'Subsequent requests should be rate limited'
);
});
test('OTP expiration flow', async () => {
const phone = '0911111111';
const tenantId = 'expire-test-gym';
// 發送 OTP
const sendResponse = await app.inject({
method: 'POST',
url: '/api/otp/send',
headers: {
'Content-Type': 'application/json',
'X-Tenant-ID': tenantId,
},
payload: {
phone,
template: 'verification',
expiresIn: 2, // 2秒過期 (僅測試用)
},
});
assert.equal(sendResponse.statusCode, 200);
const { code } = JSON.parse(sendResponse.body);
// 等待過期
await new Promise(resolve => setTimeout(resolve, 3000));
// 嘗試驗證過期的 OTP
const verifyResponse = await app.inject({
method: 'POST',
url: '/api/otp/verify',
headers: {
'Content-Type': 'application/json',
'X-Tenant-ID': tenantId,
},
payload: {
phone,
code,
},
});
assert.equal(verifyResponse.statusCode, 400);
const body = JSON.parse(verifyResponse.body);
assert.ok(!body.verified);
assert.match(body.message, /expired/i);
});
});
// 測試工具函數
async function cleanupTestData(): Promise<void> {
// TODO: 清理測試資料庫
// TODO: 清理測試 Redis
}
// test/coverage.config.ts
export const coverageConfig = {
// 覆蓋率門檻
thresholds: {
global: {
statements: 80,
branches: 75,
functions: 80,
lines: 80,
},
// 核心模組要求更高
'src/services/otp-service.ts': {
statements: 90,
branches: 85,
functions: 90,
lines: 90,
},
'src/services/auth-service.ts': {
statements: 90,
branches: 85,
functions: 90,
lines: 90,
},
},
// 排除項目
exclude: [
'src/**/*.d.ts',
'src/**/types.ts',
'src/test/**',
'dist/**',
],
// 報告格式
reporters: ['text', 'html', 'lcov', 'json'],
};
我們今天實作了完整的測試金字塔: