ExpressJS (TypeScript) – Testing 自動化測試:Supertest API 測試
簡介
在 Node.js 生態系中,Express 是最常見的 Web 框架,而使用 TypeScript 開發則能提供編譯時的型別安全,減少執行時錯誤。開發完 API 後,如果沒有自動化測試,任何微小的變更都可能導致不可預期的回應,甚至破壞上線服務。
Supertest 是一套與 Superagent 配合的測試工具,專門用來對 Express (或任何符合 Connect 標準的) 應用程式發送 HTTP 請求,並以 Jest / Mocha 等測試框架斷言回應結果。透過 Supertest,我們可以在 單元測試、整合測試 甚至 CI/CD 流程中,快速驗證路由、驗證中介層、錯誤處理等行為是否如預期。
本篇文章將以 TypeScript 為基礎,示範如何使用 Supertest 針對 Express API 撰寫可讀、可維護且易於擴充的自動化測試。
核心概念
1️⃣ 設定測試環境
在測試前,我們需要先把 Express 應用程式 以可被 Supertest 直接呼叫的方式匯出,而不是在 app.listen() 內直接啟動伺服器。這樣可以避免測試時真的開啟埠口。
// src/app.ts
import express from 'express';
import userRouter from './routes/user';
const app = express();
app.use(express.json());
app.use('/api/users', userRouter);
// 只在主程式執行時才監聽
if (require.main === module) {
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Server listening on ${PORT}`));
}
export default app; // <-- 讓測試檔案可以 import
重點:
export default app讓測試檔案可以直接以request(app)的方式發送請求,避免產生多個執行緒或端口衝突。
2️⃣ 基本的 Supertest 使用方式
下面示範最簡單的 GET 請求測試,搭配 Jest 作為測試 runner。
// tests/user.test.ts
import request from 'supertest';
import app from '../src/app';
describe('GET /api/users', () => {
it('should return an empty array when no users exist', async () => {
const response = await request(app).get('/api/users').expect(200);
expect(response.body).toEqual([]);
});
});
request(app):傳入 Express 實例。.get('/api/users'):發送 GET 請求。.expect(200):直接斷言 HTTP 狀態碼。expect(response.body).toEqual([]):使用 Jest 的斷言檢查回傳資料。
3️⃣ 測試 POST、PUT、DELETE 以及驗證請求 Body
以下範例展示如何測試 建立使用者(POST)以及 更新使用者(PUT)的流程,並同時檢查驗證錯誤。
// tests/user.test.ts(續)
describe('POST /api/users', () => {
it('should create a new user and return it', async () => {
const newUser = { name: 'Alice', email: 'alice@example.com' };
const response = await request(app)
.post('/api/users')
.send(newUser) // <-- 送出 JSON body
.set('Accept', 'application/json')
.expect('Content-Type', /json/)
.expect(201); // 建立成功的 HTTP 狀態碼
expect(response.body).toMatchObject(newUser);
expect(response.body).toHaveProperty('id'); // 伺服器會自動產生 id
});
it('should reject when required fields are missing', async () => {
const incompleteUser = { name: 'Bob' }; // 缺少 email
const response = await request(app)
.post('/api/users')
.send(incompleteUser)
.expect(400);
expect(response.body).toHaveProperty('error');
expect(response.body.error).toContain('email');
});
});
技巧:
- 使用
.set('Accept', 'application/json')明確告訴伺服器我們期望 JSON 回應。 .expect('Content-Type', /json/)可以同時驗證回應的Content-Type。toMatchObject只比對部份屬性,適合檢查自動產生的id。
4️⃣ 模擬資料庫(Mock)與測試隔離
在測試環境下,我們不希望真的寫入正式資料庫。常見做法是 使用 In‑Memory DB(如 sqlite3)或 mock DAO/Repository。以下示範如何使用 jest.mock 取代原本的 UserService。
// src/services/userService.ts
export class UserService {
async create(user: { name: string; email: string }) {
// 假設此方法會寫入資料庫
// ...
return { id: 1, ...user };
}
}
// tests/user.test.ts(續)
import { UserService } from '../src/services/userService';
jest.mock('../src/services/userService'); // <-- 自動產生 mock
const mockedCreate = jest.fn().mockResolvedValue({
id: 99,
name: 'Mocked User',
email: 'mock@example.com',
});
(UserService as jest.Mock).mockImplementation(() => ({
create: mockedCreate,
}));
describe('POST /api/users with mocked service', () => {
it('should use the mocked service and return mocked data', async () => {
const payload = { name: 'Test', email: 'test@example.com' };
const res = await request(app).post('/api/users').send(payload).expect(201);
expect(mockedCreate).toHaveBeenCalledWith(payload);
expect(res.body.id).toBe(99);
});
});
透過 mock,測試只關注 API 行為,不會因為資料庫連線失敗而影響測試結果,亦能提升測試速度。
5️⃣ 結合測試前置與清理(Hooks)
使用 Jest 的 beforeAll, beforeEach, afterAll 等 hook,可以在測試執行前後做 資料初始化、關閉連線 等工作,確保每個測試案例都是 獨立 的。
// tests/user.test.ts(續)
let server: import('http').Server;
beforeAll(() => {
// 若有需要額外啟動監聽埠口,可在此處
server = app.listen(0); // 0 代表自動分配埠口
});
afterAll(async () => {
await server.close(); // 釋放資源
});
describe('DELETE /api/users/:id', () => {
it('should delete an existing user', async () => {
// 先建立一筆資料(使用 mock 或直接呼叫 service)
const created = await request(app)
.post('/api/users')
.send({ name: 'Del', email: 'del@example.com' });
const id = created.body.id;
await request(app).delete(`/api/users/${id}`).expect(204);
});
});
常見陷阱與最佳實踐
| 陷阱 | 說明 | 改善方式 |
|---|---|---|
測試時直接使用 app.listen() |
會導致埠口被佔用,測試跑不下去。 | 只在主程式 (if (require.main === module)) 呼叫 listen,測試時直接 import app。 |
| 未清理測試資料 | 前一次測試留下的資料會影響後續測試結果,產生間歇性失敗。 | 使用 beforeEach / afterEach 重置資料庫或 mock,或採用 transaction rollback。 |
忘記設定 Content-Type |
某些中介層只接受 application/json,測試會得到 415 錯誤。 |
在 request(app).post(...).set('Accept', 'application/json') 中明確設定。 |
| 直接依賴實體服務 | 連接真實 DB、外部 API 時測試慢且不穩定。 | Mock 服務或使用 in‑memory DB,只測試 API 本身的行為。 |
| 斷言過於寬鬆 | 只檢查狀態碼,錯誤訊息或資料結構變更不易被捕捉。 | 同時斷言 status, headers, body 結構(如 toMatchObject、toHaveProperty)。 |
最佳實踐:
- 保持測試獨立:每個
it應該不依賴其他測試的執行結果。 - 使用 TypeScript 型別:在測試檔案中也寫入介面 (
interface UserResponse { id: number; name: string; email: string; }) 讓編譯器幫忙檢查回傳結構。 - 結合 CI:把
npm test -- --runInBand加入 GitHub Actions,確保每一次 PR 都會跑完整測試。 - 測試覆蓋率:使用
jest --coverage觀察哪些路由尚未被測試,持續提升覆蓋率。 - 文件化測試案例:在 README 或 Confluence 中列出每個 API 的測試目的,方便新人快速了解測試範圍。
實際應用場景
微服務間契約測試
在多服務架構下,A 服務的某個 API 需要被 B 服務呼叫。利用 Supertest 在 A 服務的 CI 中跑完整的 契約測試,確保回傳格式不會因為重構而破壞 B 服務。驗證授權與中介層
透過 Supertest 可以在同一測試中加入 JWT、OAuth 的 Header,驗證未授權、過期 token、權限不足的情況。例如:request(app) .get('/api/admin') .set('Authorization', `Bearer ${invalidToken}`) .expect(401);端到端(E2E)測試
雖然 Supertest 仍屬於 API 層 測試,但結合 Puppeteer 或 Playwright,可在同一 CI pipeline 中先跑 API 測試,確保後端正常後再執行前端 UI 測試,縮短除錯時間。資料遷移與版本升級
當 API 需要升級至新版本(v1 → v2)時,先寫好 舊版與新版的測試,確保新實作仍能兼容舊有客戶端,降低上線風險。
總結
- Supertest 為 Express(含 TypeScript)提供了簡潔且功能完整的 HTTP 測試介面,讓我們能在 單元測試 與 整合測試 階段即時驗證 API 行為。
- 透過 app 匯出、Jest hooks、mock 服務 與 型別斷言,可以建立 快速、可靠且易於維護 的測試基礎。
- 注意避免端口衝突、資料污染與過度依賴實體服務,遵循 測試獨立、斷言完整、CI 整合 的最佳實踐,才能在實務專案中真正發揮自動化測試的價值。
掌握上述概念與技巧後,你的 Express + TypeScript 專案將能在每一次提交、每一次部署時,都自動得到 安全感 與 品質保證。祝你測試順利,開發愉快! 🚀