mcpField
U
unit-test-generator

unit-test-generator

github.com/patricio0312rev测试与质量63365 次安装41 周前更新
代码仓库

关于 unit-test-generator

用于生成单元测试的技能,覆盖函数、类、异步代码和 React 组件。内容强调 AAA 结构、边界与错误场景,以及覆盖率记录。

主要功能

  1. 01按 AAA 结构组织测试
  2. 02覆盖边界与错误场景
  3. 03测试异步函数
  4. 04测试类与 React 组件
  5. 05覆盖率说明与检查清单

适用场景

  1. 01为工具函数起草测试
  2. 02测试异步服务及失败情况
  3. 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();
}

最佳实践

  1. AAA 模式:Arrange-Act-Assert(准备-执行-断言)结构
  2. 每个测试一个断言:让测试保持聚焦
  3. 描述性名称:“当[条件]时,应[预期行为]”
  4. 测试边缘情况:null、undefined、空值、最大值
  5. 测试错误:验证错误处理
  6. 隔离测试:测试之间不共享状态
  7. 快速测试:单元测试应在毫秒内运行

输出检查清单

  • 已创建与源代码结构相匹配的测试文件
  • 始终使用 AAA 模式
  • 已覆盖正常情况
  • 已识别并测试边缘情况
  • 已测试错误情况
  • 已使用正确的 await 测试异步函数
  • 已使用模拟对象处理依赖项
  • 已记录覆盖率说明
  • 测试名称具有描述性
  • 已适当使用设置/清理钩子

评论

支持 Markdown