iT邦幫忙

2025 iThome 鐵人賽

DAY 22
0
Software Development

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

Day 22: 30天打造SaaS產品後端篇-整合測試與 E2E 測試

  • 分享至 

  • xImage
  •  

前情提要

在 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 發送流程
 *    - 多租戶切換
 *    - 錯誤處理與重試
 *    - 使用完整的應用程式堆疊
 */

整合測試實作

1. 資料庫整合測試

首先建立資料庫測試的基礎設施:

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

2. Redis 整合測試

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

3. API 路由整合測試

// 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 測試實作

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

今日總結

我們今天實作了完整的測試金字塔:

核心成就

  1. 整合測試: 資料庫、Redis、API 路由的整合測試
  2. E2E 測試: 完整的用戶流程測試
  3. 測試策略: 60% 單元、30% 整合、10% E2E
  4. 真實場景: 多租戶隔離、Rate Limiting、OTP 過期

技術亮點

  • Node.js 內建測試器: 無需額外依賴
  • 真實依賴: 使用真實的 PostgreSQL 和 Redis
  • 完整流程: 從發送 OTP 到 JWT 認證
  • 錯誤場景: 測試各種失敗情況

測試覆蓋率目標

  • 核心服務: 90%+
  • API 路由: 85%+
  • 工具函數: 80%+
  • 整體專案: 80%+

上一篇
Day 21: 30天打造SaaS產品後端篇 - 測試框架建立與單元測試策略
下一篇
Day 23: 30天打造SaaS產品後端篇-測試與 CI/CD 深度整合
系列文
30 天打造工作室 SaaS 產品 (後端篇)25
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言