测试与质量¶
测试分两层:Go 的单元/HTTP 集成测试,以及前端的 Vitest 组件测试。CI 会同时跑后端与前端。
1. 命令¶
# 后端
make test # go test ./...
make coverage # 生成 coverage.out 并打印总覆盖率
make coverage-html # 生成 coverage.html
go test ./tests/integration -run TestUsers -v
go test -race ./tests/unit/service/...
# 前端
cd frontend
npm test # vitest run
npm run test:watch
npm run test:coverage
npm run lint # oxlint
npm run build # tsc -b && vite build
CI 对应 .github/workflows/ci.yml:
- Backend:
go vet→go test ./tests/... -coverpkg=./internal/...→ 上传覆盖率。 - Frontend:
npm ci→npm run lint→npm test→npm run build。
2. 目录¶
tests/
testsupport/app.go 完整应用测试夹具(SQLite + 内存缓存 + 路由)
unit/
pkg/ 基础库单测
service/ 业务规则单测
repository/ 仓储单测(软删除、错误分支)
handler/ middleware/ HTTP 层单测
integration/ 走真实路由 + 鉴权的接口测试
frontend/src/**/*.test.tsx 组件与 store 测试
单元测试和集成测试都大量复用 testsupport,区别是:unit 直接调用 service/repository,integration 通过 app.Do 发 HTTP 请求。
3. Go 测试夹具¶
它做了:
- 用当前测试名创建独立的内存 SQLite 数据库,
SetMaxOpenConns(1)。 database.AutoMigrate建全部表(含新模型,记得同步)。- 内存缓存、临时目录存储、低成本 bcrypt(cost=4)。
- 组装全部 repo、service、handler、router,与生产的装配方式一致。
- 启动导出 worker,注册 users / logs 导出模块。
t.Cleanup关闭连接、日志队列、导出服务。
常用辅助:
admin := app.SeedAdmin(t) // admin / Admin@123456 + super_admin
pair := app.Login(t, admin.Username, "Admin@123456") // 真实验证码 + 登录流程
role := app.EnsureRole(t, "operator", "运营")
user := app.CreateUser(t, "alice", "abc12345", 1, "operator")
rr := app.Do(http.MethodGet, "/admin/v1/users", nil, pair.AccessToken)
env := testsupport.Decode(t, rr) // {code,message,data}
Login 会先请求 /auth/captcha,再从缓存读取答案,模拟真实登录;因此它同时覆盖验证码、锁定、令牌签发。
4. 写一个 service 单测¶
func TestRoleValidation(t *testing.T) {
ctx := context.Background()
app := testsupport.New(t)
_, err := app.Services.Roles.Create(ctx, service.RoleInput{
Name: "", Code: "operator", Status: 1,
})
require.Error(t, err)
assert.Equal(t, apperr.CodeInvalidArgument, apperr.From(err).Code)
}
建议覆盖:
- 正常路径(创建/更新/删除/查询)。
- 参数校验(空值、格式、状态)。
- 唯一冲突(用户名/角色编码/参数键)。
- 404 与软删除后的行为。
- 越权(非 super 授予 super、分配高权限角色)。
- 安全变更后的 session_version。
5. 写一个 HTTP 集成测试¶
func TestAnnouncementHTTP(t *testing.T) {
app := testsupport.New(t)
admin := app.SeedAdmin(t)
pair := app.Login(t, admin.Username, "Admin@123456")
rr := app.Do(http.MethodPost, "/admin/v1/announcements", map[string]any{
"title": "维护通知", "content": "今晚升级", "status": 1,
}, pair.AccessToken)
require.Equal(t, http.StatusCreated, rr.Code)
env := testsupport.Decode(t, rr)
require.Equal(t, 0, env.Code)
}
集成测试重点:
Require权限码是否生效(普通用户 403、super 200)。- 统一响应信封与 HTTP 状态码。
- 分页参数边界(page=0、page_size=999)。
- 文件上传/下载、分享令牌、Cookie 鉴权。
- 会话撤销、改密、停用后旧 token 失效。
- 导出任务的提交、轮询、下载。
6. 前端测试¶
Vitest 配置在 frontend/vite.config.ts:
test: {
environment: 'jsdom',
include: ['src/**/*.test.{ts,tsx}'],
setupFiles: ['./src/test/setup.ts'],
}
src/test/setup.ts 补齐了 jsdom 缺失的 matchMedia、scrollIntoView、ResizeObserver 与 getComputedStyle。
典型组件测试(FileSelect.test.tsx):
vi.mock('../stores/auth', () => ({
useAuth: (selector) => selector({ can: () => false }),
}))
function renderWithQuery(ui: ReactElement) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>)
}
建议:
- 用
@testing-library/react的screen.getByRole查询,避免依赖 class。 - 不 mock
api/client时,用 MSW 或在测试内vi.mock;现有页面测试多用vi.spyOn(api, 'get')。 - antd 弹窗/通知需要包
App或AntdApp上下文。 - 每个测试后
cleanup()+vi.clearAllMocks()。 - 组件里的权限判断通过 mock
useAuth控制,不依赖真实登录。
7. 覆盖率¶
- 后端覆盖率命令:
go test ./tests/... -coverpkg=./internal/... -coverprofile=coverage.out。 - 覆盖率只统计被测试触达的代码;
cmd/与生成代码不计入。 - 不要为了数字写无断言测试;优先覆盖业务分支、错误路径、越权路径。
- 前端用
@vitest/coverage-v8,关注核心组件与工具函数,不追求 100%。
8. 测试约定¶
- 测试文件命名
*_test.go/*.test.ts(x)。 - 表驱动测试用
t.Run(name, ...);失败信息包含输入。 - 单元测试不连真实 MySQL/Redis;需要时用
testsupport(SQLite + memory)。 - 时间相关逻辑通过
WithClock或可替换的now注入,避免 sleep。 - 不依赖测试执行顺序;每个测试独立 app。
- 测试中的密码用低成本 bcrypt,生产配置不要跟着改。
- 新增模型后同步
database.AutoMigrate,否则集成测试建表失败。
9. 常见问题¶
| 现象 | 原因 |
|---|---|
no such table |
新模型没加到 AutoMigrate |
| 集成测试 401 | 没走 app.Login,或访问的是 /uploads 需要 Cookie |
| 验证码错误 | 直接构造登录请求,没先取 /auth/captcha |
| SQLite 锁库 | 多个连接并发写;夹具已设 MaxOpenConns(1),测试内不要另开连接 |
前端 matchMedia is not a function |
没加载 src/test/setup.ts |
antd 报 App.useApp 上下文缺失 |
组件外层没包 AntdApp / App |
| 测试互相污染 | 共用了同一个 testsupport.New(t) 实例或全局 store |