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 解析失敗 | 使用 mockResolvedValue、mockRejectedValue 時,提供正確的型別 |
| 過度 Mock(Mock 整個大型物件) | 測試失去真實行為、維護成本升高 | 只 mock 必要的層級,盡量保持 partial |
| 忘記重置 Mock 狀態 | 前一個測試的呼叫次數會影響後續測試 | 在 beforeEach 中呼叫 jest.clearAllMocks() 或 mockFn.mockReset() |
最佳實踐
- 介面抽象化:將所有外部依賴抽成介面,測試只關心介面。
- 使用型別輔助工具:
jest.Mocked<T>、vitest.Mocked<T>能自動把方法轉成 mock 函式。 - Factory + Partial:提供可自訂的 mock 產生器,讓每個測試只覆寫需要的行為。
- 型別斷言慎用:
as T應該是最後的手段,盡量讓編譯器自行推斷。 - 保持 Mock 與實作同步:介面變更時,CI 會直接報錯,別忘了同步更新 mock。
實際應用場景
| 場景 | 為什麼需要 Mock 型別 | 建議的設計方式 |
|---|---|---|
| 呼叫第三方 API(Axios、Fetch) | 測試不想真的發網路請求,且 API 回傳結構常變 | 把 HttpClient 抽成介面,使用 Partial<HttpClient> 或 jest.Mocked<HttpClient> |
| 資料庫存取層(Prisma、TypeORM) | DB 操作成本高且不易在 CI 環境搭建 | 介面 UserRepository → createMock<UserRepository>(),使用 mockResolvedValue 回傳假資料 |
| Redux / Zustand 狀態管理 | UI 測試需要特定的 state,卻不想跑整個 store | 用 StoreMock 把 getState、dispatch 包成 mock,並利用 Partial<AppState> 控制狀態 |
| WebSocket 或 EventSource | 真實連線會產生非同步事件,測試難以控制 | 把 Socket 抽成 SocketAdapter,在測試中提供 emit、on 的 jest mock |
| 第三方 UI 元件(如 Ant Design、Material UI) | 有些元件會在內部呼叫 requestAnimationFrame 或 setTimeout,測試時需 stub |
用 Partial<ComponentProps> 只提供必需的 props,並在 mock 中加入 jest.useFakeTimers() |
總結
- Mock 型別設計 是提升 TypeScript 測試品質的關鍵步驟,能讓測試程式與實作保持型別一致性。
- 透過 介面抽象、
Partial<T>、jest.Mocked<T>、以及 Factory 的組合,我們可以快速產生 型別安全且可自訂 的 mock 物件。 - 注意避免 any、過度 mock、忘記重置等常見陷阱,並遵循 抽象化 + 型別輔助工具 的最佳實踐。
- 在實務專案中,從 API 呼叫、資料庫存取、狀態管理到 WebSocket,幾乎所有外部依賴都可以透過型別安全的 mock 來測試,提升 CI 穩定性與開發效率。
結語:只要把型別安全的思維帶入測試設計,TypeScript 的強大靜態檢查就會成為你最可靠的測試守護者,讓程式碼在變更時仍能保持高品質。祝你在 TypeScript 測試的路上寫出乾淨、可靠的程式碼!