Jest + TypeScript 設定教學
簡介
在前端與 Node.js 專案中,單元測試是保證程式碼品質、降低回歸風險的關鍵手段。隨著 TypeScript 成為主流的靜態型別語言,開發者往往會面臨「如何在保留型別資訊的同時,順利使用 Jest 進行測試」的問題。
若測試環境與 TypeScript 編譯設定不一致,常會出現型別錯誤、模組找不到或是測試執行速度過慢等狀況。這篇文章將一步步說明 Jest + TypeScript 的完整設定流程,從專案初始化、編譯工具選擇、到實作測試案例與最佳實踐,讓你在開發過程中即能得到即時、可靠的回饋。
核心概念
1. 為什麼要使用 ts-jest 或 babel-jest
| 方案 | 優點 | 缺點 |
|---|---|---|
| ts-jest | 直接使用 TypeScript 編譯器 (tsc) 產生的型別資訊,支援 paths、baseUrl 等 tsconfig 設定。 |
編譯速度較慢,尤其在大型專案。 |
babel-jest + @babel/preset-typescript |
轉譯速度快,適合與 Babel 共用配置(如 React JSX)。 | 失去部分 TypeScript 的型別檢查,需要自行在 CI 中跑 tsc --noEmit。 |
本文以 ts-jest 為主,因為它能在測試時同時保留型別檢查,最符合「測試 + 型別驗證」的教學目標。
2. 基本檔案結構
my-project/
├─ src/
│ ├─ utils/
│ │ └─ math.ts
│ └─ index.ts
├─ tests/
│ └─ utils/
│ └─ math.test.ts
├─ tsconfig.json
├─ jest.config.ts
└─ package.json
src/放置正式程式碼。tests/放置測試檔,檔名慣例為*.test.ts。
3. 設定 tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"outDir": "./dist",
"rootDir": "./src",
"baseUrl": ".",
"paths": {
"@utils/*": ["src/utils/*"]
}
},
"include": ["src/**/*", "tests/**/*"],
"exclude": ["node_modules", "dist"]
}
strict開啟所有嚴格檢查,保證型別安全。baseUrl+paths讓我們在程式與測試中都能使用 絕對路徑別名(如@utils/math),避免相對路徑的混亂。
4. 安裝必備套件
npm i -D jest ts-jest @types/jest typescript
jest:測試執行器。ts-jest:讓 Jest 能直接理解 TypeScript。@types/jest:提供 Jest 的型別宣告。typescript:編譯器本身。
5. 建立 jest.config.ts
// jest.config.ts
import type { Config } from '@jest/types';
const config: Config.InitialOptions = {
preset: 'ts-jest', // 使用 ts-jest 轉譯
testEnvironment: 'node', // 執行環境 (node / jsdom)
roots: ['<rootDir>/tests'], // 測試檔案根目錄
moduleFileExtensions: ['ts', 'js', 'json', 'node'],
moduleNameMapper: {
// 讓 Jest 也能辨識 tsconfig 中的 paths 別名
'^@utils/(.*)$': '<rootDir>/src/utils/$1'
},
globals: {
'ts-jest': {
tsconfig: 'tsconfig.json' // 明確指定使用的 tsconfig
}
},
// 只顯示失敗測試的詳細訊息,提升執行速度
verbose: false,
};
export default config;
小技巧:若專案同時使用 Babel,
preset可改為'babel-jest',但仍需在.babelrc中加入@babel/preset-typescript。
6. 程式碼範例
以下示範三個常見的測試情境,從最基礎的函式測試、異步函式、到檔案模組的 mock。
6.1 基礎函式測試
// src/utils/math.ts
export function add(a: number, b: number): number {
return a + b;
}
// tests/utils/math.test.ts
import { add } from '@utils/math';
describe('add 函式', () => {
it('應回傳兩數相加的結果', () => {
expect(add(2, 3)).toBe(5);
expect(add(-1, 1)).toBe(0);
});
});
- 使用
@utils/math別名,讓測試檔不必寫長長的相對路徑。 describe與it分別代表「測試群組」與「測試案例」,可讀性佳。
6.2 測試非同步函式
// src/utils/fetcher.ts
export async function fetchJson(url: string): Promise<any> {
const response = await fetch(url);
if (!response.ok) throw new Error('Network error');
return response.json();
}
// tests/utils/fetcher.test.ts
import { fetchJson } from '@utils/fetcher';
// 使用 jest.fn() 手動 mock fetch
global.fetch = jest.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ success: true })
})
) as jest.Mock;
describe('fetchJson', () => {
it('應正確解析 JSON 資料', async () => {
const data = await fetchJson('https://example.com/api');
expect(data).toEqual({ success: true });
// 確認 fetch 被呼叫且參數正確
expect(fetch).toHaveBeenCalledWith('https://example.com/api');
});
it('當 response 不 ok 時拋出錯誤', async () => {
// 改寫 mock 為失敗情境
(fetch as jest.Mock).mockResolvedValueOnce({
ok: false,
json: () => Promise.resolve({})
});
await expect(fetchJson('https://bad.url')).rejects.toThrow('Network error');
});
});
global.fetch被 mock 為 Jest 函式,避免真的發送網路請求。- 使用
await expect(promise).rejects.toThrow()來測試 異常拋出。
6.3 Mock 模組與自動模擬
// src/services/userService.ts
import { getUserFromDb } from '../db/user';
export async function getUserProfile(id: string) {
const user = await getUserFromDb(id);
return {
id: user.id,
name: user.name,
email: user.email
};
}
// tests/services/userService.test.ts
// 使用 jest.mock 自動 mock 整個模組
jest.mock('../db/user', () => ({
getUserFromDb: jest.fn()
}));
import { getUserProfile } from '../../src/services/userService';
import { getUserFromDb } from '../../src/db/user';
describe('getUserProfile', () => {
it('應根據 DB 回傳正確的使用者資訊', async () => {
// 設定 mock 回傳值
(getUserFromDb as jest.Mock).mockResolvedValueOnce({
id: 'u123',
name: 'Alice',
email: 'alice@example.com',
// 其他欄位會被自動忽略
});
const profile = await getUserProfile('u123');
expect(profile).toEqual({
id: 'u123',
name: 'Alice',
email: 'alice@example.com'
});
expect(getUserFromDb).toHaveBeenCalledWith('u123');
});
});
jest.mock會 自動 hoist 到檔案最上方,確保在被測試模組載入前完成 mock。- 透過
as jest.Mock取得型別補強,讓 TypeScript 能正確推斷mockResolvedValueOnce。
6.4 使用測試快照(Snapshot)
// src/components/Hello.tsx
import React from 'react';
export const Hello = ({ name }: { name: string }) => (
<h1>Hello, {name}!</h1>
);
// tests/components/Hello.test.tsx
import React from 'react';
import renderer from 'react-test-renderer';
import { Hello } from '../../src/components/Hello';
test('Hello component snapshot', () => {
const tree = renderer.create(<Hello name="Bob" />).toJSON();
expect(tree).toMatchSnapshot();
});
- 需要額外安裝
react-test-renderer(npm i -D react-test-renderer)。 - Snapshot 能快速捕捉 UI 變更,但要配合審核流程,避免產生不必要的噪音。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方式 |
|---|---|---|
測試跑不出來,顯示 SyntaxError: Unexpected token |
Jest 無法解析 TypeScript 語法(如 import、export) |
確認 jest.config.ts 中 preset: 'ts-jest' 已設定,且 ts-jest 版本與 typescript 相容。 |
moduleNameMapper 沒對應到別名 |
測試時找不到 @utils/... 路徑 |
在 jest.config.ts 中加入相同的 paths 映射,或使用 tsconfig-paths-jest 套件。 |
| 型別錯誤在測試中被忽略 | 只跑 jest 而未執行 tsc --noEmit |
在 CI 中加入 npm run lint && tsc --noEmit,確保編譯階段的型別檢查。 |
| 測試速度過慢 | 每個測試都重新編譯 TypeScript | 使用 ts-jest 的 isolatedModules: true 或改用 babel-jest,同時在 package.json 設定 --runInBand 只在 CI 使用。 |
| Mock 失效,實際呼叫到了原始函式 | jest.mock 放在 import 之後 |
jest.mock 必須寫在檔案最上方(在任何 import 之前),或使用 jest.doMock 動態 mock。 |
最佳實踐要點
- 保持
tsconfig.json與jest.config.ts同步:任何paths、baseUrl、target的變更,都要同步更新兩個檔案。 - 在測試前先跑一次
tsc --noEmit:可在package.json裡加一個pretestscript,保證型別正確。"scripts": { "pretest": "tsc --noEmit", "test": "jest" } - 使用
--coverage產生測試覆蓋率報告,並在 CI 中設定門檻(例如 80%)。 - 針對大型專案啟用
cacheDirectory:cacheDirectory: "<rootDir>/tmp/jest_cache" - 分層測試:單元測試聚焦純函式,整合測試(使用
supertest)則針對 API 路由。保持測試範圍清晰,有助於維護。
實際應用場景
1. 企業級微服務(Node.js + TypeScript)
在微服務架構中,每個服務都可能擁有獨立的資料存取層與外部 API 呼叫。使用 Jest + ts-jest 可以:
- 快速定位錯誤:透過單元測試捕捉 DAO 層的 SQL 拼字錯誤。
- 模擬外部服務:利用
jest.mock或nock替代第三方 API,確保測試不受網路波動影響。 - CI 整合:在 GitHub Actions 中加入
npm test -- --coverage,自動產生覆蓋率與型別檢查報告。
2. 前端 React + TypeScript 專案
React 元件往往需要 快照測試、事件驅動測試與Hooks 測試。結合 @testing-library/react 與 ts-jest,可以:
- 直接在
.test.tsx中寫 JSX,不必額外設定 Babel(ts-jest內建支援)。 - 使用
renderHook測試自訂 Hook,保留完整型別資訊。
// example of testing a custom hook
import { renderHook, act } from '@testing-library/react-hooks';
import useCounter from '../../src/hooks/useCounter';
test('useCounter 初始化為 0 且可遞增', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
3. 開源函式庫(npm package)
發佈 TypeScript 函式庫時,型別正確性是使用者最在乎的。透過 Jest:
- 自動驗證每個公開 API:測試函式的輸入/輸出型別,防止破壞性變更。
- 生成
d.ts檔案的同時跑測試:在npm publish前,以npm run prepublishOnly先執行npm test && tsc,保證發佈的套件不會因型別錯誤而失效。
總結
本文從 環境安裝、設定檔、程式碼範例、常見陷阱 以及 實務應用,完整說明了在 TypeScript 專案中使用 Jest 進行測試與型別驗證的全流程。掌握以下重點,即可在日常開發與 CI/CD 中建立可靠、可維護的測試基礎:
- 選擇適合的轉譯器(
ts-jest為主,視需求可改用babel-jest)。 - 保持
tsconfig.json與jest.config.ts同步,尤其是路徑別名。 - 在測試前執行
tsc --noEmit,確保型別安全不被測試環境忽略。 - 善用 Jest 的 mock 機制,避免外部依賴干擾測試結果。
- 結合覆蓋率、快照與 Hook 測試,讓測試範圍既完整又具可讀性。
只要依循上述步驟與最佳實踐,無論是 Node 後端服務、React 前端應用,或是 開源函式庫,都能以 快速、正確且具型別保證 的方式完成測試,提升程式碼品質與團隊信心。祝開發順利,測試愉快!