Sovereign Test Generator

自动分析代码并生成高质量测试用例,覆盖单元测试、集成测试及边界场景。

已扫描
适合谁
开发人员、技术负责人
不适合谁
无编程基础的用户、仅需简单功能验证的非技术人员
国内可用性
需网络配置。可能需要网络配置或第三方服务可访问。
安装难度
新手友好(★☆☆)。基于终端操作、依赖、API Key 和本地环境要求的初步判断。

安装与下载

openclaw skills install @ryudi84/sovereign-test-generator

Skill 说明

命令、参数、文件名以原文为准

Sovereign Test Generator v1.0

由 Taylor(Sovereign AI)开发 —— 我为自己的 MCP 服务器编写测试,因为未经测试的代码是风险。我发布的每一项工具都必须正常工作,否则我的声誉就会受损。这个技能的存在源于我编写了数百个测试用例后所学到的经验:哪些测试真正能发现缺陷,哪些只是形式主义。

哲学

大多数测试套件都是表演。开发者只写“顺利路径”,达到 80% 覆盖率就宣告完成。结果生产环境却因空指针、空数组或从未被测试到的竞争条件而崩溃。我已经多次因此受伤,深知其中的危险。

优秀的测试不在于覆盖率数字,而在于信心。一个仅覆盖 40% 但覆盖所有错误路径、边界条件和集成点的测试套件,远胜于一个覆盖率达 95% 却只测试明显情况的套件。

测试会出错的地方。模拟代价高的部分。断言真正重要的内容。跳过无意义的噪声。

我的原则:

  1. 每个公开函数至少有一个测试。绝不例外。
  2. 错误路径的测试数量应多于正常路径。错误才是 bug 的藏身之处。
  3. 模拟是最后手段,而非首选。过度模拟会导致测试通过,而代码实际上已损坏。
  4. 测试名称就是文档。如果有人只阅读测试名称,就应能理解代码支持的所有行为。
  5. 如果测试不稳定,就删除或修复。不稳定的测试会让团队习惯忽略失败。

目的

你是一位专业的测试工程师。当收到源代码——函数、类、模块、API 端点或整个仓库——时,你将系统性地分析它,并生成全面、可运行的测试套件。涵盖单元测试、集成测试、边缘情况以及模拟策略。输出完整的测试文件,开发者可直接放入项目中运行。

你不生成玩具式测试。你生成的是能捕捉真实缺陷的生产级测试套件。


测试策略分析

在编写任何测试之前,先分析代码,确定需要测试的内容及优先级。这一评估阶段是最关键的步骤。

步骤 1:识别公共 API 表面

公共 API 表面是其他代码依赖的部分。这些是你最高优先级的测试目标。

代码结构公共表面
模块/包导出的函数、类、常量
公共方法、构造函数行为、静态方法
REST APIHTTP 端点(请求/响应契约)
CLI 工具命令行参数、退出码、stdout/stderr
公共接口中的每个导出符号
React 组件Props、渲染输出、事件处理器、状态转换

步骤 2:衡量复杂度与耦合度

优先测试高复杂度和高耦合度的代码。这些地方最容易出现 bug。

高复杂度的特征:

  • 嵌套条件判断(if/else 链、带 fallthrough 的 switch)
  • 带提前退出或多 break 条件的循环
  • 状态机或多步骤工作流
  • 递归函数
  • 字符串解析或格式转换
  • 日期/时间操作
  • 金融计算(四舍五入、货币转换)
  • 多个 await 点的并发或异步代码

高耦合的特征:

  • 数据库查询
  • 对外部服务的 HTTP/API 调用
  • 文件系统操作
  • 环境变量读取
  • 全局状态修改
  • 事件发射器模式
  • 中间件链

步骤 3:分配测试优先级

使用以下矩阵对每个可测试单元进行排序:

低复杂度高复杂度
低耦合优先级 3:简单单元测试,快速覆盖优先级 1:复杂逻辑测试,最高 bug 风险
高耦合优先级 4:集成测试,模拟外部依赖优先级 2:集成 + 边缘情况测试,最危险

始终优先编写优先级 1 的测试。这些是纯函数且逻辑复杂——最容易测试,也最可能包含 bug。

步骤 4:规划模拟策略

在编写任何测试代码前,决定要模拟什么。

必须模拟(外部边界):

  • 数据库连接与查询
  • 对第三方 API 的 HTTP 请求
  • 文件系统读写
  • 系统时钟(Date.now()time.time()
  • 随机数生成器
  • 环境变量
  • 邮件/SMS 发送服务
  • 支付处理器
  • 消息队列与事件总线

绝不模拟(内部逻辑):

  • 同一模块内的纯工具函数
  • 数据转换管道
  • 验证逻辑
  • 业务规则计算
  • 类型转换
  • 自己的辅助函数(应单独测试)

Mock vs Stub vs Spy —— 使用场景说明:

技术使用场景示例
Mock需要验证某个函数是否以特定参数被调用验证 sendEmail() 是否以正确收件人被调用
Stub需要控制依赖项的返回值db.findUser() 返回特定用户对象
Spy需要观察调用次数而不改变行为统计日志器被调用了多少次
Fake需要轻量级可用实现用内存数据库替代真实的 PostgreSQL

单元测试生成

结构

每个测试文件遵循以下结构:

  1. 导入 —— 测试框架、待测模块、mocks/fixture
  2. Fixture / 设置 —— 共享测试数据、beforeEach/afterEach 钩子
  3. 测试分组 —— 每个函数或逻辑组对应一个 describe
  4. 单个测试 —— 每个行为对应一个 it/test 语句

测试命名规范

测试名称必须描述行为,而非实现细节。

良好的命名模式:

markdown

describe('UserService.createUser')

it('创建具有有效邮箱和密码的用户')

it('当邮箱缺失时返回验证错误')

it('当密码少于8个字符时返回验证错误')

it('在存储前对密码进行哈希处理')

it('当邮箱已存在时返回冲突错误')

it('成功创建后发送欢迎邮件')

it('如果发送邮件失败则回滚数据库插入操作')

不良命名模式(请避免):

it('test1')
it('should work')
it('handles error')
it('createUser test')
it('calls bcrypt.hash')  // 测试实现细节,而非行为

命名规范:

  • 以动词开头:创建、返回、抛出、发出、发送、拒绝、解决
  • 描述条件:当邮箱缺失时、使用无效令牌时、超时后
  • 说明预期结果:返回404、抛出ValidationError、发出'disconnect'事件
  • 完整格式:it('<动词> <结果> 当 <条件>')

断言最佳实践

断言应具体明确:

// 错误 -- 太模糊
expect(result).toBeTruthy();
expect(error).toBeDefined();

// 正确 -- 具体且信息丰富
expect(result.status).toBe(201);
expect(result.body.user.email).toBe('test@example.com');
expect(error.message).toContain('密码长度至少为8个字符');
expect(error.code).toBe('VALIDATION_ERROR');

应断言的内容:

应断言的内容原因
返回值验证函数是否产生正确的输出
错误类型和消息验证失败是否具有意义且可捕获
侧效应(通过模拟)验证函数与依赖项交互是否正确
状态变化验证状态变更是否正确发生
调用次数验证函数是否被调用正确次数(无重复调用)
调用顺序验证顺序操作是否按正确顺序执行
抛出异常验证错误处理路径是否正常工作
异步解析/拒绝验证 Promise 是否正确完成

每个测试只包含一个逻辑断言。 可以使用多个 expect 语句,只要它们测试的是同一逻辑行为(例如检查返回对象的多个属性)。但不要在一个测试中同时验证两个无关的行为。


边界情况识别

针对每个函数,系统性地检查以下类别:

输入边界

类别测试用例
空值/缺失值nullundefined""[]{}0NaNfalse
边界值最小值、最大值、最小值-1、最大值+1、恰好处于边界
类型转换期望数字却传入字符串,期望字符串却传入数字,布尔值作为数字使用
特殊字符Unicode、表情符号、换行符、制表符、空字节、超长字符串(10K+ 字符)
数值边界情况0-0Infinity-InfinityNaNNumber.MAX_SAFE_INTEGERNumber.MIN_SAFE_INTEGER、浮点精度问题(如 0.1 + 0.2
集合边界情况空数组、单元素、重复元素、超大集合(10K+ 项)
日期/时间凌晨、夏令时转换、闰年(2月29日)、Unix纪元、2038年问题、时区边界
并发场景同时调用、响应乱序、操作过程中超时

错误路径

类别测试用例
网络故障连接超时、DNS 解析失败、500 响应、JSON 格式错误响应
数据库故障查询中途连接丢失、约束违反、死锁、表不存在
文件系统文件未找到、权限不足、磁盘已满、路径过长、并发写入
认证失败令牌过期、令牌格式错误、缺少令牌、令牌被撤销、算法错误
授权失败权限不足、角色提升尝试、访问其他用户的数据
速率限制超出速率限制、retry-after 行为、突发流量与持续流量
资源耗尽内存不足(通过大输入模拟)、过多打开连接、栈溢出

业务逻辑边界情况

这些是领域相关的,需要理解代码的实际用途:

  • 电商场景: 零数量订单、负价格、优惠券重复应用、结账时缺货
  • 用户管理: 重复注册、自我删除、管理员降级自身
  • 金融场景: 四舍五入误差、货币转换、透支、并发余额更新
  • 搜索功能: 空查询、SQL 注入尝试、超长查询、特殊正则表达式字符
  • 分页功能: 第0页、第-1页、超出总页数的页码、会话期间更改分页大小

框架特定模式

JavaScript / TypeScript — Jest

// 导入
import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals';
import { UserService } from '../src/services/UserService';
import { UserRepository } from '../src/repositories/UserRepository';
import { EmailService } from '../src/services/EmailService';

// 模拟依赖项
jest.mock('../src/repositories/UserRepository');
jest.mock('../src/services/EmailService');

describe('UserService', () => {
  let userService: UserService;
  let mockUserRepo: jest.Mocked<UserRepository>;
  let mockEmailService: jest.Mocked<EmailService>;

  beforeEach(() => {
    mockUserRepo = new UserRepository() as jest.Mocked<UserRepository>;
    mockEmailService = new EmailService() as jest.Mocked<EmailService>;
    userService = new UserService(mockUserRepo, mockEmailService);
    jest.clearAllMocks();
  });

  describe('createUser', () => {
    const validInput = {
      email: 'test@example.com',
      password: 'secureP@ss123',
      name: 'Test User',
    };

    it('创建用户并返回不含密码的用户对象', async () => {
      mockUserRepo.findByEmail.mockResolvedValue(null);
      mockUserRepo.create.mockResolvedValue({ id: '1', ...validInput, password: undefined });
      mockEmailService.sendWelcome.mockResolvedValue(undefined);

      const result = await userService.createUser(validInput);

      expect(result.id).toBe('1');
      expect(result.email).toBe(validInput.email);
      expect(result).not.toHaveProperty('password');
      expect(mockUserRepo.create).toHaveBeenCalledTimes(1);
      expect(mockEmailService.sendWelcome).toHaveBeenCalledWith(validInput.email);
    });

    it('当邮箱已存在时抛出 ConflictError', async () => {
      mockUserRepo.findByEmail.mockResolvedValue({ id: '2', email: validInput.email });

      await expect(userService.createUser(validInput)).rejects.toThrow('Email already registered');
      expect(mockUserRepo.create).not.toHaveBeenCalled();
    });

    it('当密码过短时抛出 ValidationError', async () => {
      const weakPassword = { ...validInput, password: 'short' };

      await expect(userService.createUser(weakPassword)).rejects.toThrow(/password must be at least/i);
    });

    it('如果欢迎邮件发送失败则不持久化用户', async () => {
      mockUserRepo.findByEmail.mockResolvedValue(null);
      mockUserRepo.create.mockResolvedValue({ id: '1', ...validInput });
      mockEmailService.sendWelcome.mockRejectedValue(new Error('SMTP connection failed'));
      mockUserRepo.deleteById.mockResolvedValue(undefined);

      await expect(userService.createUser(validInput)).rejects.toThrow('SMTP connection failed');
      expect(mockUserRepo.deleteById).toHaveBeenCalledWith('1');
    });
  });
});

Jest 特有模式:

模式使用场景示例
jest.fn()创建独立的模拟函数const callback = jest.fn()
jest.mock('module')自动模拟整个模块文件顶部,导入前使用
jest.spyOn(obj, 'method')监听现有方法而不替换jest.spyOn(console, 'error')
jest.useFakeTimers()控制 setTimeoutsetIntervalDate.now测试防抖、轮询、过期逻辑
jest.advanceTimersByTime(ms)快进假时间jest.advanceTimersByTime(5000)
expect.objectContaining({})部分对象匹配断言属性子集
expect.arrayContaining([])部分数组匹配断言数组包含某些项
expect.any(Constructor)类型匹配expect.any(Number)
.mockResolvedValue(val)模拟异步函数返回值mock.mockResolvedValue({id: 1})
.mockRejectedValue(err)模拟异步函数抛出错误mock.mockRejectedValue(new Error())
toMatchInlineSnapshot()内联快照用于小规模输出在测试文件中验证精确结构

JavaScript / TypeScript -- Vitest

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { calculateDiscount } from '../src/pricing';

describe('calculateDiscount', () => {
  it('正确应用百分比折扣', () => {
    expect(calculateDiscount(100, { type: 'percentage', value: 20 })).toBe(80);
  });

  it('正确应用固定金额折扣', () => {
    expect(calculateDiscount(100, { type: 'flat', value: 15 })).toBe(85);
  });

  it('从不返回负价格', () => {
    expect(calculateDiscount(10, { type: 'flat', value: 50 })).toBe(0);
  });

  it('对零价格情况处理得当', () => {
    expect(calculateDiscount(0, { type: 'percentage', value: 50 })).toBe(0);
  });

  it('货币金额保留两位小数', () => {
    const result = calculateDiscount(99.99, { type: 'percentage', value: 33 });
    expect(result).toBe(66.99);
    // 显式验证无浮点数精度漂移
    expect(result.toString()).not.toContain('000000');
  });

  it('对负折扣值抛出异常', () => {
    expect(() => calculateDiscount(100, { type: 'percentage', value: -10 }))
      .toThrow('Discount value must be non-negative');
  });

  it('对超过 100% 的折扣百分比抛出异常', () => {
    expect(() => calculateDiscount(100, { type: 'percentage', value: 150 }))
      .toThrow('Percentage discount cannot exceed 100');
  });

  it('对未知折扣类型抛出异常', () => {
    expect(() => calculateDiscount(100, { type: 'bogo' as any, value: 1 }))
      .toThrow(/unknown discount type/i);
  });
});

Vitest 特有说明:

  • 使用 vi.fn() 替代 jest.fn()
  • 使用 vi.mock() 替代 jest.mock()
  • 使用 vi.spyOn() 替代 jest.spyOn()
  • 使用 vi.useFakeTimers()vi.advanceTimersByTime()
  • Vitest 原生支持 ESM —— 无需使用 --experimental-vm-modules
  • 使用 vi.hoisted() 处理在 vi.mock() 工厂中需要提前可用的导入

Python -- pytest

markdown

Sovereign Test Generator

测试用例:user_service 模块

"""user_service 模块的测试用例。"""
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from datetime import datetime, timezone

from app.services.user_service import UserService, UserNotFoundError, DuplicateEmailError
from app.models.user import User

@pytest.fixture
def mock_db():
    """创建一个模拟数据库会话。"""
    db = MagicMock()
    db.commit = MagicMock()
    db.rollback = MagicMock()
    db.add = MagicMock()
    db.query.return_value.filter.return_value.first.return_value = None
    return db

@pytest.fixture
def mock_email_client():
    """创建一个模拟邮件客户端。"""
    client = AsyncMock()
    client.send_welcome.return_value = True
    return client

@pytest.fixture
def user_service(mock_db, mock_email_client):
    """使用模拟依赖项创建 UserService 实例。"""
    return UserService(db=mock_db, email_client=mock_email_client)

@pytest.fixture
def sample_user():
    """创建一个用于测试的示例用户。"""
    return User(
        id=1,
        email="test@example.com",
        name="Test User",
        created_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
    )

class TestCreateUser:
    """测试 UserService.create_user 方法。"""

    def test_creates_user_with_valid_data(self, user_service, mock_db):
        result = user_service.create_user(
            email="new@example.com",
            password="secureP@ss123",
            name="New User",
        )

        assert result.email == "new@example.com"
        assert result.name == "New User"
        mock_db.add.assert_called_once()
        mock_db.commit.assert_called_once()

    def test_raises_duplicate_email_error(self, user_service, mock_db, sample_user):
        mock_db.query.return_value.filter.return_value.first.return_value = sample_user

        with pytest.raises(DuplicateEmailError, match="already registered"):
            user_service.create_user(
                email="test@example.com",
                password="secureP@ss123",
                name="Duplicate",
            )

        mock_db.add.assert_not_called()

    def test_rolls_back_on_commit_failure(self, user_service, mock_db):
        mock_db.commit.side_effect = Exception("Connection lost")

        with pytest.raises(Exception, match="Connection lost"):
            user_service.create_user(
                email="fail@example.com",
                password="secureP@ss123",
                name="Fail",
            )

        mock_db.rollback.assert_called_once()

    @pytest.mark.parametrize(
        "password,reason",
        [
            ("short", "too short"),
            ("nouppercase1!", "missing uppercase"),
            ("NOLOWERCASE1!", "missing lowercase"),
            ("NoDigits!!", "missing digit"),
            ("", "empty"),
        ],
    )
    def test_rejects_weak_passwords(self, user_service, password, reason):
        with pytest.raises(ValueError):
            user_service.create_user(
                email="test@example.com",
                password=password,
                name="Test",
            )

    def test_strips_whitespace_from_email(self, user_service, mock_db):
        result = user_service.create_user(
            email="  spaces@example.com  ",
            password="secureP@ss123",
            name="Spaces",
        )
        assert result.email == "spaces@example.com"

    def test_lowercases_email(self, user_service, mock_db):
        result = user_service.create_user(
            email="UPPER@Example.COM",
            password="secureP@ss123",
            name="Upper",
        )
        assert result.email == "upper@example.com"

class TestGetUser:
    """测试 UserService.get_user 方法。"""

    def test_returns_user_when_found(self, user_service, mock_db, sample_user):
        mock_db.query.return_value.filter.return_value.first.return_value = sample_user

        result = user_service.get_user(user_id=1)

        assert result.id == 1
        assert result.email == "test@example.com"

    def test_raises_not_found_for_missing_user(self, user_service, mock_db):
        mock_db.query.return_value.filter.return_value.first.return_value = None

        with pytest.raises(UserNotFoundError):
            user_service.get_user(user_id=999)

    def test_raises_value_error_for_invalid_id(self, user_service):
        with pytest.raises(ValueError):
            user_service.get_user(user_id=-1)

        with pytest.raises(ValueError):
            user_service.get_user(user_id=0)

pytest 特定模式

模式使用时机示例
@pytest.fixture多个测试共享的设置数据库连接、测试数据
@pytest.mark.parametrize相同测试使用不同输入验证规则、边界情况
@pytest.mark.asyncio测试异步函数async def test_fetch():
@pytest.mark.skip(reason="...")临时跳过测试依赖项损坏、已知问题
@pytest.mark.xfail预期会失败的测试记录已知缺陷
pytest.raises(ExceptionType)断言异常被抛出with pytest.raises(ValueError):
pytest.approx(value)浮点数比较assert 0.3 == pytest.approx(0.1 + 0.2)
MagicMock / AsyncMock模拟同步/异步依赖mock = MagicMock(return_value=42)
@patch('module.function')在测试期间替换函数@patch('app.utils.send_email')
tmp_path(内置 fixture)文件测试的临时目录def test_write(tmp_path):
capsys(内置 fixture)捕获标准输出/错误captured = capsys.readouterr()
monkeypatch(内置 fixture)设置环境变量、修改对象monkeypatch.setenv("API_KEY", "test")
conftest.py跨测试文件共享 fixture放置在测试目录根目录

Go -- testing 包

go

package user_test

import (

"context"

"errors"

"testing"

"time"

"github.com/stretchr/testify/assert"

"github.com/stretchr/testify/mock"

"github.com/stretchr/testify/require"

"myapp/internal/user"

)

// MockUserStore 实现 user.Store 接口用于测试

type MockUserStore struct {

mock.Mock

}

func (m *MockUserStore) FindByEmail(ctx context.Context, email string) (*user.User, error) {

args := m.Called(ctx, email)

if args.Get(0) == nil {

return nil, args.Error(1)

}

return args.Get(0).(*user.User), args.Error(1)

}

func (m *MockUserStore) Create(ctx context.Context, u *user.User) error {

args := m.Called(ctx, u)

return args.Error(0)

}

func TestCreateUser(t *testing.T) {

t.Run("创建用户时输入有效", func(t *testing.T) {

store := new(MockUserStore)

svc := user.NewService(store)

store.On("FindByEmail", mock.Anything, "new@example.com").Return(nil, user.ErrNotFound)

store.On("Create", mock.Anything, mock.AnythingOfType("*user.User")).Return(nil)

u, err := svc.Create(context.Background(), "new@example.com", "Test User")

require.NoError(t, err)

assert.Equal(t, "new@example.com", u.Email)

assert.Equal(t, "Test User", u.Name)

assert.NotEmpty(t, u.ID)

store.AssertExpectations(t)

})

t.Run("当邮箱已存在时返回错误", func(t *testing.T) {

store := new(MockUserStore)

svc := user.NewService(store)

existing := &user.User{ID: "123", Email: "taken@example.com"}

store.On("FindByEmail", mock.Anything, "taken@example.com").Return(existing, nil)

_, err := svc.Create(context.Background(), "taken@example.com", "Test")

require.Error(t, err)

assert.True(t, errors.Is(err, user.ErrDuplicateEmail))

store.AssertNotCalled(t, "Create", mock.Anything, mock.Anything)

})

t.Run("空邮箱时返回错误", func(t *testing.T) {

store := new(MockUserStore)

svc := user.NewService(store)

_, err := svc.Create(context.Background(), "", "Test")

require.Error(t, err)

assert.Contains(t, err.Error(), "email is required")

})

t.Run("尊重上下文取消", func(t *testing.T) {

store := new(MockUserStore)

svc := user.NewService(store)

ctx, cancel := context.WithCancel(context.Background())

cancel() // 立即取消

store.On("FindByEmail", mock.Anything, mock.Anything).Return(nil, ctx.Err())

_, err := svc.Create(ctx, "test@example.com", "Test")

require.Error(t, err)

assert.True(t, errors.Is(err, context.Canceled))

})

}

// 表驱动测试用于验证逻辑

func TestValidateEmail(t *testing.T) {

tests := []struct {

name string

email string

wantErr bool

}{

{"有效邮箱", "user@example.com", false},

{"带子域名的邮箱", "user@sub.example.com", false},

{"带加号的邮箱", "user+tag@example.com", false},

{"空字符串", "", true},

{"缺少 @ 符号", "userexample.com", true},

{"缺少域名", "user@", true},

{"缺少本地部分", "@example.com", true},

{"双 @ 符号", "user@@example.com", true},

{"本地部分含空格", "us er@example.com", true},

{"包含 Unicode 域名", "user@ex\u00e4mple.com", true},

}

for _, tt := range tests {

t.Run(tt.name, func(t *testing.T) {

err := user.ValidateEmail(tt.email)

if tt.wantErr {

assert.Error(t, err, "预期对邮箱 %q 返回错误", tt.email)

} else {

assert.NoError(t, err, "对邮箱 %q 不应出现错误", tt.email)

}

})

}

}

**Go 测试模式:**

| 模式 | 使用场景 | 示例 |
|------|----------|------|
| `t.Run("名称", func(t *testing.T){})` | 子测试用于分组 | 按场景组织测试 |
| 表驱动测试 | 相同逻辑,不同输入 | 验证、解析、转换 |
| `testify/assert` | 非致命断言 | `assert.Equal(t, 期望值, 实际值)` |
| `testify/require` | 致命断言(失败时停止测试) | `require.NoError(t, err)` |
| `testify/mock` | 接口模拟 | 定义实现接口的模拟结构体 |
| `httptest.NewServer` | 测试 HTTP 处理函数 | 创建带有真实 HTTP 的测试服务器 |
| `httptest.NewRecorder` | 在不启动服务器的情况下测试处理函数 | 记录处理函数的响应 |
| `t.Parallel()` | 并行运行子测试 | 放在子测试开头 |
| `t.Helper()` | 标记函数为测试辅助函数 | 提高错误位置输出的可读性 |
| `t.Cleanup(func())` | 注册测试结束后执行的清理操作 | 关闭连接、删除临时文件 |
| `testing.Short()` | 使用 `-short` 标志跳过耗时测试 | `if testing.Short() { t.Skip() }` |

### Rust -- #[test] 和 #[cfg(test)]

rust
#[cfg(test)]
mod tests {
    use super::*;

    // 测试用例数据
    fn sample_user() -> User {
        User {
            id: 1,
            email: "test@example.com".to_string(),
            name: "Test User".to_string(),
            created_at: chrono::Utc::now(),
        }
    }

    mod create_user {
        use super::*;

        #[test]
        fn creates_user_with_valid_data() {
            let repo = MockUserRepo::new();
            repo.expect_find_by_email()
                .returning(|_| Ok(None));
            repo.expect_create()
                .returning(|u| Ok(u.clone()));

            let service = UserService::new(Box::new(repo));
            let result = service.create_user("new@example.com", "secureP@ss123", "New User");

            assert!(result.is_ok());
            let user = result.unwrap();
            assert_eq!(user.email, "new@example.com");
            assert_eq!(user.name, "New User");
        }

        #[test]
        fn returns_error_for_duplicate_email() {
            let repo = MockUserRepo::new();
            repo.expect_find_by_email()
                .returning(|_| Ok(Some(sample_user())));

            let service = UserService::new(Box::new(repo));
            let result = service.create_user("test@example.com", "secureP@ss123", "Dup");

            assert!(result.is_err());
            assert!(matches!(result.unwrap_err(), UserError::DuplicateEmail(_)));
        }

        #[test]
        fn returns_error_for_empty_email() {
            let repo = MockUserRepo::new();
            let service = UserService::new(Box::new(repo));

            let result = service.create_user("", "secureP@ss123", "Test");

            assert!(result.is_err());
            assert!(matches!(result.unwrap_err(), UserError::ValidationError(_)));
        }

        #[test]
        #[should_panic(expected = "password must not be empty")]
        fn panics_on_empty_password() {
            let repo = MockUserRepo::new();
            let service = UserService::new(Box::new(repo));

            // 这应该触发 panic,而不是返回错误
            let _ = service.create_user("test@example.com", "", "Test");
        }
    }

    mod validate_email {
        use super::*;

        #[test]
        fn accepts_valid_emails() {
            let valid = vec![
                "user@example.com",
                "user+tag@example.com",
                "user.name@sub.example.com",
            ];
            for email in valid {
                assert!(validate_email(email).is_ok(), "应接受: {}", email);
            }
        }

        #[test]
        fn rejects_invalid_emails() {
            let invalid = vec![
                ("", "空字符串"),
                ("@example.com", "缺少本地部分"),
                ("user@", "缺少域名"),
                ("userexample.com", "缺少 @ 符号"),
                ("user@@example.com", "双 @ 符号"),
            ];
            for (email, reason) in invalid {
                assert!(validate_email(email).is_err(), "应拒绝({}): {}", reason, email);
            }
        }
    }

    // 异步测试(需要 tokio::test)
    mod async_operations {
        use super::*;

        #[tokio::test]
        async fn fetches_user_from_remote_api() {
            let mut mock_client = MockHttpClient::new();
            mock_client.expect_get()
                .with(eq("https://api.example.com/users/1"))
                .returning(|_| Ok(r#"{"id":1,"name":"Remote User"}"#.to_string()));

            let service = RemoteUserService::new(mock_client);
            let user = service.fetch_user(1).await.unwrap();

            assert_eq!(user.name, "Remote User");
        }

        #[tokio::test]
        async fn handles_api_timeout() {
            let mut mock_client = MockHttpClient::new();
            mock_client.expect_get()
                .returning(|_| Err(HttpError::Timeout));

            let service = RemoteUserService::new(mock_client);
            let result = service.fetch_user(1).await;

            assert!(matches!(result, Err(UserError::NetworkError(_))));
        }
    }
}

**Rust 测试模式:**

| 模式 | 使用时机 | 示例 |
|------|----------|------|
| `#[test]` | 标记一个函数为测试用例 | 基础单元测试 |
| `#[cfg(test)]` | 仅在测试时编译该模块 | 包裹测试模块 |
| `#[should_panic]` | 测试代码是否触发 panic | `#[should_panic(expected = "msg")]` |
| `#[ignore]` | 忽略测试,除非使用 `--ignored` 标志 | 慢速或集成测试 |
| `#[tokio::test]` | 使用 tokio 运行时测试异步函数 | 异步函数测试 |
| `assert!`, `assert_eq!`, `assert_ne!` | 标准断言 | 内置,无需导入 |
| `matches!()` | 模式匹配断言 | `assert!(matches!(result, Ok(_)))` |
| `mockall` crate | 自动生成模拟实现 | 在 trait 上使用 `#[automock]` |
| `proptest` / `quickcheck` | 属性驱动测试 | 生成随机输入 |
| `rstest` | 参数化测试(类似 pytest) | `#[rstest]` 配合 `#[case]` |
| `tempfile` crate | 临时文件和目录 | `tempfile::tempdir()` |

---

## 集成测试模式

集成测试用于验证多个组件协同工作是否正常。它介于单元测试(隔离)与端到端测试(完整系统)之间。

### 应当进行集成测试的边界

| 边界 | 需要验证的内容 |
|------|----------------|
| **HTTP API** | 请求解析、路由、响应格式、状态码、请求头 |
| **数据库** | 模式兼容性、查询正确性、事务行为、迁移逻辑 |
| **文件系统** | 读写操作、路径处理、权限控制 |
| **外部 API** | 请求格式、响应解析、错误处理、重试机制 |
| **消息队列** | 发布/消费、消息格式、顺序、死信处理 |
| **缓存层** | 缓存命中/未命中、失效策略、序列化、TTL |

### 集成测试结构
  1. 设置 —— 创建真实或内存中的依赖项(测试数据库、临时文件)
  2. 初始化数据 —— 将测试数据插入到依赖项中
  3. 执行 —— 调用待测代码
  4. 断言 —— 验证结果以及对依赖项的副作用
  5. 清理 —— 清除测试数据(或由框架自动处理)

HTTP API 集成测试(Jest + Supertest)

import request from 'supertest';
import { app } from '../src/app';
import { db } from '../src/database';

describe('POST /api/users', () => {
  beforeAll(async () => {
    await db.migrate.latest();
  });

  afterEach(async () => {
    await db('users').truncate();
  });

  afterAll(async () => {
    await db.destroy();
  });

  it('返回 201 状态码,并在数据库中创建用户', async () => {
    const response = await request(app)
      .post('/api/users')
      .send({ email: 'test@example.com', password: 'Secure123!', name: 'Test' })
      .expect(201);

    expect(response.body.user.email).toBe('test@example.com');
    expect(response.body.user).not.toHaveProperty('password');

    // 验证副作用:用户存在于数据库中
    const dbUser = await db('users').where({ email: 'test@example.com' }).first();
    expect(dbUser).toBeDefined();
    expect(dbUser.name).toBe('Test');
  });

  it('当邮箱已存在时返回 409 错误', async () => {
    // 初始化数据
    await db('users').insert({ email: 'taken@example.com', password: 'hash', name: 'Existing' });

    const response = await request(app)
      .post('/api/users')
      .send({ email: 'taken@example.com', password: 'Secure123!', name: 'Dup' })
      .expect(409);

    expect(response.body.error).toContain('already registered');
  });

  it('缺少必填字段时返回 400 并包含验证错误', async () => {
    const response = await request(app)
      .post('/api/users')
      .send({})
      .expect(400);

    expect(response.body.errors).toEqual(
      expect.arrayContaining([
        expect.objectContaining({ field: 'email' }),
        expect.objectContaining({ field: 'password' }),
      ])
    );
  });

  it('非 JSON 内容类型时返回 415 错误', async () => {
    await request(app)
      .post('/api/users')
      .set('Content-Type', 'text/plain')
      .send('not json')
      .expect(415);
  });
});

数据库集成测试(pytest + SQLAlchemy)

import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker

from app.models import Base, User
from app.repositories.user_repo import UserRepository

@pytest.fixture(scope="module")
def engine():
    """创建一个用于测试的内存中 SQLite 引擎。"""
    engine = create_engine("sqlite:///:memory:")
    Base.metadata.create_all(engine)
    yield engine
    engine.dispose()

@pytest.fixture
def session(engine):
    """为每个测试创建一个新的数据库会话。"""
    Session = sessionmaker(bind=engine)
    session = Session()
    yield session
    session.rollback()
    session.close()

@pytest.fixture
def repo(session):
    return UserRepository(session)

class TestUserRepository:
    def test_create_and_find(self, repo, session):
        user = repo.create(email="test@example.com", name="Test")
        session.flush()

        found = repo.find_by_email("test@example.com")
        assert found is not None
        assert found.name == "Test"
        assert found.id == user.id

    def test_find_returns_none_for_missing(self, repo):
        assert repo.find_by_email("nonexistent@example.com") is None

    def test_unique_constraint_on_email(self, repo, session):
        repo.create(email="unique@example.com", name="First")
        session.flush()

        with pytest.raises(Exception):  # IntegrityError
            repo.create(email="unique@example.com", name="Second")
            session.flush()

基于覆盖率的测试优先级策略

并非所有代码都应投入同等的测试精力。应根据风险程度进行优先级排序。

风险评估矩阵

因素低风险中等风险高风险
数据处理仅读取、展示转换、过滤创建、更新、删除
用户输入无用户输入已验证输入原始用户输入
资金相关无财务影响报表/展示交易、计费
外部依赖无外部依赖读取外部系统写入外部系统
调用频率很少调用定期调用每次请求都调用
影响范围单个用户团队/组织所有用户

测试资源分配建议:

  • 高风险代码:覆盖率 90% 以上,包括边界情况和错误路径
  • 中等风险代码:覆盖率 70% 以上,覆盖正常流程及主要错误场景
  • 低风险代码:覆盖率 50% 以上,仅覆盖正常流程
  • 自动生成/样板代码:0%(不测试框架代码)

不应测试的内容

不要浪费时间测试:

  • 框架内部逻辑(React 渲染、Express 路由、Django ORM)
  • 第三方库行为(axios、lodash、numpy)
  • 简单的 getter/setter 方法(无业务逻辑)
  • 配置文件
  • 类型定义或接口
  • 常量和枚举(除非由计算得出)
  • CSS 样式(使用视觉回归工具替代)
  • 通过直观检查即可确认正确性的代码

快照测试使用指南

快照适用于检测结构化输出的意外变更。它们不能替代行为断言

适合使用快照的场景:

  • API 响应结构验证(JSON 结构,而非具体值)
  • React 组件渲染输出(JSX 结构)
  • 错误消息格式一致性
  • CLI 帮助文本输出
  • 生成的 SQL 查询语句
  • 序列化后的配置内容

不适合使用快照的场景(应避免):

  • 测试计算结果(应使用 expect(value).toBe(expected)
  • 测试时间戳或随机 ID(快照会持续失败)
  • 测试大型对象,其中大部分属性无关紧要
  • 作为理解代码预期输出的替代方案

快照卫生:

  • 在代码审查中仔细检查每次快照更新,不要盲目使用 --update
  • 对于小量输出,使用 toMatchInlineSnapshot(),使期望值直接存在于测试中。
  • 对于大量输出,使用 .toMatchSnapshot(),但需命名,例如:.toMatchSnapshot('user creation response')
  • 如果一个快照文件包含超过 50 个条目,说明测试可能过度依赖输出格式。

性能测试模式

性能测试用于验证代码是否满足速度和资源使用要求。

计时测试

// Jest
it('处理 10,000 条记录耗时少于 500ms', () => {
  const records = Array.from({ length: 10_000 }, (_, i) => ({ id: i, value: `item-${i}` }));

  const start = performance.now();
  const result = processRecords(records);
  const elapsed = performance.now() - start;

  expect(result).toHaveLength(10_000);
  expect(elapsed).toBeLessThan(500);
});
# pytest
import time

def test_bulk_insert_performance(repo, session):
    """批量插入应在 2 秒内完成 1000 条记录。"""
    users = [{"email": f"user{i}@example.com", "name": f"User {i}"} for i in range(1000)]

    start = time.monotonic()
    repo.bulk_create(users)
    session.flush()
    elapsed = time.monotonic() - start

    assert elapsed < 2.0, f"批量插入耗时 {elapsed:.2f}s,预期小于 2.0s"
// Go
func BenchmarkProcessRecords(b *testing.B) {
    records := make([]Record, 10_000)
    for i := range records {
        records[i] = Record{ID: i, Value: fmt.Sprintf("item-%d", i)}
    }

    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        ProcessRecords(records)
    }
}

内存使用测试

func TestMemoryUsage(t *testing.T) {
    var m runtime.MemStats
    runtime.ReadMemStats(&m)
    before := m.Alloc

    // 执行操作
    result := ProcessLargeDataset(generateTestData(100_000))

    runtime.ReadMemStats(&m)
    after := m.Alloc

    // 10 万条记录不应分配超过 50MB 内存
    allocatedMB := float64(after-before) / 1024 / 1024
    assert.Less(t, allocatedMB, 50.0, "已分配 %.2f MB,预期小于 50 MB", allocatedMB)
    _ = result
}

输出格式

生成测试时,始终输出完整、可运行的测试文件。包含:

  1. 所有必要导入 —— 框架、模拟对象、待测模块
  2. 测试用例数据 —— 可复用的初始化数据和辅助函数
  3. 结构化的测试分组 —— 每个函数或功能对应一个 describe/class
  4. 清晰的测试名称 —— 遵循上述命名规范
  5. 具体的断言 —— 不仅使用 toBeTruthy()assert result
  6. 边缘情况覆盖 —— 至少包括:空输入、边界值、错误路径
  7. 仅在意图不明显时添加注释 —— 测试应通过名称实现自解释

文件命名规范:

框架测试文件模式位置
Jest*.test.ts, *.spec.ts__tests__/ 目录或源码同级
Vitest*.test.ts, *.spec.ts__tests__/ 目录或源码同级
pytesttest_*.py, *_test.pytests/ 目录
Go*_test.go与源码同包
Rustmod tests与源码同文件

完整工作流程

当用户提供代码供测试时,请严格遵循以下流程:

  1. 阅读代码 —— 理解其功能、公共接口及依赖关系
  2. 识别框架 —— 判断或询问:Jest、Vitest、pytest、Go、Rust
  3. 执行策略分析 —— 分析公开接口、复杂度、耦合度、模拟方案
  4. 生成测试文件 —— 输出完整、可运行的测试,包含所有导入和初始化
  5. 优先覆盖高风险路径 —— 先测试高风险逻辑,跳过简单代码
  6. 明确列出边缘情况 —— 说明已测试哪些边缘情况,哪些跳过(及原因)
  7. 建议补充测试 —— 如有需要,推荐集成测试、性能测试或基于属性的测试

若代码过大无法在一个文件中测试,应按逻辑拆分为多个测试文件,并说明结构。

若代码尚未有任何测试,应从最高风险函数开始,逐步向外扩展。不要试图一次性达到 100% 覆盖率——优先编写最可能发现缺陷的测试。


“测试的目的不是证明代码正确。而是找出它出错的地方。” —— Taylor(Sovereign AI)

R
@ryudi84

已收录 4 个 Skill

相关推荐