本文 AI 產出,尚未審核

ExpressJS (TypeScript) – 實戰專案:完整 API 服務

主題:加入測試與 Swagger


簡介

在完成一個 RESTful API 後,最常被忽略的兩件事是 自動化測試文件化
測試能確保程式在未來的修改中不會意外破壞既有功能,而 Swagger(OpenAPI)則讓前端、測試人員,甚至是第三方開發者,能即時看到 API 的介面規格,減少溝通成本。

本篇文章將以 ExpressJS + TypeScript 為基礎,示範如何在已完成的專案中快速導入 Jest + SuperTest 進行單元/整合測試,並使用 swagger-jsdocswagger-ui-express 產生可互動的 API 文件。文章內容適合剛接觸測試的初學者,也能讓已有經驗的中階開發者快速落地實務需求。


核心概念

1️⃣ 為什麼要在 TypeScript 專案中加入測試?

  • 型別安全 + 行為驗證:TypeScript 能防止型別錯誤,但不保證程式邏輯正確;測試補足這一層。
  • CI/CD 整合:在 GitHub Actions、GitLab CI 等平台上跑測試,可在合併前即發現回歸。
  • 文件即測試:測試程式碼本身也能成為 API 使用方式的範例,提升可讀性。

2️⃣ Jest 與 SuperTest 的角色

套件 功能 為何選擇
Jest 測試執行器、斷言庫、Mock 支援 官方支援 TypeScript、設定簡單、執行速度快
SuperTest 對 Express 伺服器發送 HTTP 請求 直接對 app 實例測試,不需要實際啟動 server

3️⃣ Swagger(OpenAPI)概念

  • swagger-jsdoc:從程式碼中的 JSDoc 註解產生 OpenAPI JSON。
  • swagger-ui-express:將產生的 JSON 以互動 UI 呈現在 /api-docs 路徑。
  • 好處:自動同步文件與程式碼、支援 API 測試、可直接產生 client SDK。

程式碼範例

以下範例均以 TypeScript 撰寫,檔案結構簡化如下:

src/
 ├─ app.ts
 ├─ routes/
 │   └─ user.route.ts
 ├─ controllers/
 │   └─ user.controller.ts
 ├─ docs/
 │   └─ swagger.ts
 └─ tests/
     └─ user.route.test.ts

1️⃣ 基本 Express 設定(app.ts)

// src/app.ts
import express, { Application } from 'express';
import bodyParser from 'body-parser';
import userRouter from './routes/user.route';
import swaggerSetup from './docs/swagger';

const app: Application = express();

app.use(bodyParser.json());

// ★ 1️⃣ 先掛載路由
app.use('/api/users', userRouter);

// ★ 2️⃣ 再掛載 Swagger UI
swaggerSetup(app);

export default app;

說明:先把 API 路由掛載,最後再呼叫 swaggerSetup,確保所有路由的 JSDoc 註解都已被讀取。


2️⃣ 使用 JSDoc 撰寫 Swagger 註解(user.controller.ts)

// src/controllers/user.controller.ts
import { Request, Response } from 'express';

/**
 * @swagger
 * /api/users:
 *   get:
 *     summary: 取得使用者列表
 *     tags:
 *       - Users
 *     responses:
 *       200:
 *         description: 成功回傳使用者陣列
 *         content:
 *           application/json:
 *             schema:
 *               type: array
 *               items:
 *                 $ref: '#/components/schemas/User'
 */
export const getAllUsers = async (_req: Request, res: Response) => {
  // 假資料,實務上會從 DB 讀取
  const users = [
    { id: 1, name: 'Alice', email: 'alice@example.com' },
    { id: 2, name: 'Bob', email: 'bob@example.com' },
  ];
  res.json(users);
};

/**
 * @swagger
 * /api/users/{id}:
 *   get:
 *     summary: 取得單一使用者
 *     tags:
 *       - Users
 *     parameters:
 *       - in: path
 *         name: id
 *         required: true
 *         schema:
 *           type: integer
 *         description: 使用者 ID
 *     responses:
 *       200:
 *         description: 單一使用者資料
 *       404:
 *         description: 找不到使用者
 */
export const getUserById = async (req: Request, res: Response) => {
  const id = Number(req.params.id);
  if (id === 1) {
    res.json({ id: 1, name: 'Alice', email: 'alice@example.com' });
  } else {
    res.status(404).json({ message: 'User not found' });
  }
};

重點@swagger 註解直接寫在 controller 上,swagger-jsdoc 會在執行 swaggerSetup 時自動抽取。


3️⃣ 建立路由(user.route.ts)

// src/routes/user.route.ts
import { Router } from 'express';
import { getAllUsers, getUserById } from '../controllers/user.controller';

const router = Router();

router.get('/', getAllUsers);
router.get('/:id', getUserById);

export default router;

4️⃣ Swagger 設定(swagger.ts)

// src/docs/swagger.ts
import { Application } from 'express';
import swaggerJsdoc from 'swagger-jsdoc';
import swaggerUi from 'swagger-ui-express';

/**
 * 初始化 Swagger 並掛載到 Express
 */
export default function swaggerSetup(app: Application) {
  const options = {
    definition: {
      openapi: '3.0.0',
      info: {
        title: 'ExpressJS TypeScript API',
        version: '1.0.0',
        description: '範例 API,示範如何結合 Swagger 與 Jest 測試',
      },
      servers: [
        {
          url: 'http://localhost:3000',
        },
      ],
      components: {
        schemas: {
          User: {
            type: 'object',
            properties: {
              id: { type: 'integer', example: 1 },
              name: { type: 'string', example: 'Alice' },
              email: { type: 'string', example: 'alice@example.com' },
            },
          },
        },
      },
    },
    // 讀取所有含 @swagger 註解的檔案
    apis: ['./src/**/*.ts'],
  };

  const swaggerSpec = swaggerJsdoc(options);
  // 以 /api-docs 為路徑提供 UI
  app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec));
}

技巧apis 欄位使用 glob pattern,一次就能抓取所有路由與 controller 中的註解,免除手動維護。


5️⃣ Jest + SuperTest 測試(user.route.test.ts)

// src/tests/user.route.test.ts
import request from 'supertest';
import app from '../app';

describe('User API 測試', () => {
  /** 測試 GET /api/users */
  it('GET /api/users 應回傳使用者陣列', async () => {
    const res = await request(app).get('/api/users').expect(200);
    expect(Array.isArray(res.body)).toBe(true);
    expect(res.body).toHaveLength(2);
    expect(res.body[0]).toHaveProperty('id');
  });

  /** 測試 GET /api/users/:id 成功情況 */
  it('GET /api/users/1 應回傳單一使用者', async () => {
    const res = await request(app).get('/api/users/1').expect(200);
    expect(res.body).toMatchObject({
      id: 1,
      name: 'Alice',
    });
  });

  /** 測試 GET /api/users/:id 錯誤情況 */
  it('GET /api/users/999 應回傳 404', async () => {
    const res = await request(app).get('/api/users/999').expect(404);
    expect(res.body).toHaveProperty('message', 'User not found');
  });
});

說明

  • request(app) 直接對 express 實例 發送請求,無需啟動實體伺服器。
  • 使用 expect 斷言回傳狀態碼與回傳結構,確保 API 行為符合預期。

6️⃣ Jest 設定(jest.config.ts)

// jest.config.ts
export default {
  preset: 'ts-jest',
  testEnvironment: 'node',
  testMatch: ['**/tests/**/*.test.ts'],
  // 顯示測試覆蓋率,實務上建議至少 80%
  collectCoverage: true,
  coverageDirectory: 'coverage',
};

小提醒ts-jest 允許直接以 TypeScript 撰寫測試檔,省去編譯步驟。


常見陷阱與最佳實踐

陷阱 為什麼會發生 解決方式
測試跑不出supertest 找不到路由 沒有把 app 正確匯出,或在測試前未載入中間件 確保 app.ts export default app,且測試檔 import app
Swagger 文件缺少端點 apis 路徑寫錯、或 JSDoc 註解語法不完整 使用相對於根目錄的 glob,並檢查 @swagger 標籤是否正確閉合
測試環境與正式環境不一致 測試時直接讀取 .env,但 CI 中缺少變數 使用 dotenv 並在 jest.setup.ts 中先載入,或在 CI 中明確設定環境變數
測試時間過長 每個測試都啟動完整的資料庫連線 針對單元測試使用 mock,整合測試才連接真實 DB;或使用 in‑memory DB(如 SQLite)
Swagger UI 無法載入 JSON swagger-jsdoc 產生的 JSON 有語法錯誤 先在本機執行 node -e "console.log(require('./dist/docs/swagger'))" 檢查產出,或使用線上 JSON Linter

最佳實踐

  1. 測試先行(Test‑First):在寫 controller 前先寫測試,能保證需求落實。
  2. 保持 Swagger 註解與程式碼同步:可在 PR 檢查清單加入「Swagger 註解是否更新」的步驟。
  3. CI 中執行測試與產生文件:在 GitHub Actions 中加入 npm run test && npm run swagger:build,確保每次合併都有最新的 API 文件。
  4. 使用自動化腳本檢查 coverage:設定 coverage 門檻(如 85%),失敗時阻止合併。
  5. 分層測試:單元測試驗證純函式邏輯、服務層;整合測試驗證路由與中間件;E2E 測試驗證完整工作流程(可使用 Cypress 或 Playwright)。

實際應用場景

場景 為什麼需要測試 + Swagger
微服務間的 API 合約 服務 A 依賴服務 B 的介面;Swagger 為合約文件,測試保證合約不被破壞。
前端團隊同時開發 前端可直接在 Swagger UI 上測試 API,測試失敗時迅速回報,提升協作效率。
持續交付(CI/CD) 每次部署前自動跑測試,確保不會因程式碼變更導致 500 錯誤;同時自動部署最新的 API 文件至內部文件站。
第三方合作夥伴 合作夥伴只需要 Swagger URL,即可生成 SDK,測試腳本亦可作為範例。
安全與合規 有些產業要求所有 API 必須有完整文件且通過測試,才能通過審核。

總結

  • 測試 為 API 生命週期的防護盾,使用 Jest + SuperTest 能在 TypeScript 專案中快速寫出可靠的單元與整合測試。
  • Swagger 為 API 的「說明書」與「契約」,透過 swagger-jsdoc 直接從程式碼產生,配合 swagger-ui-express 提供即時、互動的文件介面。
  • 兩者結合不僅提升開發效率,也讓團隊在 CI/CD跨部門協作合規審核 等重要環節中更具信心。

實務建議:在專案的 第一個迭代 就導入測試與 Swagger,避免等到系統規模變大後再回頭補救。只要把「測試」與「文件」視為開發的必備步驟,就能讓 ExpressJS + TypeScript 的 API 服務保持 高可用、易維護、易擴充。祝開發順利!