本文 AI 產出,尚未審核

ExpressJS (TypeScript) – Testing 自動化測試:Mock 資料庫與 Service


簡介

Node.js 生態系中,Express 搭配 TypeScript 已成為建構 RESTful API 的主流組合。開發完畢後,若沒有可靠的自動化測試,程式碼的品質與可維護性將難以保證。尤其是涉及資料庫存取或外部 Service 時,直接對真的 DB 或 Service 進行測試會帶來 執行速度慢、測試不穩定、環境難以重現 等問題。

使用 Mock(模擬)技術,我們可以在單元測試中 替代真實的資料庫與 Service,只驗證程式邏輯本身。本文將一步步說明怎麼在 Express + TypeScript 專案裡,透過 Jestts-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 / thenReturnverify 等語法。

程式碼範例

以下示範一個簡易的 User 模組,包含 UserControllerUserServiceUserRepository,以及對應的測試與 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-mockitojest-mock-extended 等工具,保留介面型別。
測試依賴真實 DB 有時會不小心在測試中呼叫 getRepository,導致連到本機 DB。 getRepository 包在 Repository 類別內,測試只注入 Mock。
測試過於依賴實作細節 當 Service 實作改變(例如改用 Prisma),測試全部失效。 只測試 行為(如 create 被呼叫一次),不檢查具體實作。

最佳實踐

  1. 介面導向設計:所有外部依賴(DB、第三方 API)都抽成介面,讓測試只需要注入 Mock。
  2. 單元測試只測一件事:Service 測試只關心業務邏輯,Controller 測試只關心路由與回傳。
  3. 使用 In‑Memory DB 作整合測試:當需要驗證 ORM 行為時,可使用 SQLite in‑memory,保持速度同時保留真實 SQL 執行。
  4. 把 Mock 設定集中管理:在 test/setup.ts 裡建立共用的 Mock 工具或測試容器(如 tsyringeinversify),減少重複程式碼。

實際應用場景

場景 1:使用者註冊流程

  1. 前端呼叫 POST /users
  2. UserController 轉交 UserService.register
  3. UserService 先寫入 DB,然後呼叫 EmailService.sendWelcome

在 CI 流程中,我們只 Mock RepositoryEmailService,確保:

  • 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 的斷言與偵測功能,我們可以:

  1. 快速、穩定 地測試業務邏輯。
  2. 保持型別安全,避免因手動 any 造成的錯誤。
  3. 降低測試成本,在 CI 中不需要真實 DB 或外部服務。

只要遵循「只 Mock 必要的外部依賴」與「測試單一職責」的原則,就能在開發過程中即時捕捉錯誤,提升程式碼品質與可維護性。祝你在 Express+TypeScript 的測試之路上越走越順!