行为测试技能
触发条件
- 功能实现后
- 当测试只检查DOM渲染(“冒烟测试”)
- 当代码审查发现测试覆盖率不足
/behavior-tests斜杠命令
问题
标准测试(通过AI生成或Copilot)通常只检查"是否渲染?“,而不是"是否工作?”。这导致:
- 没有功能的按钮通过所有测试
- API调用使用错误参数从未被发现
- 导航/重新挂载时的状态丢失不可见
- 缺少错误UI从未被测试
4个测试类别(强制)
1. API合同测试
是什么: 验证API函数是否以正确的参数被调用。 为什么: 错误参数(缺少语言参数、错误端点)在渲染测试中不可见。
// 差:只检查DOM
it('发送消息', async () => {
await sendMessage('hello');
expect(screen.getByText('hello')).toBeInTheDocument(); // 仅DOM!
});
// 好:检查API合同
it('以正确语言发送消息', async () => {
await sendMessage('hello');
expect(mockApi).toHaveBeenCalledWith(
'hello',
expect.any(Array), // 历史
'en', // 语言代码
);
});
检查清单:
- [ ] 使用
vi.fn()模拟API函数 - [ ] 使用
toHaveBeenCalledWith检查调用参数 - [ ] 使用
toHaveBeenCalledTimes检查调用次数 - [ ] 检查API错误时发生什么(错误UI可见?)
2. 状态持久性测试
是什么: 验证在重新挂载、页面刷新或认证切换后,状态是否正确保持。 为什么: sessionStorage/localStorage错误,登录/注销时的状态重置。
// 检查sessionStorage持久性
it('将计数器持久化到sessionStorage', async () => {
render(<Chat />);
await sendMessage('test');
expect(sessionStorage.getItem('key')).toBe('1');
});
// 检查认证转换
it('登录时重置计数器', () => {
sessionStorage.setItem('key', '5');
useAuthStore.setState({ isAuthenticated: true });
// 效果应该重置
expect(sessionStorage.getItem('key')).toBe('0');
});
检查清单:
- [ ] 在操作前后检查sessionStorage/localStorage
- [ ] 测试认证状态切换(登录 -> 登出及反之)
- [ ] 直接使用
setState操作状态存储以处理边缘情况 - [ ] 检查存储重置后的默认状态
3. 错误边界测试
是什么: 验证错误是否向用户显示,并且应用不会崩溃。 为什么: 缺少错误UI是最常见的用户体验缺陷。
// API错误 -> 错误UI
it('结账失败时显示错误', async () => {
mockPost.mockRejectedValueOnce(new Error('服务器错误'));
fireEvent.click(checkoutButton);
await waitFor(() => {
expect(screen.getByText(/error|fehler/i)).toBeInTheDocument();
});
});
// 204无内容 -> 不崩溃
it('处理204无内容', async () => {
mockFetch({ ok: true, status: 204, text: () => Promise.resolve('') });
const result = await apiClient.get('/api/logout');
expect(result).toEqual({});
});
检查清单:
- [ ] API拒绝 -> 错误消息可见
- [ ] 空响应(204,空体) -> 不崩溃
- [ ] 速率限制(429) -> 显示重试信息
- [ ] 非JSON错误体 -> 优雅降级
4. 安全边界测试
是什么: 验证输入验证、认证门和重定向安全性。 为什么: XSS预防、开放重定向防止、速率限制执行。
// 输入长度
it('拒绝超过2000字符的输入', async () => {
await sendMessage('a'.repeat(2001));
expect(mockApi).not.toHaveBeenCalled();
expect(screen.getByText(/maximum/i)).toBeInTheDocument();
});
// URL验证
it('阻止非stripe.com重定向URL', async () => {
mockPost.mockResolvedValueOnce({ url: 'https://evil.com/steal' });
fireEvent.click(checkoutButton);
await waitFor(() => {
expect(screen.getByText(/error/i)).toBeInTheDocument();
});
});
// CSRF令牌
it('将CSRF令牌附加到POST但不附加到GET', async () => {
document.cookie = 'csrf_token=abc123';
await apiClient.post('/api/data', {});
expect(fetchMock).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: expect.objectContaining({ 'X-CSRF-Token': 'abc123' }),
}),
);
});
检查清单:
- [ ] 输入长度限制在客户端强制执行
- [ ] 认证门阻止未经认证的用户
- [ ] 计划门在高级功能中阻止免费计划
- [ ] 重定向URL检查允许的域名
- [ ] CSRF令牌在POST中附加,不在GET中
- [ ] 在所有请求中设置Credentials: ‘include’
实施工作流程
- 识别缺陷:阅读现有测试。检查它们是否只使用
toBeInTheDocument()。 - 模拟APIs:使用
vi.mock()用于服务模块,vi.fn()用于单个函数。 - 直接设置状态:使用
useAuthStore.setState({...})代替通过UI导航。 - 检查调用:
toHaveBeenCalledWith()比toBeInTheDocument()更重要。 - 测试错误路径:生产代码中的每个
try/catch都需要一个测试。
文件命名
# 保留现有渲染测试:
ComponentName.test.tsx
# 新的行为测试放在旁边:
ComponentName.behavior.test.tsx
# 或在describe块中分离:
describe('rendering', () => { ... }); // 现有
describe('behavior', () => { ... }); // 新
describe('error handling', () => { ... }); // 新
反模式
| 反模式 | 问题 | 解决方案 |
|---|---|---|
仅使用toBeInTheDocument() |
不检查按钮是否工作 | toHaveBeenCalledWith() |
| 没有API模拟 | 测试调用真实API | vi.mock() + vi.fn() |
| 只测试快乐路径 | 错误UI从未被测试 | mockRejectedValueOnce() |
| 状态假设 | 存储未重置 | beforeEach + setState |
| 没有认证上下文 | 测试只作为登录用户运行 | 测试两种状态 |
参考实现
参见:Website/Relaunch 2026 01/services/__tests__/apiClient.test.ts(错误边界 + 安全边界模式)
参见:Website/Relaunch 2026 01/components/__tests__/MultilingualChat.behavior.test.tsx(API合同 + 状态持久性模式)