iT邦幫忙

2025 iThome 鐵人賽

DAY 21
0
Software Development

30 天打造工作室 SaaS 產品 (後端篇)系列 第 21

Day 21: 30天打造SaaS產品後端篇 - 測試框架建立與單元測試策略

  • 分享至 

  • xImage
  •  

前情提要

在 Day 20 完成架構盤點後,我們發現目前的測試覆蓋還不夠完善。今天我們將建立完整的測試框架,使用 Node.js 內建測試執行器搭配 TypeScript,為 Kyo-System 後端服務建立可靠的測試基礎。

為什麼選擇 Node.js 內建測試執行器?

從 Node.js 18 開始,官方提供了內建的測試執行器(node:test),相比傳統的 Jest 或 Mocha,它有以下優勢:

優勢分析

// 1. 零依賴 - 無需額外安裝測試框架
// 2. 原生支援 - 與 Node.js 緊密整合
// 3. ESM 友善 - 完美支援 ES Modules
// 4. TypeScript 相容 - 搭配 tsx 或 ts-node 即可
// 5. 快速執行 - 啟動速度比 Jest 快 3-5 倍

與其他框架對比

特性 Node.js Test Jest Vitest
安裝大小 0 KB (內建) ~30 MB ~15 MB
啟動時間 < 100ms ~2-3s ~500ms
ESM 支援 ✅ 原生 ⚠️ 需配置 ✅ 原生
TypeScript 搭配 ts-node 需 ts-jest 內建支援
Watch 模式 --watch

測試環境配置

Package.json 設定

// apps/kyo-otp-service/package.json
{
  "name": "kyo-otp-service",
  "type": "module",
  "scripts": {
    "test": "node --test",
    "test:watch": "node --test --watch",
    "test:coverage": "node --test --experimental-test-coverage",
    "pretest": "tsc -p tsconfig.json"
  },
  "devDependencies": {
    "@types/node": "^20.0.0",
    "typescript": "^5.5.0"
  }
}

TypeScript 配置

// tsconfig.json
{
  "compilerOptions": {
    "module": "ESNext",
    "target": "ES2022",
    "moduleResolution": "node",
    "esModuleInterop": true,
    "strict": true,
    "skipLibCheck": true,
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src/**/*", "test/**/*"],
  "exclude": ["node_modules", "dist"]
}

測試檔案結構

apps/kyo-otp-service/
├── src/
│   ├── app.ts
│   ├── routes/
│   └── middleware/
│
├── test/
│   ├── setup.ts                    # 測試環境設定
│   ├── helpers.ts                  # 測試輔助函數
│   │
│   ├── unit/                       # 單元測試
│   │   ├── services/
│   │   │   ├── otp.test.ts
│   │   │   └── sms.test.ts
│   │   └── utils/
│   │       └── validation.test.ts
│   │
│   ├── integration/                # 整合測試
│   │   ├── api/
│   │   │   ├── otp-send.test.ts
│   │   │   └── auth.test.ts
│   │   └── database/
│   │       └── tenant.test.ts
│   │
│   └── e2e/                        # 端對端測試
│       └── otp-flow.test.ts
│
└── package.json

基礎測試設定

測試環境設定檔

// test/setup.ts
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';

// 設定測試環境變數
process.env.NODE_ENV = 'test';
process.env.PORT = '0'; // 使用隨機 port
process.env.REDIS_URL = process.env.REDIS_TEST_URL || 'redis://localhost:6379/15';

// 全域清理函數
export const globalSetup = async () => {
  console.log('🧪 Setting up test environment...');
  // 可在這裡初始化測試資料庫連線等
};

export const globalTeardown = async () => {
  console.log('🧹 Cleaning up test environment...');
  // 清理測試資料
};

// 導出常用的測試工具
export { test, describe, before, after };
export { strict as assert } from 'node:assert';

測試輔助函數

// test/helpers.ts
import { buildApp } from '../src/app.js';
import type { FastifyInstance } from 'fastify';

/**
 * 建立測試用 Fastify 實例
 */
export async function createTestApp(): Promise<FastifyInstance> {
  const app = await buildApp();
  await app.ready();
  return app;
}

/**
 * 產生測試用 JWT Token
 */
export function generateTestToken(payload: {
  userId: string;
  gymId: string;
  email: string;
}): string {
  // 使用實際的 JWT 服務
  const { createToken } = await import('@kyong/kyo-core/auth/auth-service.js');
  return createToken(payload);
}

/**
 * 清理 Redis 測試資料
 */
export async function cleanupRedis() {
  const { getRedisClient } = await import('@kyong/kyo-core/redis.js');
  const redis = getRedisClient();
  const keys = await redis.keys('test:*');
  if (keys.length > 0) {
    await redis.del(...keys);
  }
}

/**
 * 產生隨機測試資料
 */
export const fixtures = {
  phone: () => `09${Math.random().toString().slice(2, 10)}`,
  email: () => `test-${Date.now()}@example.com`,
  gymId: () => `gym-test-${Date.now()}`,
  otpCode: () => Math.floor(100000 + Math.random() * 900000).toString(),
};

單元測試實作

OTP 服務單元測試

// test/unit/services/otp.test.ts
import { describe, test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { createOtpServiceFromEnv } from '@kyong/kyo-core';
import { cleanupRedis, fixtures } from '../../helpers.js';

describe('OTP Service Unit Tests', () => {
  const otpService = createOtpServiceFromEnv();

  before(async () => {
    await cleanupRedis();
  });

  after(async () => {
    await cleanupRedis();
  });

  test('應該成功發送 OTP', async () => {
    const phone = fixtures.phone();

    const result = await otpService.send({
      phone,
      templateId: 1,
    });

    assert.ok(result.success, 'OTP 發送應該成功');
    assert.ok(result.msgId, '應該返回訊息 ID');
    assert.equal(typeof result.msgId, 'string');
  });

  test('應該拒絕無效的手機號碼', async () => {
    await assert.rejects(
      async () => {
        await otpService.send({
          phone: '123',  // 無效號碼
          templateId: 1,
        });
      },
      {
        name: 'KyoError',
        message: /Invalid phone number/,
      },
      '應該拋出無效手機號碼錯誤'
    );
  });

  test('應該驗證正確的 OTP 碼', async () => {
    const phone = fixtures.phone();

    // 先發送 OTP
    await otpService.send({ phone, templateId: 1 });

    // 從 Redis 獲取實際的 OTP 碼(測試環境)
    const { getRedisClient } = await import('@kyong/kyo-core/redis.js');
    const redis = getRedisClient();
    const code = await redis.get(`otp:${phone}`);

    assert.ok(code, '應該能從 Redis 獲取 OTP 碼');

    // 驗證
    const result = await otpService.verify({ phone, code: code! });

    assert.ok(result.valid, 'OTP 驗證應該成功');
  });

  test('應該拒絕錯誤的 OTP 碼', async () => {
    const phone = fixtures.phone();

    const result = await otpService.verify({
      phone,
      code: '000000',  // 錯誤的碼
    });

    assert.equal(result.valid, false, '錯誤的 OTP 應該驗證失敗');
    assert.equal(result.reason, 'invalid_code');
  });

  test('應該在 OTP 過期後拒絕驗證', async (t) => {
    // 使用 mock timer 控制時間
    const clock = t.mock.timers.enable({ apis: ['Date'] });

    const phone = fixtures.phone();
    await otpService.send({ phone, templateId: 1 });

    // 快轉 6 分鐘(OTP 有效期 5 分鐘)
    clock.tick(6 * 60 * 1000);

    const { getRedisClient } = await import('@kyong/kyo-core/redis.js');
    const redis = getRedisClient();
    const code = await redis.get(`otp:${phone}`);

    const result = await otpService.verify({ phone, code: code || '123456' });

    assert.equal(result.valid, false);
    assert.equal(result.reason, 'expired');
  });
});

Zod Schema 驗證測試

// test/unit/schemas/validation.test.ts
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import { OtpSendSchema, OtpVerifySchema } from '@kyong/kyo-types/schemas.js';

describe('Zod Schema Validation Tests', () => {
  describe('OtpSendSchema', () => {
    test('應該接受有效的 OTP 發送請求', () => {
      const validData = {
        phone: '0987654321',
        templateId: 1,
      };

      const result = OtpSendSchema.safeParse(validData);

      assert.ok(result.success, '有效資料應該通過驗證');
      assert.deepEqual(result.data, validData);
    });

    test('應該拒絕無效的手機號碼格式', () => {
      const invalidData = {
        phone: '123',  // 太短
        templateId: 1,
      };

      const result = OtpSendSchema.safeParse(invalidData);

      assert.equal(result.success, false, '應該驗證失敗');
      if (!result.success) {
        assert.ok(result.error.issues.some(i => i.path[0] === 'phone'));
      }
    });

    test('應該接受選填的 templateId', () => {
      const dataWithoutTemplate = {
        phone: '0987654321',
      };

      const result = OtpSendSchema.safeParse(dataWithoutTemplate);

      assert.ok(result.success, 'templateId 是選填的');
    });
  });

  describe('OtpVerifySchema', () => {
    test('應該驗證 6 位數字的 OTP 碼', () => {
      const validData = {
        phone: '0987654321',
        code: '123456',
      };

      const result = OtpVerifySchema.safeParse(validData);

      assert.ok(result.success);
    });

    test('應該拒絕非 6 位數的 OTP 碼', () => {
      const invalidData = {
        phone: '0987654321',
        code: '12345',  // 只有 5 位
      };

      const result = OtpVerifySchema.safeParse(invalidData);

      assert.equal(result.success, false);
    });
  });
});

限流邏輯測試

// test/unit/services/rate-limiter.test.ts
import { describe, test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { RateLimiter } from '@kyong/kyo-core/rateLimiter.js';
import { cleanupRedis } from '../../helpers.js';

describe('Rate Limiter Unit Tests', () => {
  const limiter = new RateLimiter({
    points: 5,           // 5 次
    duration: 60,        // 每 60 秒
    keyPrefix: 'test:rl:',
  });

  before(async () => {
    await cleanupRedis();
  });

  after(async () => {
    await cleanupRedis();
  });

  test('應該允許在限制內的請求', async () => {
    const key = 'user:123';

    for (let i = 0; i < 5; i++) {
      const result = await limiter.consume(key);
      assert.ok(result, `第 ${i + 1} 次請求應該被允許`);
    }
  });

  test('應該阻擋超過限制的請求', async () => {
    const key = 'user:456';

    // 消耗掉所有配額
    for (let i = 0; i < 5; i++) {
      await limiter.consume(key);
    }

    // 第 6 次應該被拒絕
    await assert.rejects(
      async () => {
        await limiter.consume(key);
      },
      {
        name: 'Error',
        message: /Rate limit exceeded/,
      }
    );
  });

  test('應該在時間窗口後重置', async (t) => {
    const clock = t.mock.timers.enable({ apis: ['Date'] });
    const key = 'user:789';

    // 消耗配額
    for (let i = 0; i < 5; i++) {
      await limiter.consume(key);
    }

    // 快轉 61 秒
    clock.tick(61 * 1000);

    // 應該可以再次使用
    const result = await limiter.consume(key);
    assert.ok(result, '時間窗口後應該重置配額');
  });
});

整合測試實作

API 端點測試

// test/integration/api/otp-send.test.ts
import { describe, test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { createTestApp, cleanupRedis, fixtures, generateTestToken } from '../../helpers.js';

describe('OTP Send API Integration Tests', () => {
  let app: Awaited<ReturnType<typeof createTestApp>>;
  let authToken: string;

  before(async () => {
    app = await createTestApp();
    authToken = generateTestToken({
      userId: 'test-user-1',
      gymId: fixtures.gymId(),
      email: fixtures.email(),
    });
    await cleanupRedis();
  });

  after(async () => {
    await app.close();
    await cleanupRedis();
  });

  test('POST /api/otp/send - 應該成功發送 OTP', async () => {
    const response = await app.inject({
      method: 'POST',
      url: '/api/otp/send',
      headers: {
        authorization: `Bearer ${authToken}`,
      },
      payload: {
        phone: fixtures.phone(),
        templateId: 1,
      },
    });

    assert.equal(response.statusCode, 202, '應該返回 202 Accepted');

    const body = JSON.parse(response.body);
    assert.ok(body.success);
    assert.ok(body.msgId);
  });

  test('POST /api/otp/send - 應該拒絕未認證的請求', async () => {
    const response = await app.inject({
      method: 'POST',
      url: '/api/otp/send',
      payload: {
        phone: fixtures.phone(),
      },
    });

    assert.equal(response.statusCode, 401, '應該返回 401 Unauthorized');
  });

  test('POST /api/otp/send - 應該驗證請求格式', async () => {
    const response = await app.inject({
      method: 'POST',
      url: '/api/otp/send',
      headers: {
        authorization: `Bearer ${authToken}`,
      },
      payload: {
        phone: '123',  // 無效格式
      },
    });

    assert.equal(response.statusCode, 400, '應該返回 400 Bad Request');

    const body = JSON.parse(response.body);
    assert.ok(body.error);
  });

  test('POST /api/otp/send - 應該執行速率限制', async () => {
    const phone = fixtures.phone();

    // 快速發送多次請求
    const requests = Array(6).fill(null).map(() =>
      app.inject({
        method: 'POST',
        url: '/api/otp/send',
        headers: {
          authorization: `Bearer ${authToken}`,
        },
        payload: { phone },
      })
    );

    const responses = await Promise.all(requests);

    // 應該有請求被限流
    const rateLimited = responses.some(r => r.statusCode === 429);
    assert.ok(rateLimited, '應該觸發速率限制');
  });
});

完整 OTP 流程測試

// test/e2e/otp-flow.test.ts
import { describe, test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { createTestApp, cleanupRedis, fixtures, generateTestToken } from '../helpers.js';

describe('OTP End-to-End Flow', () => {
  let app: Awaited<ReturnType<typeof createTestApp>>;
  let authToken: string;

  before(async () => {
    app = await createTestApp();
    authToken = generateTestToken({
      userId: 'e2e-user',
      gymId: 'e2e-gym',
      email: 'e2e@example.com',
    });
    await cleanupRedis();
  });

  after(async () => {
    await app.close();
    await cleanupRedis();
  });

  test('完整的 OTP 發送與驗證流程', async () => {
    const phone = fixtures.phone();

    // 1. 發送 OTP
    const sendResponse = await app.inject({
      method: 'POST',
      url: '/api/otp/send',
      headers: {
        authorization: `Bearer ${authToken}`,
      },
      payload: { phone },
    });

    assert.equal(sendResponse.statusCode, 202);
    const sendBody = JSON.parse(sendResponse.body);
    assert.ok(sendBody.success);

    // 2. 從 Redis 獲取 OTP 碼(測試環境)
    const { getRedisClient } = await import('@kyong/kyo-core/redis.js');
    const redis = getRedisClient();
    const code = await redis.get(`otp:${phone}`);
    assert.ok(code, '應該能從 Redis 獲取 OTP');

    // 3. 驗證 OTP
    const verifyResponse = await app.inject({
      method: 'POST',
      url: '/api/otp/verify',
      payload: {
        phone,
        code: code!,
      },
    });

    assert.equal(verifyResponse.statusCode, 200);
    const verifyBody = JSON.parse(verifyResponse.body);
    assert.ok(verifyBody.valid, 'OTP 應該驗證成功');

    // 4. 再次驗證應該失敗(一次性使用)
    const retryResponse = await app.inject({
      method: 'POST',
      url: '/api/otp/verify',
      payload: {
        phone,
        code: code!,
      },
    });

    const retryBody = JSON.parse(retryResponse.body);
    assert.equal(retryBody.valid, false, '已使用的 OTP 不應再次驗證成功');
  });
});

執行測試

基本執行

# 執行所有測試
pnpm test

# Watch 模式(開發時使用)
pnpm test:watch

# 產生覆蓋率報告
pnpm test:coverage

# 只執行特定測試檔案
node --test test/unit/services/otp.test.ts

# 執行特定目錄的測試
node --test test/integration/**/*.test.ts

測試輸出範例

$ pnpm test

✔ OTP Service Unit Tests > 應該成功發送 OTP (145.2ms)
✔ OTP Service Unit Tests > 應該拒絕無效的手機號碼 (12.4ms)
✔ OTP Service Unit Tests > 應該驗證正確的 OTP 碼 (89.3ms)
✔ OTP Service Unit Tests > 應該拒絕錯誤的 OTP 碼 (45.6ms)
✔ OTP Service Unit Tests > 應該在 OTP 過期後拒絕驗證 (23.1ms)

✔ Zod Schema Validation Tests > OtpSendSchema > 應該接受有效的 OTP 發送請求 (5.2ms)
✔ Zod Schema Validation Tests > OtpSendSchema > 應該拒絕無效的手機號碼格式 (3.8ms)

✔ Rate Limiter Unit Tests > 應該允許在限制內的請求 (67.4ms)
✔ Rate Limiter Unit Tests > 應該阻擋超過限制的請求 (34.2ms)

✔ OTP Send API Integration Tests > 應該成功發送 OTP (234.5ms)
✔ OTP Send API Integration Tests > 應該拒絕未認證的請求 (45.3ms)
✔ OTP Send API Integration Tests > 應該執行速率限制 (567.8ms)

✔ OTP End-to-End Flow > 完整的 OTP 發送與驗證流程 (423.6ms)

ℹ tests 13
ℹ suites 5
ℹ pass 13
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 1847.2

測試覆蓋率目標

// 設定覆蓋率目標
{
  "test": {
    "coverage": {
      "thresholds": {
        "lines": 80,      // 行覆蓋率 80%
        "functions": 80,  // 函數覆蓋率 80%
        "branches": 75,   // 分支覆蓋率 75%
        "statements": 80  // 語句覆蓋率 80%
      }
    }
  }
}

CI/CD 整合

GitHub Actions 配置

# .github/workflows/test.yml
name: Tests

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main, develop ]

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      redis:
        image: redis:7-alpine
        ports:
          - 6379:6379

    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v2
        with:
          version: 9

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'pnpm'

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Build packages
        run: pnpm run build

      - name: Run tests
        run: pnpm test
        env:
          REDIS_TEST_URL: redis://localhost:6379/15

      - name: Generate coverage
        run: pnpm test:coverage

      - name: Upload coverage
        uses: codecov/codecov-action@v3
        with:
          files: ./coverage/coverage-final.json

今日總結

我們建立了完整的測試框架:

完成項目

測試環境設定

  • Node.js 內建測試執行器
  • TypeScript 完整支援
  • 測試輔助函數與 fixtures

單元測試

  • OTP 服務邏輯測試
  • Zod schema 驗證測試
  • 限流邏輯測試

整合測試

  • API 端點測試
  • 認證流程測試
  • 錯誤處理測試

E2E 測試

  • 完整業務流程測試

CI/CD 整合

  • GitHub Actions 自動化測試

下一步

明天我們將繼續完善測試,並加入:

  • Mock 策略與外部服務隔離
  • 測試資料管理
  • 效能測試基礎

參考資源


上一篇
Day 20: 30天打造SaaS產品後端篇 - 第20天Fastify架構盤點與後端驗證
系列文
30 天打造工作室 SaaS 產品 (後端篇)21
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言