使用 Jest、Vitest 和 Testing Library 實作全面的測試策略,涵蓋單元測試、整合測試與端對端測試,並包含模擬(mocking)、測試固定資料(fixtures)與測試驅動開發(TDD)。適用於撰寫 JavaScript/TypeScript 測試、建立測試基礎設施或實作 TDD/BDD 工作流程。
JavaScript 測試模式
在 JavaScript/TypeScript 應用程式中使用現代測試框架與最佳實務,實作穩健測試策略的完整指南。
何時使用此技能
- 為新專案建立測試基礎設施
- 為函式與類別撰寫單元測試
- 為 API 與服務建立整合測試
- 為使用者流程實作端對端測試
- 模擬外部相依項目與 API
- 測試 React、Vue 或其他前端元件
- 實作測試驅動開發(TDD)
- 在 CI/CD 管線中設定持續測試
測試框架
Jest - 功能完整的測試框架
設定:
// jest.config.ts
import type { Config } from "jest";
const config: Config = {
preset: "ts-jest",
testEnvironment: "node",
roots: ["<rootDir>/src"],
testMatch: ["**/__tests__/**/*.ts", "**/?(*.)+(spec|test).ts"],
collectCoverageFrom: [
"src/**/*.ts",
"!src/**/*.d.ts",
"!src/**/*.interface.ts",
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
setupFilesAfterEnv: ["<rootDir>/src/test/setup.ts"],
};
export default config;
Vitest - 快速、Vite 原生測試
設定:
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
exclude: ["**/*.d.ts", "**/*.config.ts", "**/dist/**"],
},
setupFiles: ["./src/test/setup.ts"],
},
});
單元測試模式
模式 1:測試純函式
// utils/calculator.ts
export function add(a: number, b: number): number {
return a + b;
}
export function divide(a: number, b: number): number {
if (b === 0) {
throw new Error("Division by zero");
}
return a / b;
}
// utils/calculator.test.ts
import { describe, it, expect } from "vitest";
import { add, divide } from "./calculator";
describe("Calculator", () => {
describe("add", () => {
it("should add two positive numbers", () => {
expect(add(2, 3)).toBe(5);
});
it("should add negative numbers", () => {
expect(add(-2, -3)).toBe(-5);
});
it("should handle zero", () => {
expect(add(0, 5)).toBe(5);
expect(add(5, 0)).toBe(5);
});
});
describe("divide", () => {
it("should divide two numbers", () => {
expect(divide(10, 2)).toBe(5);
});
it("should handle decimal results", () => {
expect(divide(5, 2)).toBe(2.5);
});
it("should throw error when dividing by zero", () => {
expect(() => divide(10, 0)).toThrow("Division by zero");
});
});
});
模式 2:測試類別
// services/user.service.ts
export class UserService {
private users: Map<string, User> = new Map();
create(user: User): User {
if (this.users.has(user.id)) {
throw new Error("User already exists");
}
this.users.set(user.id, user);
return user;
}
findById(id: string): User | undefined {
return this.users.get(id);
}
update(id: string, updates: Partial<User>): User {
const user = this.users.get(id);
if (!user) {
throw new Error("User not found");
}
const updated = { ...user, ...updates };
this.users.set(id, updated);
return updated;
}
delete(id: string): boolean {
return this.users.delete(id);
}
}
// services/user.service.test.ts
import { describe, it, expect, beforeEach } from "vitest";
import { UserService } from "./user.service";
describe("UserService", () => {
let service: UserService;
beforeEach(() => {
service = new UserService();
});
describe("create", () => {
it("should create a new user", () => {
const user = { id: "1", name: "John", email: "john@example.com" };
const created = service.create(user);
expect(created).toEqual(user);
expect(service.findById("1")).toEqual(user);
});
it("should throw error if user already exists", () => {
const user = { id: "1", name: "John", email: "john@example.com" };
service.create(user);
expect(() => service.create(user)).toThrow("User already exists");
});
});
describe("update", () => {
it("should update existing user", () => {
const user = { id: "1", name: "John", email: "john@example.com" };
service.create(user);
const updated = service.update("1", { name: "Jane" });
expect(updated.name).toBe("Jane");
expect(updated.email).toBe("john@example.com");
});
it("should throw error if user not found", () => {
expect(() => service.update("999", { name: "Jane" })).toThrow(
"User not found",
);
});
});
});
模式 3:測試非同步函式
// services/api.service.ts
export class ApiService {
async fetchUser(id: string): Promise<User> {
const response = await fetch(`https://api.example.com/users/${id}`);
if (!response.ok) {
throw new Error("User not found");
}
return response.json();
}
async createUser(user: CreateUserDTO): Promise<User> {
const response = await fetch("https://api.example.com/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(user),
});
return response.json();
}
}
// services/api.service.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { ApiService } from "./api.service";
// 全域模擬 fetch
global.fetch = vi.fn();
describe("ApiService", () => {
let service: ApiService;
beforeEach(() => {
service = new ApiService();
vi.clearAllMocks();
});
describe("fetchUser", () => {
it("should fetch user successfully", async () => {
const mockUser = { id: "1", name: "John", email: "john@example.com" };
(fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => mockUser,
});
const user = await service.fetchUser("1");
expect(user).toEqual(mockUser);
expect(fetch).toHaveBeenCalledWith("https://api.example.com/users/1");
});
it("should throw error if user not found", async () => {
(fetch as any).mockResolvedValueOnce({
ok: false,
});
await expect(service.fetchUser("999")).rejects.toThrow("User not found");
});
});
describe("createUser", () => {
it("should create user successfully", async () => {
const newUser = { name: "John", email: "john@example.com" };
const createdUser = { id: "1", ...newUser };
(fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => createdUser,
});
const user = await service.createUser(newUser);
expect(user).toEqual(createdUser);
expect(fetch).toHaveBeenCalledWith(
"https://api.example.com/users",
expect.objectContaining({
method: "POST",
body: JSON.stringify(newUser),
}),
);
});
});
});
模擬模式
模式 1:模擬模組
// services/email.service.ts
import nodemailer from "nodemailer";
export class EmailService {
private transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: 587,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
async sendEmail(to: string, subject: string, html: string) {
await this.transporter.sendMail({
from: process.env.EMAIL_FROM,
to,
subject,
html,
});
}
}
// services/email.service.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { EmailService } from "./email.service";
vi.mock("nodemailer", () => ({
default: {
createTransport: vi.fn(() => ({
sendMail: vi.fn().mockResolvedValue({ messageId: "123" }),
})),
},
}));
describe("EmailService", () => {
let service: EmailService;
beforeEach(() => {
service = new EmailService();
});
it("should send email successfully", async () => {
await service.sendEmail(
"test@example.com",
"Test Subject",
"<p>Test Body</p>",
);
expect(service["transporter"].sendMail).toHaveBeenCalledWith(
expect.objectContaining({
to: "test@example.com",
subject: "Test Subject",
}),
);
});
});
模式 2:依賴注入進行測試
// services/user.service.ts
export interface IUserRepository {
findById(id: string): Promise<User | null>;
create(user: User): Promise<User>;
}
export class UserService {
constructor(private userRepository: IUserRepository) {}
async getUser(id: string): Promise<User> {
const user = await this.userRepository.findById(id);
if (!user) {
throw new Error("User not found");
}
return user;
}
async createUser(userData: CreateUserDTO): Promise<User> {
// 商業邏輯
const user = { id: generateId(), ...userData };
return this.userRepository.create(user);
}
}
// services/user.service.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { UserService, IUserRepository } from "./user.service";
describe("UserService", () => {
let service: UserService;
let mockRepository: IUserRepository;
beforeEach(() => {
mockRepository = {
findById: vi.fn(),
create: vi.fn(),
};
service = new UserService(mockRepository);
});
describe("getUser", () => {
it("should return user if found", async () => {
const mockUser = { id: "1", name: "John", email: "john@example.com" };
vi.mocked(mockRepository.findById).mockResolvedValue(mockUser);
const user = await service.getUser("1");
expect(user).toEqual(mockUser);
expect(mockRepository.findById).toHaveBeenCalledWith("1");
});
it("should throw error if user not found", async () => {
vi.mocked(mockRepository.findById).mockResolvedValue(null);
await expect(service.getUser("999")).rejects.toThrow("User not found");
});
});
describe("createUser", () => {
it("should create user successfully", async () => {
const userData = { name: "John", email: "john@example.com" };
const createdUser = { id: "1", ...userData };
vi.mocked(mockRepository.create).mockResolvedValue(createdUser);
const user = await service.createUser(userData);
expect(user).toEqual(createdUser);
expect(mockRepository.create).toHaveBeenCalled();
});
});
});
模式 3:監視函式
// utils/logger.ts
export const logger = {
info: (message: string) => console.log(`INFO: ${message}`),
error: (message: string) => console.error(`ERROR: ${message}`),
};
// services/order.service.ts
import { logger } from "../utils/logger";
export class OrderService {
async processOrder(orderId: string): Promise<void> {
logger.info(`Processing order ${orderId}`);
// 處理訂單邏輯
logger.info(`Order ${orderId} processed successfully`);
}
}
// services/order.service.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { OrderService } from "./order.service";
import { logger } from "../utils/logger";
describe("OrderService", () => {
let service: OrderService;
let loggerSpy: any;
beforeEach(() => {
service = new OrderService();
loggerSpy = vi.spyOn(logger, "info");
});
afterEach(() => {
loggerSpy.mockRestore();
});
it("should log order processing", async () => {
await service.processOrder("123");
expect(loggerSpy).toHaveBeenCalledWith("Processing order 123");
expect(loggerSpy).toHaveBeenCalledWith("Order 123 processed successfully");
expect(loggerSpy).toHaveBeenCalledTimes(2);
});
});
整合測試
整合測試使用 supertest 和測試資料庫實例來驗證真實的資料庫操作與 HTTP 端點。務必在 beforeEach 中清空資料表,並在 afterAll 中進行清理。
完整的 API 整合測試範例(supertest + PostgreSQL)與資料庫儲存庫整合測試,請參閱 references/advanced-testing-patterns.md。
前端測試(Testing Library)
測試 React 元件時,將其渲染並透過角色(role)、佔位符(placeholder)或測試 ID 進行查詢。使用 renderHook 搭配 act 測試鉤子。優先使用語意化查詢(getByRole、getByPlaceholderText),而非 data-testid。
完整的 React 元件測試範例(UserForm、使用 renderHook/act 的鉤子),請參閱 references/advanced-testing-patterns.md。
測試固定資料與工廠
使用 @faker-js/faker 產生逼真的測試資料工廠。工廠函式接受可選的 overrides 參數,讓測試只需設定關心的欄位:
// tests/fixtures/user.fixture.ts
import { faker } from "@faker-js/faker";
export function createUserFixture(overrides?: Partial<User>): User {
return {
id: faker.string.uuid(),
name: faker.person.fullName(),
email: faker.internet.email(),
createdAt: faker.date.past(),
...overrides,
};
}
關於快照測試、涵蓋率設定、測試組織模式、Promise 測試與計時器模擬,請參閱 references/advanced-testing-patterns.md。
最佳實務
- 遵循 AAA 模式:安排(Arrange)、執行(Act)、斷言(Assert)
- 每個測試一個斷言:或邏輯相關的斷言
- 描述性的測試名稱:應說明測試的內容
- 使用 beforeEach/afterEach:進行設定與清理
- 模擬外部相依項目:保持測試隔離
- 測試邊界情況:不僅是快樂路徑
- 避免實作細節:測試行為,而非實作
- 使用測試工廠:產生一致的測試資料
- 保持測試快速:模擬慢速操作
- 先寫測試(TDD):盡可能採用
- 維持測試涵蓋率:目標 80% 以上
- 使用 TypeScript:確保型別安全的測試
- 測試錯誤處理:不僅是成功案例
- 謹慎使用 data-testid:優先使用語意化查詢
- 測試後清理:防止測試污染






