執行環境套件管理器打包器測試執行器程式碼檢查格式化指南參考部落格意見回饋

編寫測試

使用 Bun 相容 Jest 的 API 編寫測試,包括非同步測試、超時和測試修飾符

使用從內建 bun:test 模組匯入的類 Jest API 定義測試。從長遠來看,Bun 的目標是完全相容 Jest。

基本用法

定義測試:

math.test.ts
import { expect, test } from "bun:test";

test("2 + 2", () => {
  expect(2 + 2).toBe(4);
});

測試分組

使用 describe 將測試分組到測試套件中。

math.test.ts
import { expect, test, describe } from "bun:test";

describe("算術", () => {
  test("2 + 2", () => {
    expect(2 + 2).toBe(4);
  });

  test("2 * 2", () => {
    expect(2 * 2).toBe(4);
  });
});

非同步測試

測試可以是非同步的。

math.test.ts
import { expect, test } from "bun:test";

test("2 * 2", async () => {
  const result = await Promise.resolve(2 * 2);
  expect(result).toEqual(4);
});

或者,使用 done 回撥來表示完成。如果測試函式接收 done 引數,則必須呼叫它,否則測試會一直掛起。

math.test.ts
import { expect, test } from "bun:test";

test("2 * 2", done => {
  Promise.resolve(2 * 2).then(result => {
    expect(result).toEqual(4);
    done();
  });
});

超時

可通過給 test 傳入第三個引數數字來指定單個測試的超時時間(毫秒)。

math.test.ts
import { expect, test } from "bun:test";

test("wat", async () => {
  const data = await slowOperation();
  expect(data).toBe(42);
}, 500); // 測試必須在 <500ms 內完成

在 bun:test 中,超時會丟擲無法捕獲的異常,以強制測試停止執行並失敗。Bun 還會終止測試中生成的所有子行程,因此它們不會作為殭屍行程殘留。

除非使用此超時選項或 jest.setTimeout() 覆蓋,否則每個測試的預設超時時間為 5000ms(5 秒)。

重試和重複執行

test.retry

使用 retry 選項可在不穩定的測試失敗時自動重試。測試在指定的嘗試次數內成功即可通過。

example.test.ts
import { expect, test } from "bun:test";

test(
  "不穩定的網路請求",
  async () => {
    const response = await fetch("https://example.com/api");
    expect(response.ok).toBe(true);
  },
  { retry: 3 }, // 失敗時最多重試3次
);

test.repeats

使用 repeats 選項可多次執行測試,而不論測試通過還是失敗;如果任何一次迭代失敗,測試就會失敗。可使用此選項檢測不穩定的測試或進行壓力測試。repeats: N 總共執行測試 N+1 次(1 次初始執行 + N 次重複執行)。

example.test.ts
import { expect, test } from "bun:test";

test(
  "確保測試穩定",
  () => {
    expect(Math.random()).toBeLessThan(1);
  },
  { repeats: 20 }, // 總計執行21次(1次初始 + 20次重複)
);

🧟 殭屍行程清理器

當測試超時時,Bun 會終止測試使用 Bun.spawn、Bun.spawnSync 或 node:child_process 生成的所有仍在執行的行程,並在控制台中記錄一條訊息。這可以防止超時測試結束後留下殭屍行程。

測試修飾符

test.skip

使用 test.skip 跳過單個測試。Bun 不會執行這些測試。

math.test.ts
import { expect, test } from "bun:test";

test.skip("wat", () => {
  // TODO: 修復此處
  expect(0.1 + 0.2).toEqual(0.3);
});

test.todo

使用 test.todo 將測試標記為待辦事項。Bun 不會執行這些測試。

math.test.ts
import { expect, test } from "bun:test";

test.todo("修復此處", () => {
  myTestFunction();
});

要執行待辦測試並找出其中通過的測試,請使用 bun test --todo。當 myTestFunction() 正常工作後,Bun 會將上面的測試報告為失敗。

terminal
bun test --todo
Code
math.test.ts:
✗ fix this
  ^ this test is marked as todo but passes. Remove `.todo` if tested behavior now works

 0 pass
 1 fail

使用此標誌時,失敗的待辦測試不會導致錯誤,但 Bun 會將通過的待辦測試標記為失敗,以便你移除待辦標記或修復測試。

test.only

要執行特定測試或測試套件,請使用 test.only() 或 describe.only()。

example.test.ts
import { test, describe } from "bun:test";

test("測試 #1", () => {
  // 不執行
});

test.only("測試 #2", () => {
  // 執行
});

describe.only("僅此", () => {
  test("測試 #3", () => {
    // 執行
  });
});

以下命令只執行測試 #2 和 #3。

terminal
bun test --only

test.if

要有條件地執行測試,請使用 test.if()。當條件為真值時,測試才會執行。對於僅應在特定架構或作業系統上執行的測試,可以使用它。

example.test.ts
test.if(Math.random() > 0.5)("有一半機率執行", () => {
  // ...
});

const macOS = process.platform === "darwin";
test.if(macOS)("僅在 macOS 執行", () => {
  // 僅在 macOS 執行
});

test.skipIf

條件跳過測試,使用 test.skipIf() 或 describe.skipIf()。

example.test.ts
const macOS = process.platform === "darwin";

test.skipIf(macOS)("僅在非 macOS 執行", () => {
  // 在非 macOS 執行
});

test.todoIf

要改為將測試標記為待辦事項,請使用 test.todoIf() 或 describe.todoIf()。選擇 skipIf 還是 todoIf 表明了不同意圖:“對該目標無效”與“計劃實現但尚未實現”。

example.test.ts
const macOS = process.platform === "darwin";

// TODO: 目前只在 Linux 上實現。
test.todoIf(macOS)("執行在 posix 系統上", () => {
  // 在非 macOS 執行
});

test.failing

當你知道某個測試會失敗,但希望跟蹤它並在它開始通過時收到通知,請使用 test.failing()。這會反轉測試結果:

  • 標記了 .failing() 的失敗測試會通過
  • 標記了 .failing() 的通過測試會失敗,並顯示它現在已經通過、應當進行修復的訊息
math.test.ts
// 因測試故意失敗,這裡會通過
test.failing("數學有問題", () => {
  expect(0.1 + 0.2).toBe(0.3); // 由於浮點精度失敗
});

// 測試通過,但期望失敗,因此測試失敗並提示修復
test.failing("修復了 bug", () => {
  expect(1 + 1).toBe(2); // 通過,但我們預期失敗
});

可以使用它跟蹤計劃稍後修復的已知 bug,或用於測試驅動開發。

describe 塊中的條件測試

條件修飾符 .if()、.skipIf() 和 .todoIf() 同樣適用於 describe 塊,會影響套件中的所有測試:

example.test.ts
const isMacOS = process.platform === "darwin";

// 僅在 macOS 上執行整個套件
describe.if(isMacOS)("macOS 特有功能", () => {
  test("特性 A", () => {
    // 僅在 macOS 執行
  });

  test("特性 B", () => {
    // 僅在 macOS 執行
  });
});

// 在 Windows 上跳過整個套件
describe.skipIf(process.platform === "win32")("Unix 功能", () => {
  test("特性 C", () => {
    // Windows 上跳過
  });
});

// 在 Linux 上將整個套件標記為 TODO
describe.todoIf(process.platform === "linux")("即將支援 Linux", () => {
  test("特性 D", () => {
    // Linux 上標記為 TODO
  });
});

引數化測試

test.each 和 describe.each

使用 test.each 可針對多個數據集執行相同測試。每個測試用例都會執行一次。

math.test.ts
const cases = [
  [1, 2, 3],
  [3, 4, 7],
];

test.each(cases)("%p + %p 應該是 %p", (a, b, expected) => {
  expect(a + b).toBe(expected);
});

describe.each 建立一個引數化測試套件,每個測試用例執行一次:

sum.test.ts
describe.each([
  [1, 2, 3],
  [3, 4, 7],
])("add(%i, %i)", (a, b, expected) => {
  test(`傳回 ${expected}`, () => {
    expect(a + b).toBe(expected);
  });

  test(`結果大於每個值`, () => {
    expect(a + b).toBeGreaterThan(a);
    expect(a + b).toBeGreaterThan(b);
  });
});

引數傳遞

Bun 如何向測試函式傳遞引數,取決於測試用例的結構:

  • 如果表格行是陣列(例如 [1, 2, 3]),Bun 會將每個元素作為單獨的引數傳遞
  • 如果行不是陣列(例如物件),Bun 會將其作為單個引數傳遞
example.test.ts
// 陣列項作為單獨引數傳入
test.each([
  [1, 2, 3],
  [4, 5, 9],
])("add(%i, %i) = %i", (a, b, expected) => {
  expect(a + b).toBe(expected);
});

// 物件項作為單個引數傳入
test.each([
  { a: 1, b: 2, expected: 3 },
  { a: 4, b: 5, expected: 9 },
])("add($a, $b) = $expected", data => {
  expect(data.a + data.b).toBe(data.expected);
});

格式化佔位符

使用以下格式說明符來格式化測試標題:

佔位符描述
%ppretty-format 美化輸出
%s字串
%d數字
%i整數
%f浮點數
%jJSON
%o物件
%#測試用例索引
%%單個百分號 (%)

範例

example.test.ts
// 基本佔位符
test.each([
  ["hello", 123],
  ["world", 456],
])("字串: %s, 數字: %i", (str, num) => {
  // "字串: hello, 數字: 123"
  // "字串: world, 數字: 456"
});

// %p 用於 pretty-format 輸出
test.each([
  [{ name: "Alice" }, { a: 1, b: 2 }],
  [{ name: "Bob" }, { x: 5, y: 10 }],
])("使用者 %p,資料 %p", (user, data) => {
  // "使用者 { name: 'Alice' },資料 { a: 1, b: 2 }"
  // "使用者 { name: 'Bob' },資料 { x: 5, y: 10 }"
});

// %# 用於索引
test.each(["apple", "banana"])("水果 #%# 是 %s", fruit => {
  // "水果 #0 是 apple"
  // "水果 #1 是 banana"
});

斷言計數

Bun 支援驗證測試期間呼叫的斷言數量:

expect.hasAssertions()

使用 expect.hasAssertions() 驗證測試中至少呼叫過一次斷言:

example.test.ts
test("非同步工作呼叫斷言", async () => {
  expect.hasAssertions(); // 如果沒有斷言呼叫,測試失敗

  const data = await fetchData();
  expect(data).toBeDefined();
});

expect.hasAssertions() 在非同步測試中特別有用,可確保斷言確實執行。

expect.assertions(count)

使用 expect.assertions(count) 驗證測試中呼叫了指定數量的斷言:

example.test.ts
test("恰好兩個斷言", () => {
  expect.assertions(2); // 如果斷言不是精確2個則失敗

  expect(1 + 1).toBe(2);
  expect("hello").toContain("ell");
});

expect.assertions(count) 有助於確保所有斷言都執行,尤其是在包含多個程式碼路徑的複雜非同步程式碼中。

型別測試

Bun 包含用於測試 TypeScript 型別的 expectTypeOf,相容 Vitest。

expectTypeOf

expectTypeOf 函式提供型別級斷言,由 TypeScript 型別檢查器驗證。要測試型別:

  1. 使用 expectTypeOf 編寫型別斷言
  2. 執行 bunx tsc --noEmit 檢查型別是否正確
example.test.ts
import { expectTypeOf } from "bun:test";

// 基本型別斷言
expectTypeOf<string>().toEqualTypeOf<string>();
expectTypeOf(123).toBeNumber();
expectTypeOf("hello").toBeString();

// 物件型別匹配
expectTypeOf({ a: 1, b: "hello" }).toMatchObjectType<{ a: number }>();

// 函式型別
function greet(name: string): string {
  return `Hello ${name}`;
}

expectTypeOf(greet).toBeFunction();
expectTypeOf(greet).parameters.toEqualTypeOf<[string]>();
expectTypeOf(greet).returns.toEqualTypeOf<string>();

// 陣列型別
expectTypeOf([1, 2, 3]).items.toBeNumber();

// Promise 型別
expectTypeOf(Promise.resolve(42)).resolves.toBeNumber();

有關 expectTypeOf 匹配器的完整文件,請參閱 API 參考。

匹配器

Bun 實現了以下匹配器。計劃實現完整的 Jest 相容性;請參閱跟蹤 issue。

基本匹配器

狀態匹配器
✅.not
✅.toBe()
✅.toEqual()
✅.toBeNull()
✅.toBeUndefined()
✅.toBeNaN()
✅.toBeDefined()
✅.toBeFalsy()
✅.toBeTruthy()
✅.toStrictEqual()

字串和陣列匹配器

狀態匹配器
✅.toContain()
✅.toHaveLength()
✅.toMatch()
✅.toContainEqual()
✅.stringContaining()
✅.stringMatching()
✅.arrayContaining()

物件匹配器

狀態匹配器
✅.toHaveProperty()
✅.toMatchObject()
✅.toContainAllKeys()
✅.toContainValue()
✅.toContainValues()
✅.toContainAllValues()
✅.toContainAnyValues()
✅.objectContaining()

數字匹配器

狀態匹配器
✅.toBeCloseTo()
✅.closeTo()
✅.toBeGreaterThan()
✅.toBeGreaterThanOrEqual()
✅.toBeLessThan()
✅.toBeLessThanOrEqual()

函式和類匹配器

狀態匹配器
✅.toThrow()
✅.toBeInstanceOf()

Promise 匹配器

狀態匹配器
✅.resolves()
✅.rejects()

模擬函式匹配器

狀態匹配器
✅.toHaveBeenCalled()
✅.toHaveBeenCalledTimes()
✅.toHaveBeenCalledWith()
✅.toHaveBeenLastCalledWith()
✅.toHaveBeenNthCalledWith()
✅.toHaveReturned()
✅.toHaveReturnedTimes()
✅.toHaveReturnedWith()
✅.toHaveLastReturnedWith()
✅.toHaveNthReturnedWith()

快照匹配器

狀態匹配器
✅.toMatchSnapshot()
✅.toMatchInlineSnapshot()
✅.toThrowErrorMatchingSnapshot()
✅.toThrowErrorMatchingInlineSnapshot()

工具匹配器

狀態匹配器
✅.extend
✅.anything()
✅.any()
✅.assertions()
✅.hasAssertions()

未實現

狀態匹配器
❌.addSnapshotSerializer()

最佳實踐

使用描述性的測試名稱

example.test.ts
// 好的範例
test("應計算多個商品含稅總價", () => {
  // 測試實現
});

// 避免
test("價格計算", () => {
  // 測試實現
});

分組相關測試

auth.test.ts
describe("使用者認證", () => {
  describe("有效憑證時", () => {
    test("應傳回使用者資料", () => {
      // 測試實現
    });

    test("應設定認證令牌", () => {
      // 測試實現
    });
  });

  describe("無效憑證時", () => {
    test("應丟擲認證錯誤", () => {
      // 測試實現
    });
  });
});

使用合適的匹配器

auth.test.ts
// 好的範例:使用具體匹配器
expect(users).toHaveLength(3);
expect(user.email).toContain("@");
expect(response.status).toBeGreaterThanOrEqual(200);

// 避免:將邏輯表示式用 toBe 包裹
expect(users.length === 3).toBe(true);
expect(user.email.includes("@")).toBe(true);
expect(response.status >= 200).toBe(true);

測試錯誤情況

example.test.ts
test("無效輸入應丟擲錯誤", () => {
  expect(() => {
    validateEmail("not-an-email");
  }).toThrow("郵箱格式無效");
});

test("應處理非同步錯誤", async () => {
  await expect(async () => {
    await fetchUser("invalid-id");
  }).rejects.toThrow("未找到使用者");
});

使用初始化和清理

example.test.ts
import { beforeEach, afterEach, test } from "bun:test";

let testUser;

beforeEach(() => {
  testUser = createTestUser();
});

afterEach(() => {
  cleanupTestUser(testUser);
});

test("應更新使用者資料", () => {
  // 在測試中使用 testUser
});