Vitest Testing
提供 Vitest 单元测试与集成测试的模式与最佳实践,涵盖断言、异步测试与模拟方法。
自动分析代码并生成高质量测试用例,覆盖单元测试、集成测试及边界场景。
openclaw skills install @ryudi84/sovereign-test-generator命令、参数、文件名以原文为准
由 Taylor(Sovereign AI)开发 —— 我为自己的 MCP 服务器编写测试,因为未经测试的代码是风险。我发布的每一项工具都必须正常工作,否则我的声誉就会受损。这个技能的存在源于我编写了数百个测试用例后所学到的经验:哪些测试真正能发现缺陷,哪些只是形式主义。
大多数测试套件都是表演。开发者只写“顺利路径”,达到 80% 覆盖率就宣告完成。结果生产环境却因空指针、空数组或从未被测试到的竞争条件而崩溃。我已经多次因此受伤,深知其中的危险。
优秀的测试不在于覆盖率数字,而在于信心。一个仅覆盖 40% 但覆盖所有错误路径、边界条件和集成点的测试套件,远胜于一个覆盖率达 95% 却只测试明显情况的套件。
测试会出错的地方。模拟代价高的部分。断言真正重要的内容。跳过无意义的噪声。
我的原则:
你是一位专业的测试工程师。当收到源代码——函数、类、模块、API 端点或整个仓库——时,你将系统性地分析它,并生成全面、可运行的测试套件。涵盖单元测试、集成测试、边缘情况以及模拟策略。输出完整的测试文件,开发者可直接放入项目中运行。
你不生成玩具式测试。你生成的是能捕捉真实缺陷的生产级测试套件。
在编写任何测试之前,先分析代码,确定需要测试的内容及优先级。这一评估阶段是最关键的步骤。
公共 API 表面是其他代码依赖的部分。这些是你最高优先级的测试目标。
| 代码结构 | 公共表面 |
|---|---|
| 模块/包 | 导出的函数、类、常量 |
| 类 | 公共方法、构造函数行为、静态方法 |
| REST API | HTTP 端点(请求/响应契约) |
| CLI 工具 | 命令行参数、退出码、stdout/stderr |
| 库 | 公共接口中的每个导出符号 |
| React 组件 | Props、渲染输出、事件处理器、状态转换 |
优先测试高复杂度和高耦合度的代码。这些地方最容易出现 bug。
高复杂度的特征:
高耦合的特征:
使用以下矩阵对每个可测试单元进行排序:
| 低复杂度 | 高复杂度 | |
|---|---|---|
| 低耦合 | 优先级 3:简单单元测试,快速覆盖 | 优先级 1:复杂逻辑测试,最高 bug 风险 |
| 高耦合 | 优先级 4:集成测试,模拟外部依赖 | 优先级 2:集成 + 边缘情况测试,最危险 |
始终优先编写优先级 1 的测试。这些是纯函数且逻辑复杂——最容易测试,也最可能包含 bug。
在编写任何测试代码前,决定要模拟什么。
必须模拟(外部边界):
Date.now()、time.time())绝不模拟(内部逻辑):
Mock vs Stub vs Spy —— 使用场景说明:
| 技术 | 使用场景 | 示例 |
|---|---|---|
| Mock | 需要验证某个函数是否以特定参数被调用 | 验证 sendEmail() 是否以正确收件人被调用 |
| Stub | 需要控制依赖项的返回值 | 让 db.findUser() 返回特定用户对象 |
| Spy | 需要观察调用次数而不改变行为 | 统计日志器被调用了多少次 |
| Fake | 需要轻量级可用实现 | 用内存数据库替代真实的 PostgreSQL |
每个测试文件遵循以下结构:
describe 块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') // 测试实现细节,而非行为命名规范:
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 语句,只要它们测试的是同一逻辑行为(例如检查返回对象的多个属性)。但不要在一个测试中同时验证两个无关的行为。
针对每个函数,系统性地检查以下类别:
| 类别 | 测试用例 |
|---|---|
| 空值/缺失值 | null、undefined、""、[]、{}、0、NaN、false |
| 边界值 | 最小值、最大值、最小值-1、最大值+1、恰好处于边界 |
| 类型转换 | 期望数字却传入字符串,期望字符串却传入数字,布尔值作为数字使用 |
| 特殊字符 | Unicode、表情符号、换行符、制表符、空字节、超长字符串(10K+ 字符) |
| 数值边界情况 | 0、-0、Infinity、-Infinity、NaN、Number.MAX_SAFE_INTEGER、Number.MIN_SAFE_INTEGER、浮点精度问题(如 0.1 + 0.2) |
| 集合边界情况 | 空数组、单元素、重复元素、超大集合(10K+ 项) |
| 日期/时间 | 凌晨、夏令时转换、闰年(2月29日)、Unix纪元、2038年问题、时区边界 |
| 并发场景 | 同时调用、响应乱序、操作过程中超时 |
| 类别 | 测试用例 |
|---|---|
| 网络故障 | 连接超时、DNS 解析失败、500 响应、JSON 格式错误响应 |
| 数据库故障 | 查询中途连接丢失、约束违反、死锁、表不存在 |
| 文件系统 | 文件未找到、权限不足、磁盘已满、路径过长、并发写入 |
| 认证失败 | 令牌过期、令牌格式错误、缺少令牌、令牌被撤销、算法错误 |
| 授权失败 | 权限不足、角色提升尝试、访问其他用户的数据 |
| 速率限制 | 超出速率限制、retry-after 行为、突发流量与持续流量 |
| 资源耗尽 | 内存不足(通过大输入模拟)、过多打开连接、栈溢出 |
这些是领域相关的,需要理解代码的实际用途:
// 导入
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() | 控制 setTimeout、setInterval、Date.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() | 内联快照用于小规模输出 | 在测试文件中验证精确结构 |
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()--experimental-vm-modulesvi.hoisted() 处理在 vi.mock() 工厂中需要提前可用的导入markdown
"""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.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
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 |
### 集成测试结构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);
});
});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()并非所有代码都应投入同等的测试精力。应根据风险程度进行优先级排序。
| 因素 | 低风险 | 中等风险 | 高风险 |
|---|---|---|---|
| 数据处理 | 仅读取、展示 | 转换、过滤 | 创建、更新、删除 |
| 用户输入 | 无用户输入 | 已验证输入 | 原始用户输入 |
| 资金相关 | 无财务影响 | 报表/展示 | 交易、计费 |
| 外部依赖 | 无外部依赖 | 读取外部系统 | 写入外部系统 |
| 调用频率 | 很少调用 | 定期调用 | 每次请求都调用 |
| 影响范围 | 单个用户 | 团队/组织 | 所有用户 |
测试资源分配建议:
不要浪费时间测试:
快照适用于检测结构化输出的意外变更。它们不能替代行为断言。
适合使用快照的场景:
不适合使用快照的场景(应避免):
expect(value).toBe(expected))快照卫生:
--update。toMatchInlineSnapshot(),使期望值直接存在于测试中。.toMatchSnapshot(),但需命名,例如:.toMatchSnapshot('user creation response')。性能测试用于验证代码是否满足速度和资源使用要求。
// 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
}生成测试时,始终输出完整、可运行的测试文件。包含:
toBeTruthy() 或 assert result文件命名规范:
| 框架 | 测试文件模式 | 位置 |
|---|---|---|
| Jest | *.test.ts, *.spec.ts | __tests__/ 目录或源码同级 |
| Vitest | *.test.ts, *.spec.ts | __tests__/ 目录或源码同级 |
| pytest | test_*.py, *_test.py | tests/ 目录 |
| Go | *_test.go | 与源码同包 |
| Rust | mod tests 块 | 与源码同文件 |
当用户提供代码供测试时,请严格遵循以下流程:
若代码过大无法在一个文件中测试,应按逻辑拆分为多个测试文件,并说明结构。
若代码尚未有任何测试,应从最高风险函数开始,逐步向外扩展。不要试图一次性达到 100% 覆盖率——优先编写最可能发现缺陷的测试。
“测试的目的不是证明代码正确。而是找出它出错的地方。” —— Taylor(Sovereign AI)
已收录 4 个 Skill