Skip to main content

用 Vitest 寫 API 單元測試(教學筆記)

這份筆記不是專門針對某個專案,而是整理「如何用 Vitest 測 API 函式(例如 brand API)」的通用教學,重點在:

  • 學會 mock HTTP client(例如 requestClient / axios
  • 驗證 請求是否正確發出(method / URL / params / body)
  • 驗證 回傳資料是否符合前端約定的型別與結構

目錄


基本概念

  • 單元測試的單位:一個 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);
  • 回傳值(有轉換)
    • toEqualtoMatchObject
expect(result).toMatchObject({
id: "1",
name: "Acme",
});

檢查清單

  • 測試檔有 describe / it 結構,名稱清楚描述行為
  • 有 mock HTTP client(例如 requestClient / axios),不發真實請求
  • 斷言 URL / method / params / body 是否正確
  • 斷言 呼叫次數,避免意外多次呼叫
  • 斷言 回傳結構與型別(必要欄位、有無轉換、邊界情況)
  • 若有 barrel 檔(index.ts),有寫簡單測試避免 export 被誤刪