本文 AI 產出,尚未審核

Jest + TypeScript 設定教學


簡介

在前端與 Node.js 專案中,單元測試是保證程式碼品質、降低回歸風險的關鍵手段。隨著 TypeScript 成為主流的靜態型別語言,開發者往往會面臨「如何在保留型別資訊的同時,順利使用 Jest 進行測試」的問題。

若測試環境與 TypeScript 編譯設定不一致,常會出現型別錯誤、模組找不到或是測試執行速度過慢等狀況。這篇文章將一步步說明 Jest + TypeScript 的完整設定流程,從專案初始化、編譯工具選擇、到實作測試案例與最佳實踐,讓你在開發過程中即能得到即時、可靠的回饋。


核心概念

1. 為什麼要使用 ts-jestbabel-jest

方案 優點 缺點
ts-jest 直接使用 TypeScript 編譯器 (tsc) 產生的型別資訊,支援 pathsbaseUrltsconfig 設定。 編譯速度較慢,尤其在大型專案。
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 別名,讓測試檔不必寫長長的相對路徑。
  • describeit 分別代表「測試群組」與「測試案例」,可讀性佳

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.fetchmock 為 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-renderernpm i -D react-test-renderer)。
  • Snapshot 能快速捕捉 UI 變更,但要配合審核流程,避免產生不必要的噪音。

常見陷阱與最佳實踐

陷阱 說明 解決方式
測試跑不出來,顯示 SyntaxError: Unexpected token Jest 無法解析 TypeScript 語法(如 importexport 確認 jest.config.tspreset: '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-jestisolatedModules: true 或改用 babel-jest,同時在 package.json 設定 --runInBand 只在 CI 使用。
Mock 失效,實際呼叫到了原始函式 jest.mock 放在 import 之後 jest.mock 必須寫在檔案最上方(在任何 import 之前),或使用 jest.doMock 動態 mock。

最佳實踐要點

  1. 保持 tsconfig.jsonjest.config.ts 同步:任何 pathsbaseUrltarget 的變更,都要同步更新兩個檔案。
  2. 在測試前先跑一次 tsc --noEmit:可在 package.json 裡加一個 pretest script,保證型別正確。
    "scripts": {
      "pretest": "tsc --noEmit",
      "test": "jest"
    }
    
  3. 使用 --coverage 產生測試覆蓋率報告,並在 CI 中設定門檻(例如 80%)。
  4. 針對大型專案啟用 cacheDirectory
    cacheDirectory: "<rootDir>/tmp/jest_cache"
    
  5. 分層測試:單元測試聚焦純函式,整合測試(使用 supertest)則針對 API 路由。保持測試範圍清晰,有助於維護。

實際應用場景

1. 企業級微服務(Node.js + TypeScript)

在微服務架構中,每個服務都可能擁有獨立的資料存取層與外部 API 呼叫。使用 Jest + ts-jest 可以:

  • 快速定位錯誤:透過單元測試捕捉 DAO 層的 SQL 拼字錯誤。
  • 模擬外部服務:利用 jest.mocknock 替代第三方 API,確保測試不受網路波動影響。
  • CI 整合:在 GitHub Actions 中加入 npm test -- --coverage,自動產生覆蓋率與型別檢查報告。

2. 前端 React + TypeScript 專案

React 元件往往需要 快照測試事件驅動測試Hooks 測試。結合 @testing-library/reactts-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 中建立可靠、可維護的測試基礎:

  1. 選擇適合的轉譯器ts-jest 為主,視需求可改用 babel-jest)。
  2. 保持 tsconfig.jsonjest.config.ts 同步,尤其是路徑別名。
  3. 在測試前執行 tsc --noEmit,確保型別安全不被測試環境忽略。
  4. 善用 Jest 的 mock 機制,避免外部依賴干擾測試結果。
  5. 結合覆蓋率、快照與 Hook 測試,讓測試範圍既完整又具可讀性。

只要依循上述步驟與最佳實踐,無論是 Node 後端服務React 前端應用,或是 開源函式庫,都能以 快速、正確且具型別保證 的方式完成測試,提升程式碼品質與團隊信心。祝開發順利,測試愉快!