name: 测试专家 description: 这个技能应该用于编写测试用例、修复bug、分析代码潜在问题或提高JavaScript/TypeScript应用程序的测试覆盖率。适用于单元测试、集成测试、端到端测试、调试运行时错误、逻辑bug、性能问题、安全漏洞和系统代码分析。
测试专家
JS/TS应用程序的系统化测试方法和调试技术。
何时使用
适用于:
- 编写单元测试、集成测试或端到端测试
- 修复bug和调试
- 提高测试覆盖率
- 分析代码潜在问题
- 安全测试和性能测试
不适用于:
- 代码审查 → 使用
generic-code-reviewer - 技术债务 → 使用
tech-debt-analyzer - 功能开发 → 使用
generic-feature-developer
按项目类型的测试栈
| 项目类型 | 单元测试 | 组件测试 | 端到端测试 |
|---|---|---|---|
| React/Next.js | Vitest/Jest | Testing Library | Playwright |
| Node.js | Vitest/Jest | Supertest | Playwright |
| 静态网站 | Jest | - | Playwright |
测试模式
单元测试 (AAA模式)
describe("calculateTotal", () => {
test("正确求和金额", () => {
// 安排
const items = [{ amount: 100 }, { amount: 50 }];
// 执行
const total = calculateTotal(items);
// 断言
expect(total).toBe(150);
});
test("处理空列表", () => {
expect(calculateTotal([])).toBe(0);
});
});
组件测试 (用户行为)
// ✅ 测试用户行为,而非实现细节
it('用户点击添加时创建项目', async () => {
const user = userEvent.setup();
render(<ItemList />);
await user.click(screen.getByRole('button', { name: /add/i }));
await user.type(screen.getByLabelText(/title/i), '新项目');
await user.click(screen.getByRole('button', { name: /save/i }));
expect(screen.getByText('新项目')).toBeInTheDocument();
});
端到端测试 (Playwright)
import { test, expect } from "@playwright/test";
test("用户可以完成结账", async ({ page }) => {
await page.goto("/products");
// 添加到购物车
await page.click('button:has-text("添加到购物车")');
await page.click('a:has-text("购物车")');
// 结账
await page.click('button:has-text("结账")');
await page.fill('[name="email"]', "test@example.com");
await page.click('button:has-text("下订单")');
// 验证
await expect(page.locator("h1")).toContainText("订单确认");
});
集成测试
test("POST /items 创建项目", async () => {
const response = await request(app)
.post("/api/items")
.send({ name: "测试" })
.expect(201);
expect(response.body).toMatchObject({ id: expect.any(Number) });
});
Bug分析流程
- 重现 - 记录确切步骤、预期与实际结果
- 隔离 - 二分查找、最小化重现
- 根因 - 追踪执行、检查假设、git blame
- 修复 - 先写失败测试、实现修复
- 验证 - 运行完整套件、测试边界情况
调试清单
当调试问题时:
- [ ] 能否一致重现
- [ ] 创建了最小化重现
- [ ] 检查了控制台/网络日志
- [ ] 检查了失败点的状态
- [ ] 检查了git blame以查看最近更改
- [ ] 在修复前编写了失败测试
常见Bug模式
竞态条件
test("处理并发更新", async () => {
const promises = Array.from({ length: 100 }, () => increment());
await Promise.all(promises);
expect(getCount()).toBe(100);
});
空安全性
test.each([null, undefined, "", 0])("处理无效输入: %p", (input) => {
expect(() => process(input)).toThrow("无效");
});
边界值
test("处理边界情况", () => {
expect(paginate([], 1, 10)).toEqual([]); // 空列表
expect(paginate([item], 1, 10)).toEqual([item]); // 单个项目
expect(paginate(items25, 3, 10)).toHaveLength(5); // 部分最后一页
});
安全测试
test("防止SQL注入", async () => {
const malicious = "'; DROP TABLE users; --";
await expect(search(malicious)).resolves.not.toThrow();
});
test("净化XSS", () => {
const xss = '<script>alert("xss")</script>';
expect(sanitize(xss)).not.toContain("<script>");
});
test("需要认证", async () => {
await request(app).post("/api/items").expect(401);
});
性能测试
test("高效处理大型数据集", () => {
const largeList = Array.from({ length: 10000 }, (_, i) => ({ value: i }));
const start = performance.now();
process(largeList);
expect(performance.now() - start).toBeLessThan(100);
});
覆盖率目标
| 代码类型 | 目标 |
|---|---|
| 关键路径 | 90%+ |
| 业务逻辑 | 85%+ |
| UI组件 | 75%+ |
| 工具函数 | 70%+ |
测试质量原则
- 每个测试一个行为
- 描述性名称 - 测试名称解释场景
- 独立测试 - 无共享状态
- 覆盖边界情况 - null、空、边界、错误
- 模拟外部依赖 - 测试应快速
- 测试行为 - 而非实现细节
工作流程决策树
| 情况 | 行动 |
|---|---|
| 添加功能 | 先写测试 (TDD) |
| 修复bug | 写失败测试,然后修复 |
| 提高覆盖率 | 找到差距,优先关键路径 |
| 代码审查 | 检查边界情况、错误处理 |
Python测试 (pytest)
夹具和参数化
import pytest
from myapp.services import UserService
@pytest.fixture
def user_service(db_session):
"""提供带测试数据库的UserService。"""
return UserService(session=db_session)
@pytest.fixture
def sample_user(user_service):
"""创建并返回示例用户。"""
return user_service.create(name="测试用户", email="test@example.com")
class TestUserService:
def test_create_user(self, user_service):
user = user_service.create(name="John", email="john@example.com")
assert user.name == "John"
assert user.id is not None
def test_get_user_not_found(self, user_service):
with pytest.raises(UserNotFoundError, match="用户 999 未找到"):
user_service.get(999)
@pytest.mark.parametrize("email,is_valid", [
("user@example.com", True),
("user@sub.domain.com", True),
("invalid", False),
("@example.com", False),
("", False),
])
def test_validate_email(self, user_service, email: str, is_valid: bool):
assert user_service.validate_email(email) == is_valid
模拟
from unittest.mock import Mock, patch, AsyncMock
def test_send_notification(user_service):
with patch("myapp.services.email_client") as mock_email:
mock_email.send = Mock(return_value=True)
user_service.notify(user_id=1, message="你好")
mock_email.send.assert_called_once_with(
to="test@example.com",
body="你好",
)
# 异步模拟
@pytest.mark.asyncio
async def test_fetch_data():
with patch("myapp.client.fetch", new_callable=AsyncMock) as mock_fetch:
mock_fetch.return_value = {"status": "ok"}
result = await process_data()
assert result["status"] == "ok"
conftest.py 模式
# tests/conftest.py
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
@pytest.fixture(scope="session")
def engine():
return create_engine("sqlite:///:memory:")
@pytest.fixture(scope="function")
def db_session(engine):
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
yield session
session.rollback()
session.close()
Base.metadata.drop_all(engine)
Go测试
表驱动测试
func TestCalculateDiscount(t *testing.T) {
tests := []struct {
name string
amount float64
code string
want float64
wantErr bool
}{
{
name: "有效的10%折扣",
amount: 100.0,
code: "SAVE10",
want: 90.0,
},
{
name: "无折扣",
amount: 100.0,
code: "",
want: 100.0,
},
{
name: "无效代码",
amount: 100.0,
code: "INVALID",
wantErr: true,
},
{
name: "零金额",
amount: 0,
code: "SAVE10",
want: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := CalculateDiscount(tt.amount, tt.code)
if (err != nil) != tt.wantErr {
t.Errorf("CalculateDiscount() 错误 = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("CalculateDiscount() = %v, want %v", got, tt.want)
}
})
}
}
Testify断言和模拟
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
// 模拟
type MockUserRepo struct {
mock.Mock
}
func (m *MockUserRepo) FindByID(id string) (*User, error) {
args := m.Called(id)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*User), args.Error(1)
}
func TestGetUser(t *testing.T) {
repo := new(MockUserRepo)
repo.On("FindByID", "123").Return(&User{ID: "123", Name: "John"}, nil)
service := NewUserService(repo)
user, err := service.GetUser("123")
require.NoError(t, err)
assert.Equal(t, "John", user.Name)
repo.AssertExpectations(t)
}
// HTTP处理器测试
func TestGetUserHandler(t *testing.T) {
req := httptest.NewRequest("GET", "/users/123", nil)
w := httptest.NewRecorder()
handler := NewHandler(mockService)
handler.GetUser(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var user User
err := json.NewDecoder(w.Body).Decode(&user)
require.NoError(t, err)
assert.Equal(t, "123", user.ID)
}
Rust测试
单元和集成测试
// src/lib.rs - 单元测试(同一文件)
pub fn calculate_discount(amount: f64, percentage: f64) -> Result<f64, DiscountError> {
if percentage < 0.0 || percentage > 100.0 {
return Err(DiscountError::InvalidPercentage(percentage));
}
Ok(amount * (1.0 - percentage / 100.0))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_valid_discount() {
let result = calculate_discount(100.0, 10.0).unwrap();
assert!((result - 90.0).abs() < f64::EPSILON);
}
#[test]
fn test_zero_discount() {
assert_eq!(calculate_discount(100.0, 0.0).unwrap(), 100.0);
}
#[test]
fn test_invalid_percentage() {
assert!(matches!(
calculate_discount(100.0, 150.0),
Err(DiscountError::InvalidPercentage(_))
));
}
#[test]
fn test_negative_percentage() {
assert!(calculate_discount(100.0, -10.0).is_err());
}
}
// tests/integration_test.rs - 集成测试(单独文件)
use my_crate::calculate_discount;
#[test]
fn test_full_workflow() {
let original = 200.0;
let discounted = calculate_discount(original, 25.0).unwrap();
assert_eq!(discounted, 150.0);
}
属性基测试 (proptest)
use proptest::prelude::*;
proptest! {
#[test]
fn discount_never_exceeds_original(amount in 0.0f64..10000.0, pct in 0.0f64..100.0) {
let result = calculate_discount(amount, pct).unwrap();
prop_assert!(result <= amount);
prop_assert!(result >= 0.0);
}
#[test]
fn roundtrip_serialization(name in "[a-zA-Z]{1,50}", age in 0u32..150) {
let user = User { name: name.clone(), age };
let json = serde_json::to_string(&user).unwrap();
let deserialized: User = serde_json::from_str(&json).unwrap();
prop_assert_eq!(user, deserialized);
}
}
视觉回归测试
Playwright截图
import { test, expect } from "@playwright/test";
test("首页视觉回归", async ({ page }) => {
await page.goto("/");
await expect(page).toHaveScreenshot("homepage.png", {
maxDiffPixelRatio: 0.01,
});
});
test("组件状态", async ({ page }) => {
await page.goto("/components");
// 默认状态
await expect(page.locator(".card")).toHaveScreenshot("card-default.png");
// 悬停状态
await page.locator(".card").hover();
await expect(page.locator(".card")).toHaveScreenshot("card-hover.png");
// 全页面,指定视口
await page.setViewportSize({ width: 375, height: 812 }); // iPhone
await expect(page).toHaveScreenshot("homepage-mobile.png");
});
// 更新快照: npx playwright test --update-snapshots
Percy (云视觉测试)
import percySnapshot from "@percy/playwright";
test("使用Percy的视觉测试", async ({ page }) => {
await page.goto("/dashboard");
await percySnapshot(page, "仪表板");
// Percy跨浏览器和视口尺寸比较
});
Chromatic (Storybook视觉测试)
# 在Storybook故事上运行Chromatic
npx chromatic --project-token=<token>
# CI集成
# Chromatic捕获每个故事作为视觉快照,并在PR中检测像素级更改
| 工具 | 方法 | 最适合 |
|---|---|---|
| Playwright | 本地截图比较 | 端到端视觉测试,免费 |
| Percy | 云端跨浏览器比较 | 多浏览器、团队审查 |
| Chromatic | Storybook故事快照 | 组件库视觉QA |
另请参阅
- 代码审查标准 - 质量要求
- 项目
CLAUDE.md- 测试规则