ExpressJS (TypeScript) – Testing 自動化測試:Mock 資料庫與 Service
簡介
在 Node.js 生態系中,Express 搭配 TypeScript 已成為建構 RESTful API 的主流組合。開發完畢後,若沒有可靠的自動化測試,程式碼的品質與可維護性將難以保證。尤其是涉及資料庫存取或外部 Service 時,直接對真的 DB 或 Service 進行測試會帶來 執行速度慢、測試不穩定、環境難以重現 等問題。
使用 Mock(模擬)技術,我們可以在單元測試中 替代真實的資料庫與 Service,只驗證程式邏輯本身。本文將一步步說明怎麼在 Express + TypeScript 專案裡,透過 Jest 與 ts-mockito(或 Sinon)建立可測試的 Mock,讓測試既快又可靠。
核心概念
1. 為什麼要 Mock 資料庫?
- 速度:使用記憶體中的假資料比往返真實 DB 快數十倍。
- 隔離:測試只關注服務層(Service)或控制器(Controller)的行為,不被資料庫的狀態干擾。
- 可預測:可以預先定義回傳結果,保證每次測試的輸入輸出一致。
2. Service 與 Repository 的抽象
在 TypeScript 專案中,常見的架構是:
Controller <---> Service <---> Repository (DB)
- Controller 處理 HTTP 請求/回應。
- Service 包含業務邏輯,呼叫 Repository。
- Repository 直接與資料庫互動(如 TypeORM、Prisma)。
只要把 Repository 與 外部 Service 抽象成 介面(interface),就能在測試時注入 Mock 實例。
3. Jest + ts-mockito 基本使用
- Jest 提供測試執行與斷言。
- ts-mockito 讓我們以 TypeScript 型別安全的方式建立 Mock,支援 when / thenReturn、verify 等語法。
程式碼範例
以下示範一個簡易的 User 模組,包含 UserController、UserService、UserRepository,以及對應的測試與 Mock 實作。
3.1 建立介面與實作
// src/repositories/IUserRepository.ts
export interface IUserRepository {
findById(id: string): Promise<User | null>;
create(user: NewUser): Promise<User>;
}
// src/services/IUserService.ts
export interface IUserService {
getUser(id: string): Promise<User>;
register(user: NewUser): Promise<User>;
}
// src/repositories/UserRepository.ts
import { IUserRepository } from './IUserRepository';
import { getRepository } from 'typeorm';
import { User } from '../entities/User';
export class UserRepository implements IUserRepository {
async findById(id: string): Promise<User | null> {
return await getRepository(User).findOne(id) ?? null;
}
async create(user: NewUser): Promise<User> {
return await getRepository(User).save(user);
}
}
3.2 Service 實作(依賴注入)
// src/services/UserService.ts
import { IUserRepository } from '../repositories/IUserRepository';
import { IUserService } from './IUserService';
import { User, NewUser } from '../entities/User';
export class UserService implements IUserService {
constructor(private readonly repo: IUserRepository) {}
async getUser(id: string): Promise<User> {
const user = await this.repo.findById(id);
if (!user) throw new Error('User not found');
return user;
}
async register(user: NewUser): Promise<User> {
// 這裡可以加入密碼雜湊、驗證等業務邏輯
return await this.repo.create(user);
}
}
3.3 Controller
// src/controllers/UserController.ts
import { Request, Response } from 'express';
import { IUserService } from '../services/IUserService';
export class UserController {
constructor(private readonly service: IUserService) {}
async get(req: Request, res: Response) {
try {
const user = await this.service.getUser(req.params.id);
res.json(user);
} catch (e) {
res.status(404).json({ message: e.message });
}
}
async post(req: Request, res: Response) {
const user = await this.service.register(req.body);
res.status(201).json(user);
}
}
3.4 測試:Mock Repository
// tests/UserService.test.ts
import { mock, instance, when, verify, anything } from 'ts-mockito';
import { UserService } from '../src/services/UserService';
import { IUserRepository } from '../src/repositories/IUserRepository';
import { User } from '../src/entities/User';
describe('UserService', () => {
const repoMock = mock<IUserRepository>();
const service = new UserService(instance(repoMock));
it('should return a user when findById succeeds', async () => {
const fakeUser: User = { id: '1', name: 'Alice', email: 'a@example.com' };
// 設定 mock 行為
when(repoMock.findById('1')).thenResolve(fakeUser);
const result = await service.getUser('1');
expect(result).toEqual(fakeUser);
// 驗證 repo 被正確呼叫一次
verify(repoMock.findById('1')).once();
});
it('should throw error when user not found', async () => {
when(repoMock.findById('2')).thenResolve(null);
await expect(service.getUser('2')).rejects.toThrow('User not found');
verify(repoMock.findById('2')).once();
});
});
3.5 測試:Mock 外部 Service(如 Email)
// src/services/EmailService.ts
export interface IEmailService {
sendWelcome(to: string): Promise<void>;
}
// src/services/UserService.ts(加入 Email 依賴)
import { IEmailService } from './EmailService';
export class UserService implements IUserService {
constructor(
private readonly repo: IUserRepository,
private readonly mailer: IEmailService,
) {}
async register(user: NewUser): Promise<User> {
const created = await this.repo.create(user);
await this.mailer.sendWelcome(created.email);
return created;
}
}
// tests/UserService.email.test.ts
import { mock, instance, when, verify } from 'ts-mockito';
import { UserService } from '../src/services/UserService';
import { IUserRepository } from '../src/repositories/IUserRepository';
import { IEmailService } from '../src/services/EmailService';
import { NewUser, User } from '../src/entities/User';
describe('UserService - email flow', () => {
const repoMock = mock<IUserRepository>();
const mailerMock = mock<IEmailService>();
const service = new UserService(instance(repoMock), instance(mailerMock));
it('should send welcome email after registration', async () => {
const newUser: NewUser = { name: 'Bob', email: 'b@example.com' };
const savedUser: User = { id: '3', ...newUser };
when(repoMock.create(newUser)).thenResolve(savedUser);
when(mailerMock.sendWelcome(savedUser.email)).thenResolve();
const result = await service.register(newUser);
expect(result).toEqual(savedUser);
verify(repoMock.create(newUser)).once();
verify(mailerMock.sendWelcome(savedUser.email)).once();
});
});
3.6 測試 Controller(使用 SuperTest + Mock Service)
// tests/UserController.test.ts
import request from 'supertest';
import express from 'express';
import { mock, instance, when } from 'ts-mockito';
import { UserController } from '../src/controllers/UserController';
import { IUserService } from '../src/services/IUserService';
import { User } from '../src/entities/User';
const app = express();
app.use(express.json());
describe('UserController', () => {
const serviceMock = mock<IUserService>();
const controller = new UserController(instance(serviceMock));
// 設定路由
app.get('/users/:id', (req, res) => controller.get(req, res));
app.post('/users', (req, res) => controller.post(req, res));
it('GET /users/:id should return user JSON', async () => {
const fake: User = { id: '10', name: 'Carol', email: 'c@example.com' };
when(serviceMock.getUser('10')).thenResolve(fake);
await request(app)
.get('/users/10')
.expect(200)
.expect('Content-Type', /json/)
.expect(res => {
expect(res.body).toEqual(fake);
});
});
it('POST /users should create user', async () => {
const payload = { name: 'Dave', email: 'd@example.com' };
const created: User = { id: '11', ...payload };
when(serviceMock.register(payload)).thenResolve(created);
await request(app)
.post('/users')
.send(payload)
.expect(201)
.expect(res => {
expect(res.body).toEqual(created);
});
});
});
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方式 |
|---|---|---|
| 忘記在測試前還原 Mock | Jest 會在同一測試檔中保留上一次的呼叫紀錄,導致驗證錯誤。 | 使用 afterEach(() => resetCalls(mock)) 或 jest.clearAllMocks()。 |
| Mock 了錯誤的層級 | 直接 Mock express 內部的 req/res 物件,會讓測試變得難以維護。 |
只 Mock Service,Controller 仍使用真實的 req/res(如 SuperTest)。 |
| 型別不對 | 用 any 直接寫 Mock,失去 TypeScript 的安全性。 |
使用 ts-mockito、jest-mock-extended 等工具,保留介面型別。 |
| 測試依賴真實 DB | 有時會不小心在測試中呼叫 getRepository,導致連到本機 DB。 |
把 getRepository 包在 Repository 類別內,測試只注入 Mock。 |
| 測試過於依賴實作細節 | 當 Service 實作改變(例如改用 Prisma),測試全部失效。 | 只測試 行為(如 create 被呼叫一次),不檢查具體實作。 |
最佳實踐
- 介面導向設計:所有外部依賴(DB、第三方 API)都抽成介面,讓測試只需要注入 Mock。
- 單元測試只測一件事:Service 測試只關心業務邏輯,Controller 測試只關心路由與回傳。
- 使用 In‑Memory DB 作整合測試:當需要驗證 ORM 行為時,可使用 SQLite in‑memory,保持速度同時保留真實 SQL 執行。
- 把 Mock 設定集中管理:在
test/setup.ts裡建立共用的 Mock 工具或測試容器(如tsyringe、inversify),減少重複程式碼。
實際應用場景
場景 1:使用者註冊流程
- 前端呼叫
POST /users。 UserController轉交UserService.register。UserService先寫入 DB,然後呼叫EmailService.sendWelcome。
在 CI 流程中,我們只 Mock Repository 與 EmailService,確保:
- DB 寫入成功(回傳的
User物件符合預期)。 - 歡迎信被正確呼叫一次。
這樣即使外部 SMTP 服務暫時不可用,測試仍能完整通過。
場景 2:分頁查詢與排序
當 API 需要根據查詢條件呼叫 UserRepository.findAll(filter) 時,直接 Mock findAll 回傳固定的陣列,測試 Controller 是否正確處理 分頁參數、錯誤回傳,而不必在測試環境建造大量測試資料。
場景 3:微服務間的 RPC 呼叫
假設 UserService 會呼叫另一個微服務 PaymentService 以檢查信用額度。只要把 IPaymentService 抽成介面,測試時注入 MockPaymentService,即可驗證 「當信用額度不足時拋出例外」 的業務邏輯,而不必真的發送 HTTP 請求。
總結
Mock 資料庫與 Service 是 Express + TypeScript 專案實現高效自動化測試的關鍵。透過 介面抽象、ts-mockito(或 Sinon)以及 Jest 的斷言與偵測功能,我們可以:
- 快速、穩定 地測試業務邏輯。
- 保持型別安全,避免因手動
any造成的錯誤。 - 降低測試成本,在 CI 中不需要真實 DB 或外部服務。
只要遵循「只 Mock 必要的外部依賴」與「測試單一職責」的原則,就能在開發過程中即時捕捉錯誤,提升程式碼品質與可維護性。祝你在 Express+TypeScript 的測試之路上越走越順!