iT邦幫忙

2025 iThome 鐵人賽

DAY 7
0

前情提要

昨天我們建立了完整的套件打包策略,為不同類型的套件選擇了最適合的打包工具。今天我們要建立一個全面的測試策略,確保我們的 Monorepo 中每個套件都有適當的測試覆蓋率和品質保證。

為什麼測試如此重要?

在 Monorepo 架構中,測試扮演更關鍵的角色:

  • 🛡️ 防止迴歸:避免修改一個套件影響到其他套件
  • 🔒 API 契約保證:確保套件間的介面穩定
  • 📈 重構信心:放心進行大規模重構
  • 🚀 持續整合:自動化品質檢查
  • 📋 文件化:測試即是最好的使用範例

測試金字塔策略

我們採用經典的測試金字塔,針對不同層級設計測試:

    🔺 E2E Tests (少量)
   ---- Integration Tests (適量)
  ------ Unit Tests (大量)

1. 單元測試(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);
  });
});

2. 整合測試(Integration Tests)

目標:測試套件間的互動和外部服務整合

// 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');
    }
  });
});

3. API 測試

目標:測試 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);
  });
});

4. E2E 測試

目標:測試整個應用的使用者流程

// 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('驗證碼錯誤'));
  });
});

測試設定檔案

1. package.json 測試腳本

{
  "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"
  }
}

2. Playwright 設定

// 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,
    },
  ],
});

CI/CD 整合

1. GitHub Actions 工作流程

# .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

測試工具和最佳實踐

1. 測試資料工廠

// 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
});

2. 測試輔助工具

// 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 自動化測試
測試覆蓋率:全面的程式覆蓋率報告
測試工廠:可重用的測試資料生成
測試輔助工具:簡化測試設定和清理


上一篇
Day 6: Monorepo 套件打包策略實戰 - Rollup vs. tsup vs. tsc
系列文
30 天打造工作室 SaaS 產品 (後端篇)7
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言