用 Vitest 寫 API 單元測試(教學筆記)
這份筆記不是專門針對某個專案,而是整理「如何用 Vitest 測 API 函式(例如 brand API)」的通用教學,重點在:
- 學會 mock HTTP client(例如
requestClient/axios) - 驗證 請求是否正確發出(method / URL / params / body)
- 驗證 回傳資料是否符合前端約定的型別與結構
目錄
- 基本概念
- 建立測試檔與結構
- Mock HTTP client
- 範例一:查詢清單 API
- 範例二:建立 / 更新 / 刪除 API
- 範例三:barrel 檔(index.ts)測試
- 常用斷言模式
- 檢查清單
基本概念
- 單元測試的單位:一個 API 函式(例如
listBrands,createBrand),不發真實 HTTP。 - 目標:
- 輸入什麼參數,應該用什麼 method / URL / params / body 去呼叫 HTTP client。
- HTTP client 回傳什麼假資料(mock),API 函式應該回傳什麼結果(原樣 or 轉換過)。
建立測試檔與結構
- 檔名慣例:與被測檔案同名,加上
.test.ts(例如brand-api.ts對應brand-api.test.ts)。 - 使用 Vitest 的基本結構:
import { describe, it, expect, vi, beforeEach } from "vitest";
describe("某個 API 模組", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("應使用正確的 URL 與 method 呼叫 HTTP client", async () => {
// arrange / act / assert
});
});
Mock HTTP client
假設你的專案有一個共用的 HTTP client:
// request.ts
export const requestClient = {
get: async (url: string, config?: unknown) => {
/* ... */
},
post: async (url: string, body?: unknown, config?: unknown) => {
/* ... */
},
put: async (url: string, body?: unknown, config?: unknown) => {
/* ... */
},
delete: async (url: string, config?: unknown) => {
/* ... */
},
};
在測試裡你會這樣 mock:
import { describe, it, expect, vi, beforeEach } from "vitest";
import { requestClient } from "#/api/request";
import { listBrands } from "./brand-api";
vi.mock("#/api/request", () => ({
requestClient: {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
},
}));
beforeEach(() => {
vi.clearAllMocks();
});
重點:mock 完之後,
requestClient.get等都變成可斷言呼叫次數與參數的 spy function。
範例一:查詢清單 API
假設有一個查詢品牌清單的函式:
// brand-api.ts
import { requestClient } from "#/api/request";
export interface ListBrandParams {
page?: number;
page_size?: number;
keyword?: string;
}
export async function listBrands(params?: ListBrandParams) {
return requestClient.get("/v1/brands", { params });
}
對應的測試可以這樣寫:
// brand-api.test.ts
import { beforeEach, describe, expect, it, vi } from "vitest";
import { requestClient } from "#/api/request";
import { listBrands } from "./brand-api";
vi.mock("#/api/request", () => ({
requestClient: {
get: vi.fn(),
},
}));
beforeEach(() => {
vi.clearAllMocks();
});
describe("listBrands", () => {
it("有帶 params 時,應用正確的 URL 與 params 呼叫 GET", async () => {
const mockGet = vi.mocked(requestClient.get);
const params = { page: 1, page_size: 20, keyword: "Acme" };
const resolved = { items: [], total: 0 } as never;
mockGet.mockResolvedValue(resolved);
const result = await listBrands(params);
expect(mockGet).toHaveBeenCalledTimes(1);
expect(mockGet).toHaveBeenCalledWith("/v1/brands", { params });
expect(result).toBe(resolved);
});
it("沒帶 params 時,也應明確傳入 { params: undefined } 或約定的預設值", async () => {
const mockGet = vi.mocked(requestClient.get);
const resolved = { items: [], total: 0 } as never;
mockGet.mockResolvedValue(resolved);
const result = await listBrands();
expect(mockGet).toHaveBeenCalledTimes(1);
expect(mockGet).toHaveBeenCalledWith("/v1/brands", { params: undefined });
expect(result).toBe(resolved);
});
});
範例二:建立 / 更新 / 刪除 API
同一個模組裡還可能有 CRUD:
// brand-api.ts
export interface CreateBrandPayload {
name: string;
description?: string;
}
export async function createBrand(payload: CreateBrandPayload) {
return requestClient.post("/v1/brands", payload);
}
export async function updateBrand(id: string, payload: CreateBrandPayload) {
return requestClient.put(`/v1/brands/${id}`, payload);
}
export async function deleteBrand(id: string) {
return requestClient.delete(`/v1/brands/${id}`);
}
對應的測試:
import { beforeEach, describe, expect, it, vi } from "vitest";
import { requestClient } from "#/api/request";
import { createBrand, updateBrand, deleteBrand } from "./brand-api";
vi.mock("#/api/request", () => ({
requestClient: {
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
},
}));
beforeEach(() => {
vi.clearAllMocks();
});
describe("brand CRUD API", () => {
it("createBrand 應用正確 payload 呼叫 POST /v1/brands", async () => {
const mockPost = vi.mocked(requestClient.post);
const payload = { name: "Acme", description: "desc" };
const resolved = { id: "1", name: "Acme" } as never;
mockPost.mockResolvedValue(resolved);
const result = await createBrand(payload);
expect(mockPost).toHaveBeenCalledTimes(1);
expect(mockPost).toHaveBeenCalledWith("/v1/brands", payload);
expect(result).toBe(resolved);
});
it("updateBrand 應用正確 URL 與 payload 呼叫 PUT", async () => {
const mockPut = vi.mocked(requestClient.put);
const id = "42";
const payload = { name: "Updated" };
const resolved = { id: "42", name: "Updated" } as never;
mockPut.mockResolvedValue(resolved);
const result = await updateBrand(id, payload);
expect(mockPut).toHaveBeenCalledTimes(1);
expect(mockPut).toHaveBeenCalledWith("/v1/brands/42", payload);
expect(result).toBe(resolved);
});
it("deleteBrand 應用正確 URL 呼叫 DELETE", async () => {
const mockDelete = vi.mocked(requestClient.delete);
const id = "7";
const resolved = { message: "ok" } as never;
mockDelete.mockResolvedValue(resolved);
const result = await deleteBrand(id);
expect(mockDelete).toHaveBeenCalledTimes(1);
expect(mockDelete).toHaveBeenCalledWith("/v1/brands/7");
expect(result).toBe(resolved);
});
});
範例三:barrel 檔(index.ts)測試
有時候我們會有一個 index.ts 只負責把多個 API 模組 re-export 出來:
// index.ts
export * from "./brand-api";
export * from "./user-api";
export type { ApiResponseEnvelope } from "./types";
這種檔案本身 不負責打 API,所以測試目標是:
- 避免重構時誤刪某個 export。
- 確認重要的函式與型別,仍然可以從
index.ts匯入。
測試可以寫成:
import type { ApiResponseEnvelope } from "./index";
import { describe, expect, it } from "vitest";
import { listBrands, createBrand } from "./index";
describe("api/core index(barrel)", () => {
it("應自 index re-export 子模組的 API 函式(抽樣)", () => {
expect(typeof listBrands).toBe("function");
expect(typeof createBrand).toBe("function");
});
it("應 re-export 共用型別 ApiResponseEnvelope", () => {
const sample: ApiResponseEnvelope<{ id: string }> = {
success: true,
data: { id: "1" },
meta: { request_id: "r", timestamp: "t" },
};
expect(sample).toMatchObject({
success: true,
data: { id: "1" },
});
});
});
常用斷言模式
- 呼叫次數
expect(mockFn).toHaveBeenCalledTimes(1);
- 呼叫參數
expect(mockFn).toHaveBeenCalledWith('/v1/brands', { params });
- 回傳值(不轉換)
expect(result).toBe(resolved);
- 回傳值(有轉換)
- 用
toEqual或toMatchObject:
- 用
expect(result).toMatchObject({
id: "1",
name: "Acme",
});
檢查清單
- 測試檔有
describe/it結構,名稱清楚描述行為 - 有 mock HTTP client(例如
requestClient/axios),不發真實請求 - 斷言 URL / method / params / body 是否正確
- 斷言 呼叫次數,避免意外多次呼叫
- 斷言 回傳結構與型別(必要欄位、有無轉換、邊界情況)
- 若有 barrel 檔(
index.ts),有寫簡單測試避免 export 被誤刪