关于 unit-test-generator
用于生成单元测试的技能,覆盖函数、类、异步代码和 React 组件。内容强调 AAA 结构、边界与错误场景,以及覆盖率记录。
主要功能
- 01按 AAA 结构组织测试
- 02覆盖边界与错误场景
- 03测试异步函数
- 04测试类与 React 组件
- 05覆盖率说明与检查清单
适用场景
- 01为工具函数起草测试
- 02测试异步服务及失败情况
- 03构建组件和类的测试套件
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ patricio0312rev/ skills --skill unit-test-generator安装后在 Claude Code 中输入/unit-test-generator确认已加载。
详细介绍
单元测试生成器
使用 AAA 模式生成全面的单元测试,并覆盖边缘情况。
AAA 模式模板
// tests/utils/validator.test.ts
import { describe, it, expect } from "vitest";
import { validateEmail } from "@/utils/validator";
describe("validateEmail", () => {
it("should return true for valid email", () => {
// Arrange
const email = "[email protected]";
// Act
const result = validateEmail(email);
// Assert
expect(result).toBe(true);
});
it("should return false for invalid email - missing @", () => {
// Arrange
const email = "userexample.com";
// Act
const result = validateEmail(email);
// Assert
expect(result).toBe(false);
});
it("should return false for invalid email - missing domain", () => {
// Arrange
const email = "user@";
// Act
const result = validateEmail(email);
// Assert
expect(result).toBe(false);
});
});
全面的测试用例
// src/utils/calculator.ts
export function divide(a: number, b: number): number {
if (b === 0) {
throw new Error("Division by zero");
}
return a / b;
}
// tests/utils/calculator.test.ts
describe("divide", () => {
describe("happy path", () => {
it("should divide positive numbers", () => {
expect(divide(10, 2)).toBe(5);
});
it("should divide negative numbers", () => {
expect(divide(-10, 2)).toBe(-5);
expect(divide(10, -2)).toBe(-5);
expect(divide(-10, -2)).toBe(5);
});
it("should handle decimal results", () => {
expect(divide(10, 3)).toBeCloseTo(3.333, 3);
});
});
describe("edge cases", () => {
it("should handle zero dividend", () => {
expect(divide(0, 5)).toBe(0);
});
it("should handle very large numbers", () => {
expect(divide(Number.MAX_SAFE_INTEGER, 2)).toBe(
Number.MAX_SAFE_INTEGER / 2
);
});
it("should handle very small numbers", () => {
expect(divide(0.0001, 0.0001)).toBe(1);
});
});
describe("error cases", () => {
it("should throw error when dividing by zero", () => {
expect(() => divide(10, 0)).toThrow("Division by zero");
});
it("should throw error when dividing by negative zero", () => {
expect(() => divide(10, -0)).toThrow("Division by zero");
});
});
});
异步函数测试
// src/services/userService.ts
export async function fetchUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error(`User not found: ${id}`);
}
return response.json();
}
// tests/services/userService.test.ts
describe("fetchUser", () => {
beforeEach(() => {
global.fetch = vi.fn();
});
afterEach(() => {
vi.resetAllMocks();
});
it("should fetch user successfully", async () => {
// Arrange
const mockUser = { id: "123", name: "John" };
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => mockUser,
});
// Act
const user = await fetchUser("123");
// Assert
expect(user).toEqual(mockUser);
expect(global.fetch).toHaveBeenCalledWith("/api/users/123");
});
it("should throw error when user not found", async () => {
// Arrange
(global.fetch as any).mockResolvedValueOnce({
ok: false,
});
// Act & Assert
await expect(fetchUser("999")).rejects.toThrow("User not found: 999");
});
it("should handle network error", async () => {
// Arrange
(global.fetch as any).mockRejectedValueOnce(new Error("Network error"));
// Act & Assert
await expect(fetchUser("123")).rejects.toThrow("Network error");
});
});
类测试
// src/models/ShoppingCart.ts
export class ShoppingCart {
private items: CartItem[] = [];
add(item: CartItem): void {
this.items.push(item);
}
remove(itemId: string): void {
this.items = this.items.filter((i) => i.id !== itemId);
}
getTotal(): number {
return this.items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
}
clear(): void {
this.items = [];
}
}
// tests/models/ShoppingCart.test.ts
describe("ShoppingCart", () => {
let cart: ShoppingCart;
beforeEach(() => {
cart = new ShoppingCart();
});
describe("add", () => {
it("should add item to cart", () => {
// Arrange
const item = { id: "1", name: "Product", price: 10, quantity: 1 };
// Act
cart.add(item);
// Assert
expect(cart.getTotal()).toBe(10);
});
it("should add multiple items", () => {
// Arrange
const item1 = { id: "1", name: "Product 1", price: 10, quantity: 1 };
const item2 = { id: "2", name: "Product 2", price: 20, quantity: 2 };
// Act
cart.add(item1);
cart.add(item2);
// Assert
expect(cart.getTotal()).toBe(50); // 10 + (20 * 2)
});
});
describe("remove", () => {
it("should remove item from cart", () => {
// Arrange
const item = { id: "1", name: "Product", price: 10, quantity: 1 };
cart.add(item);
// Act
cart.remove("1");
// Assert
expect(cart.getTotal()).toBe(0);
});
it("should not throw when removing non-existent item", () => {
// Act & Assert
expect(() => cart.remove("999")).not.toThrow();
});
});
describe("getTotal", () => {
it("should return 0 for empty cart", () => {
expect(cart.getTotal()).toBe(0);
});
it("should calculate total with quantities", () => {
// Arrange
cart.add({ id: "1", name: "Product", price: 10, quantity: 3 });
// Assert
expect(cart.getTotal()).toBe(30);
});
});
describe("clear", () => {
it("should remove all items", () => {
// Arrange
cart.add({ id: "1", name: "Product 1", price: 10, quantity: 1 });
cart.add({ id: "2", name: "Product 2", price: 20, quantity: 1 });
// Act
cart.clear();
// Assert
expect(cart.getTotal()).toBe(0);
});
});
});
React 组件测试
// src/components/Counter.tsx
export function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
<button onClick={() => setCount(0)}>Reset</button>
</div>
);
}
// tests/components/Counter.test.tsx
import { render, screen, fireEvent } from "@testing-library/react";
describe("Counter", () => {
it("should render with initial count of 0", () => {
// Arrange & Act
render(<Counter />);
// Assert
expect(screen.getByText("Count: 0")).toBeInTheDocument();
});
it("should increment count when button clicked", () => {
// Arrange
render(<Counter />);
const button = screen.getByText("Increment");
// Act
fireEvent.click(button);
// Assert
expect(screen.getByText("Count: 1")).toBeInTheDocument();
});
it("should increment multiple times", () => {
// Arrange
render(<Counter />);
const button = screen.getByText("Increment");
// Act
fireEvent.click(button);
fireEvent.click(button);
fireEvent.click(button);
// Assert
expect(screen.getByText("Count: 3")).toBeInTheDocument();
});
it("should reset count to 0", () => {
// Arrange
render(<Counter />);
fireEvent.click(screen.getByText("Increment"));
// Act
fireEvent.click(screen.getByText("Reset"));
// Assert
expect(screen.getByText("Count: 0")).toBeInTheDocument();
});
});
边缘情况类别
// Test case generation template
interface TestCase {
category: "happy-path" | "edge-case" | "error-case";
description: string;
input: any;
expectedOutput: any;
}
const testCases: TestCase[] = [
// Happy path
{
category: "happy-path",
description: "typical valid input",
input: "[email protected]",
expectedOutput: true,
},
// Edge cases
{
category: "edge-case",
description: "empty string",
input: "",
expectedOutput: false,
},
{
category: "edge-case",
description: "null input",
input: null,
expectedOutput: false,
},
{
category: "edge-case",
description: "undefined input",
input: undefined,
expectedOutput: false,
},
{
category: "edge-case",
description: "whitespace only",
input: " ",
expectedOutput: false,
},
{
category: "edge-case",
description: "very long email",
input: "a".repeat(1000) + "@example.com",
expectedOutput: false,
},
// Error cases
{
category: "error-case",
description: "invalid format - no @",
input: "userexample.com",
expectedOutput: false,
},
{
category: "error-case",
description: "invalid format - multiple @",
input: "user@@example.com",
expectedOutput: false,
},
];
覆盖率说明
/**
* Coverage targets for this module:
*
* - Line coverage: 100% (all lines executed)
* - Branch coverage: 100% (all if/else paths tested)
* - Function coverage: 100% (all functions called)
* - Statement coverage: 100% (all statements executed)
*
* Untested scenarios (intentionally):
* - None - module is fully covered
*
* High-risk areas requiring extra attention:
* - Division by zero handling
* - Null/undefined input handling
* - Type coercion edge cases
*/
测试文件结构
src/
utils/
validator.ts
calculator.ts
services/
userService.ts
models/
ShoppingCart.ts
components/
Counter.tsx
tests/
utils/
validator.test.ts
calculator.test.ts
services/
userService.test.ts
models/
ShoppingCart.test.ts
components/
Counter.test.tsx
测试生成脚本
// scripts/generate-tests.ts
import * as fs from "fs";
import * as path from "path";
function generateTestTemplate(filePath: string): string {
const fileName = path.basename(filePath, path.extname(filePath));
const className = fileName.charAt(0).toUpperCase() + fileName.slice(1);
return `
import { describe, it, expect } from 'vitest';
import { ${className} } from '@/${filePath}';
describe('${className}', () => {
describe('happy path', () => {
it('should handle typical case', () => {
// Arrange
const input = /* TODO */;
// Act
const result = ${className}(input);
// Assert
expect(result).toBe(/* TODO */);
});
});
describe('edge cases', () => {
it('should handle null input', () => {
// Arrange
const input = null;
// Act & Assert
expect(() => ${className}(input)).toThrow();
});
it('should handle empty input', () => {
// Arrange
const input = '';
// Act
const result = ${className}(input);
// Assert
expect(result).toBe(/* TODO */);
});
});
describe('error cases', () => {
it('should throw error for invalid input', () => {
// Arrange
const input = /* TODO */;
// Act & Assert
expect(() => ${className}(input)).toThrow('Invalid input');
});
});
});
`.trim();
}
最佳实践
- AAA 模式:Arrange-Act-Assert(准备-执行-断言)结构
- 每个测试一个断言:让测试保持聚焦
- 描述性名称:“当[条件]时,应[预期行为]”
- 测试边缘情况:null、undefined、空值、最大值
- 测试错误:验证错误处理
- 隔离测试:测试之间不共享状态
- 快速测试:单元测试应在毫秒内运行
输出检查清单
- 已创建与源代码结构相匹配的测试文件
- 始终使用 AAA 模式
- 已覆盖正常情况
- 已识别并测试边缘情况
- 已测试错误情况
- 已使用正确的 await 测试异步函数
- 已使用模拟对象处理依赖项
- 已记录覆盖率说明
- 测试名称具有描述性
- 已适当使用设置/清理钩子
评论
支持 Markdown