昨天我們建立了完整的套件打包策略,為不同類型的套件選擇了最適合的打包工具。今天我們要建立一個全面的測試策略,確保我們的 Monorepo 中每個套件都有適當的測試覆蓋率和品質保證。
在 Monorepo 架構中,測試扮演更關鍵的角色:
我們採用經典的測試金字塔,針對不同層級設計測試:
🔺 E2E Tests (少量)
---- Integration Tests (適量)
------ Unit Tests (大量)
目標:測試個別函數和類別的邏輯
工具選擇:Node.js 內建的 test runner + TypeScript
// packages/kyo-core/src/__tests__/rateLimiter.test.ts
import { test, describe, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert';
import { tokenBucket } from '../rateLimiter.js';
import { createRedis } from '../redis.js';
describe('Rate Limiter', () => {
let redis: ReturnType<typeof createRedis>;
beforeEach(async () => {
redis = createRedis();
await redis.flushall();
});
afterEach(async () => {
await redis.quit();
});
test('should allow requests within limit', async () => {
const key = 'test:rate:limit';
const result = await tokenBucket(redis, key, {
maxTokens: 5,
refillRate: 1,
windowMs: 60000
});
assert.strictEqual(result.allowed, true);
assert.strictEqual(result.remaining, 4);
});
test('should block requests exceeding limit', async () => {
const key = 'test:rate:limit';
const options = {
maxTokens: 2,
refillRate: 1,
windowMs: 60000
};
// 消耗所有 tokens
await tokenBucket(redis, key, options);
await tokenBucket(redis, key, options);
// 第三個請求應該被阻擋
const result = await tokenBucket(redis, key, options);
assert.strictEqual(result.allowed, false);
assert.strictEqual(result.remaining, 0);
});
test('should calculate correct reset time', async () => {
const key = 'test:rate:limit';
const windowMs = 10000; // 10 seconds
const result = await tokenBucket(redis, key, {
maxTokens: 1,
refillRate: 1,
windowMs
});
assert.strictEqual(result.allowed, true);
assert.ok(result.resetInSec > 0 && result.resetInSec <= 10);
});
});
目標:測試套件間的互動和外部服務整合
// packages/kyo-core/src/__tests__/otpService.integration.test.ts
import { test, describe, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert';
import { OtpService } from '../index.js';
import { StubSmsProvider } from '../sms.js';
import { createRedis } from '../redis.js';
describe('OTP Service Integration', () => {
let otpService: OtpService;
let redis: ReturnType<typeof createRedis>;
beforeEach(async () => {
redis = createRedis();
await redis.flushall();
const stubSms = new StubSmsProvider();
otpService = new OtpService(stubSms);
});
afterEach(async () => {
await redis.quit();
});
test('should complete full OTP flow', async () => {
const phone = '+886987654321';
// 1. 發送 OTP
const sendResult = await otpService.send({ phone });
assert.strictEqual(sendResult.status, 'sent');
assert.ok(sendResult.msgId);
// 2. 驗證正確的 OTP
// 注意:在實際測試中,我們需要從 SMS provider 取得實際的 OTP
// 這裡我們假設 StubSmsProvider 總是使用 '123456'
const verifyResult = await otpService.verify({ phone, otp: '123456' });
assert.strictEqual(verifyResult.success, true);
// 3. 再次驗證相同 OTP 應該失敗(已使用)
try {
await otpService.verify({ phone, otp: '123456' });
assert.fail('Should throw error for used OTP');
} catch (error) {
assert.ok(error.code === 'E_OTP_INVALID');
}
});
test('should handle rate limiting', async () => {
const phone = '+886987654321';
// 發送多個請求觸發 rate limit
await otpService.send({ phone });
try {
await otpService.send({ phone });
assert.fail('Should throw rate limit error');
} catch (error) {
assert.strictEqual(error.code, 'E_RATE_LIMIT');
assert.ok(error.issues.resetInSec > 0);
}
});
test('should lock account after multiple failed attempts', async () => {
const phone = '+886987654321';
// 發送 OTP
await otpService.send({ phone });
// 嘗試 3 次錯誤驗證
for (let i = 0; i < 3; i++) {
try {
await otpService.verify({ phone, otp: 'wrong' });
} catch (error) {
// 預期的錯誤
}
}
// 第四次應該被鎖定
try {
await otpService.verify({ phone, otp: '123456' });
assert.fail('Should throw locked error');
} catch (error) {
assert.strictEqual(error.code, 'E_OTP_LOCKED');
}
});
});
目標:測試 HTTP API 端點的行為
// apps/kyo-otp-service/src/__tests__/api.test.ts
import { test, describe, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert';
import { buildSimpleApp } from '../simple-app.js';
describe('OTP API', () => {
let app: Awaited<ReturnType<typeof buildSimpleApp>>;
beforeEach(async () => {
app = buildSimpleApp();
await app.ready();
});
afterEach(async () => {
await app.close();
});
test('GET /api/health should return ok', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/health'
});
assert.strictEqual(response.statusCode, 200);
const json = JSON.parse(response.body);
assert.strictEqual(json.ok, true);
});
test('POST /api/otp/send should accept valid request', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/otp/send',
headers: {
'content-type': 'application/json'
},
body: JSON.stringify({
phone: '+886987654321',
templateId: 1
})
});
assert.strictEqual(response.statusCode, 202);
const json = JSON.parse(response.body);
assert.strictEqual(json.success, true);
assert.ok(json.msgId);
});
test('POST /api/otp/verify should validate OTP', async () => {
// 先發送 OTP
await app.inject({
method: 'POST',
url: '/api/otp/send',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ phone: '+886987654321' })
});
// 驗證正確的 OTP
const response = await app.inject({
method: 'POST',
url: '/api/otp/verify',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
phone: '+886987654321',
otp: '123456'
})
});
assert.strictEqual(response.statusCode, 200);
const json = JSON.parse(response.body);
assert.strictEqual(json.success, true);
});
test('POST /api/orpc should handle RPC calls', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/orpc',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
method: 'otp.send',
params: {
phone: '+886987654321',
templateId: 1
}
})
});
assert.strictEqual(response.statusCode, 200);
const json = JSON.parse(response.body);
assert.strictEqual(json.success, true);
});
});
目標:測試整個應用的使用者流程
// e2e/otp-flow.test.ts
import { test, describe, beforeAll, afterAll } from 'node:test';
import assert from 'node:assert';
import { chromium, Browser, Page } from 'playwright';
describe('OTP E2E Flow', () => {
let browser: Browser;
let page: Page;
beforeAll(async () => {
browser = await chromium.launch();
page = await browser.newPage();
});
afterAll(async () => {
await browser.close();
});
test('should complete OTP verification flow', async () => {
// 1. 導航到 OTP 頁面
await page.goto('http://localhost:5174/otp');
// 2. 填寫手機號碼
await page.fill('input[name="phone"]', '+886987654321');
// 3. 選擇模板
await page.selectOption('select[name="templateId"]', '1');
// 4. 發送 OTP
await page.click('button[type="submit"]');
// 5. 等待成功訊息
await page.waitForSelector('[data-testid="success-message"]');
const message = await page.textContent('[data-testid="success-message"]');
assert.ok(message?.includes('驗證碼已發送'));
// 6. 導航到驗證頁面
await page.goto('http://localhost:5174/verify');
// 7. 填寫手機號碼和 OTP
await page.fill('input[name="phone"]', '+886987654321');
await page.fill('input[name="otp"]', '123456');
// 8. 提交驗證
await page.click('button[type="submit"]');
// 9. 驗證成功訊息
await page.waitForSelector('[data-testid="verify-success"]');
const verifyMessage = await page.textContent('[data-testid="verify-success"]');
assert.ok(verifyMessage?.includes('驗證成功'));
});
test('should show error for invalid OTP', async () => {
await page.goto('http://localhost:5174/verify');
await page.fill('input[name="phone"]', '+886987654321');
await page.fill('input[name="otp"]', '000000');
await page.click('button[type="submit"]');
await page.waitForSelector('[data-testid="error-message"]');
const errorMessage = await page.textContent('[data-testid="error-message"]');
assert.ok(errorMessage?.includes('驗證碼錯誤'));
});
});
{
"scripts": {
"test": "node --test",
"test:watch": "node --test --watch",
"test:coverage": "node --test --experimental-test-coverage",
"test:integration": "node --test src/**/*.integration.test.ts",
"test:e2e": "playwright test",
"test:all": "pnpm run test && pnpm run test:integration && pnpm run test:e2e"
},
"devDependencies": {
"@types/node": "^20.0.0",
"playwright": "^1.40.0",
"typescript": "^5.0.0"
}
}
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
timeout: 30 * 1000,
expect: {
timeout: 5000
},
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:5174',
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],
webServer: [
{
command: 'pnpm --filter kyo-otp-service dev',
port: 3000,
},
{
command: 'pnpm --filter kyo-dashboard dev',
port: 5174,
},
],
});
# .github/workflows/test.yml
name: Test
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build packages
run: pnpm run build
- name: Run unit tests
run: pnpm run test
- name: Run integration tests
run: pnpm run test:integration
env:
REDIS_URL: redis://localhost:6379
services:
redis:
image: redis:7-alpine
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 6379:6379
e2e-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Build packages
run: pnpm run build
- name: Run E2E tests
run: pnpm run test:e2e
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
retention-days: 30
// packages/kyo-core/src/__tests__/factories.ts
export const createOtpSendRequest = (overrides = {}) => ({
phone: '+886987654321',
templateId: 1,
...overrides
});
export const createOtpVerifyRequest = (overrides = {}) => ({
phone: '+886987654321',
otp: '123456',
...overrides
});
export const createTemplate = (overrides = {}) => ({
id: 1,
name: 'test-template',
content: '您的驗證碼:{code}',
isActive: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides
});
// packages/kyo-core/src/__tests__/helpers.ts
import { createRedis } from '../redis.js';
export async function setupTestRedis() {
const redis = createRedis();
await redis.flushall();
return redis;
}
export async function cleanupTestRedis(redis: ReturnType<typeof createRedis>) {
await redis.flushall();
await redis.quit();
}
export function delay(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// coverage.config.js
export default {
include: ['src/**/*.ts'],
exclude: [
'src/**/*.test.ts',
'src/**/*.spec.ts',
'src/**/__tests__/**',
'src/**/types.ts'
],
reporter: ['text', 'html', 'lcov'],
reportsDirectory: './coverage'
};
我們建立了完整的測試架構:
✅ 多層測試策略:單元測試、整合測試、API 測試、E2E 測試
✅ 現代測試工具:Node.js 內建 test runner + Playwright
✅ CI/CD 整合:GitHub Actions 自動化測試
✅ 測試覆蓋率:全面的程式覆蓋率報告
✅ 測試工廠:可重用的測試資料生成
✅ 測試輔助工具:簡化測試設定和清理