本文 AI 產出,尚未審核

TypeScript – 測試與型別驗證

主題:Mock 型別設計


簡介

在大型前端或 Node.js 專案中,單元測試是保證程式品質的關鍵,而 TypeScript 本身提供的靜態型別檢查,更是讓測試變得更精準、更可靠。
然而,測試時常常需要 模擬(mock)外部依賴(例如 API、資料庫、第三方套件),如果只用 any 或手寫「臨時」物件,就會失去 TypeScript 的型別保護,導致測試程式與真實程式碼之間產生斷層。

本篇文章將說明 如何以型別安全的方式設計 Mock,讓測試程式在保持靈活性的同時,也能享受到完整的型別驗證。文章適合剛接觸 TypeScript 單元測試的初學者,也能幫助已有測試基礎的中級開發者提升測試品質。


核心概念

1. 為什麼要為 Mock 設計型別?

  • 避免錯誤傳遞:若 mock 的結構與真實介面不符,編譯期不會警告,執行時才會爆錯。
  • 提升可維護性:介面變更時,相關的 mock 會自動得到編譯錯誤提示,減少遺漏。
  • 提升自動完成與文件化:IDE 能正確推斷屬性與方法,讓開發者寫測試時更順手。

2. 基本做法:使用介面(interface)或型別別名(type)作為 Mock 的藍圖

// 真實服務的介面
export interface UserService {
  getUser(id: string): Promise<User>;
  updateUser(user: User): Promise<boolean>;
}

Tip:盡量把外部依賴抽象成介面,這樣在測試時只需要 mock 介面即可。

3. Partial<T>Required<T> 的運用

  • Partial<T>:將所有屬性變成可選,適合只需要實作部份方法的情境。
  • Required<T>:將所有屬性變回必填,確保 mock 完整。
// 只需要 mock getUser 方法
type UserServiceMock = Partial<UserService>;

const mockUserService: UserServiceMock = {
  getUser: async (id) => ({
    id,
    name: "Mock User",
    email: "mock@example.com",
  }),
};

4. 使用 jest.Mocked<T>(或類似工具)取得完整的 mock 型別

如果使用 Jest 作為測試框架,jest.Mocked<T> 會把介面的每個方法自動轉成 Jest 的 mock 函式型別。

import { jest } from "@jest/globals";

type MockedUserService = jest.Mocked<UserService>;

const userServiceMock: MockedUserService = {
  getUser: jest.fn().mockResolvedValue({
    id: "1",
    name: "John Doe",
    email: "john@example.com",
  }),
  updateUser: jest.fn().mockResolvedValue(true),
};

Note:若使用其他測試框架(如 Vitest、Sinon),概念相同,只是型別輔助工具會不同。

5. 建立 Factory 函式產生可自訂的 Mock

在測試套件中,常會需要「同樣的介面,但回傳值不同」的情況。寫一個 factory 可以讓每次測試只關注差異。

function createUserServiceMock(
  overrides?: Partial<UserService>
): MockedUserService {
  const defaultMock: MockedUserService = {
    getUser: jest.fn().mockResolvedValue({
      id: "default",
      name: "Default User",
      email: "default@example.com",
    }),
    updateUser: jest.fn().mockResolvedValue(false),
  };
  return { ...defaultMock, ...overrides };
}

// 測試中使用
const serviceA = createUserServiceMock({
  getUser: jest.fn().mockResolvedValue({ id: "A", name: "Alice", email: "a@ex.com" }),
});
const serviceB = createUserServiceMock({
  updateUser: jest.fn().mockResolvedValue(true),
});

程式碼範例

以下提供 5 個實用範例,展示不同情境下如何設計型別安全的 Mock。

範例 1:簡易的 Partial Mock(只測試 getUser

// user.ts
export interface User {
  id: string;
  name: string;
  email: string;
}
export interface UserService {
  getUser(id: string): Promise<User>;
  updateUser(user: User): Promise<boolean>;
}

// test/userService.test.ts
import { UserService } from "./user";

type UserServiceMock = Partial<UserService>;

const mock: UserServiceMock = {
  getUser: async (id) => ({
    id,
    name: "Mocked " + id,
    email: `${id}@mock.com`,
  }),
};

// 測試函式
async function testGetUser(service: UserService) {
  const user = await service.getUser("123");
  console.log(user.name); // "Mocked 123"
}
testGetUser(mock as UserService); // 透過型別斷言告訴編譯器它符合介面

重點:使用 Partial 可以只實作需要的部分,最後透過 as UserService 讓編譯器接受。


範例 2:利用 jest.Mocked<T> 完整 mock

import { jest } from "@jest/globals";
import { UserService, User } from "./user";

type MockedUserService = jest.Mocked<UserService>;

const mock: MockedUserService = {
  getUser: jest.fn().mockResolvedValue({
    id: "001",
    name: "Jane",
    email: "jane@mock.com",
  }),
  updateUser: jest.fn().mockResolvedValue(true),
};

// 在測試中驗證呼叫次數
test("updateUser should be called once", async () => {
  await mock.updateUser({ id: "001", name: "Jane", email: "jane@mock.com" });
  expect(mock.updateUser).toHaveBeenCalledTimes(1);
});

範例 3:Factory 搭配 Partial 靈活覆寫

function createMockUserService(
  overrides?: Partial<UserService>
): MockedUserService {
  const base: MockedUserService = {
    getUser: jest.fn().mockResolvedValue({
      id: "base",
      name: "Base User",
      email: "base@mock.com",
    }),
    updateUser: jest.fn().mockResolvedValue(false),
  };
  return { ...base, ...overrides } as MockedUserService;
}

// 測試 A
const mockA = createMockUserService({
  getUser: jest.fn().mockResolvedValue({
    id: "A",
    name: "Alpha",
    email: "alpha@mock.com",
  }),
});
await mockA.getUser("any"); // 會回傳 Alpha

// 測試 B
const mockB = createMockUserService({
  updateUser: jest.fn().mockResolvedValue(true),
});
await mockB.updateUser({ id: "B", name: "Beta", email: "beta@mock.com" }); // true

範例 4:使用 Proxy 動態產生「全自動」Mock

當介面非常龐大,手動寫每個方法會很繁瑣。下面示範利用 Proxy 自動生成 jest.fn()

function autoMock<T>(): jest.Mocked<T> {
  return new Proxy({} as any, {
    get(_, prop) {
      // 若屬性已存在直接回傳
      if (prop in target) return target[prop];
      // 否則回傳 jest.fn()
      const fn = jest.fn();
      target[prop] = fn;
      return fn;
    },
  }) as jest.Mocked<T>;
}

// 使用
type LargeService = {
  foo(a: number): string;
  bar(b: string, c: boolean): Promise<number>;
  // … 可能還有二十個方法
};

const largeMock = autoMock<LargeService>();
largeMock.foo.mockReturnValue("auto-mocked");
largeMock.bar.mockResolvedValue(42);

注意Proxy 只在測試環境使用,因為它會略過型別檢查的靜態分析,仍建議在正式程式碼中保留介面定義。


範例 5:Mock 具備 型別保護的資料結構(例如 Redux store)

// store.ts
export interface AppState {
  count: number;
  user?: { id: string; name: string };
}
export interface Store {
  getState(): AppState;
  dispatch(action: { type: string; payload?: any }): void;
}

// test/storeMock.test.ts
import { Store, AppState } from "./store";

type StoreMock = jest.Mocked<Store>;

function createStoreMock(initialState: AppState): StoreMock {
  const mock: StoreMock = {
    getState: jest.fn().mockReturnValue(initialState),
    dispatch: jest.fn(),
  };
  return mock;
}

// 測試
const store = createStoreMock({ count: 0 });
store.dispatch({ type: "INCREMENT" });
expect(store.dispatch).toHaveBeenCalledWith({ type: "INCREMENT" });
expect(store.getState().count).toBe(0);

常見陷阱與最佳實踐

陷阱 說明 解決方式
使用 any 直接當作 Mock 失去型別檢查,測試與實作不一致 改用 Partial<T>jest.Mocked<T> 或自訂型別
忘記在測試套件中匯入介面 產生「屬性不存在」的 runtime error 確保介面與實作放在同一層目錄,或使用 paths alias
Mock 方法回傳值類型不匹配 編譯期無錯,執行時 Promise 解析失敗 使用 mockResolvedValuemockRejectedValue 時,提供正確的型別
過度 Mock(Mock 整個大型物件) 測試失去真實行為、維護成本升高 只 mock 必要的層級,盡量保持 partial
忘記重置 Mock 狀態 前一個測試的呼叫次數會影響後續測試 beforeEach 中呼叫 jest.clearAllMocks()mockFn.mockReset()

最佳實踐

  1. 介面抽象化:將所有外部依賴抽成介面,測試只關心介面。
  2. 使用型別輔助工具jest.Mocked<T>vitest.Mocked<T> 能自動把方法轉成 mock 函式。
  3. Factory + Partial:提供可自訂的 mock 產生器,讓每個測試只覆寫需要的行為。
  4. 型別斷言慎用as T 應該是最後的手段,盡量讓編譯器自行推斷。
  5. 保持 Mock 與實作同步:介面變更時,CI 會直接報錯,別忘了同步更新 mock。

實際應用場景

場景 為什麼需要 Mock 型別 建議的設計方式
呼叫第三方 API(Axios、Fetch) 測試不想真的發網路請求,且 API 回傳結構常變 HttpClient 抽成介面,使用 Partial<HttpClient>jest.Mocked<HttpClient>
資料庫存取層(Prisma、TypeORM) DB 操作成本高且不易在 CI 環境搭建 介面 UserRepositorycreateMock<UserRepository>(),使用 mockResolvedValue 回傳假資料
Redux / Zustand 狀態管理 UI 測試需要特定的 state,卻不想跑整個 store StoreMockgetStatedispatch 包成 mock,並利用 Partial<AppState> 控制狀態
WebSocket 或 EventSource 真實連線會產生非同步事件,測試難以控制 Socket 抽成 SocketAdapter,在測試中提供 emiton 的 jest mock
第三方 UI 元件(如 Ant Design、Material UI) 有些元件會在內部呼叫 requestAnimationFramesetTimeout,測試時需 stub Partial<ComponentProps> 只提供必需的 props,並在 mock 中加入 jest.useFakeTimers()

總結

  • Mock 型別設計 是提升 TypeScript 測試品質的關鍵步驟,能讓測試程式與實作保持型別一致性。
  • 透過 介面抽象Partial<T>jest.Mocked<T>、以及 Factory 的組合,我們可以快速產生 型別安全且可自訂 的 mock 物件。
  • 注意避免 any、過度 mock、忘記重置等常見陷阱,並遵循 抽象化 + 型別輔助工具 的最佳實踐。
  • 在實務專案中,從 API 呼叫、資料庫存取、狀態管理到 WebSocket,幾乎所有外部依賴都可以透過型別安全的 mock 來測試,提升 CI 穩定性與開發效率。

結語:只要把型別安全的思維帶入測試設計,TypeScript 的強大靜態檢查就會成為你最可靠的測試守護者,讓程式碼在變更時仍能保持高品質。祝你在 TypeScript 測試的路上寫出乾淨、可靠的程式碼!