OPCStack 使用 Vitest 进行单元测试和 E2E 测试。单元测试与源代码放在一起。E2E 测试放在 e2e/ 目录下,调用运行中的应用的 HTTP API。
默认的 pnpm test 命令不仅仅是 Vitest,它还运行 TypeScript、Svelte 和单元测试:
pnpm exec tsc --noEmit -p tsconfig.tsc.json
pnpm exec svelte-check --tsconfig ./tsconfig.json
vitest
测试层次
| 层次 | 位置 | 命令 | 用途 |
|---|---|---|---|
| 类型检查 | src/、配置 |
pnpm test |
捕获 TS 契约错误 |
| Svelte 检查 | src/frontend/ |
pnpm test |
捕获 Svelte 组件错误 |
| 单元测试 | src/**/*.test.ts、scripts/**/*.test.mjs |
pnpm test |
领域、处理器、配置、provider、工具函数行为 |
| 本地 E2E | e2e/**/*.test.ts |
pnpm test:e2e |
对本地开发应用的 HTTP 流程 |
| 首次安装验收 | 隔离的临时项目 | pnpm test:e2e:first-run |
空 D1、一次性凭据、配置、OAuth 和撤销 |
| 远端 E2E | e2e/**/*.test.ts |
pnpm test:e2e:remote |
对已部署应用的 HTTP 流程 |
单元测试不包含 e2e/**。E2E 测试使用 vitest.e2e.config.ts。
单元测试
单元测试与被测代码放在一起:
src/backend/payment/index.ts
src/backend/payment/index.test.ts
src/backend/ai/image/gemini/index.ts
src/backend/ai/image/gemini/index.test.ts
src/api-contract/common.ts
src/api-contract/common.test.ts
运行所有单元检查:
pnpm test
运行单个单元测试文件:
pnpm exec vitest src/backend/payment/index.test.ts
按名称运行单个测试:
pnpm exec vitest src/backend/payment/index.test.ts -t "checkout"
遵循现有的本地风格。这个项目通常将被测输出包装在结构化对象中再断言:
type ThenExpected = {
result: number
}
const actual: ThenExpected = {
result: add(1, 2)
}
expect(actual).toEqual({
result: 3
})
这种风格让 diff 可读,避免分散的断言。
BDD 辅助工具
共享 BDD 辅助工具:
src/backend/testing/bdd.ts
导出内容:
export type TestCase<TGiven, TWhen, TThen> = {
scenario: string
given: string
when: string
then: string
timeoutMs?: number
givenDetail: TGiven
whenDetail: TWhen
thenExpected: TThen
}
export function runCases<TGiven, TWhen, TThen>(
cases: TestCase<TGiven, TWhen, TThen>[],
fn: (given: TGiven, when: TWhen) => TThen | Promise<TThen>
): void
当一个行为有多个输入用例时使用它:
import { describe } from 'vitest'
import { runCases, type TestCase } from '$backend/testing/bdd'
function add(a: number, b: number): number {
return a + b
}
type GivenDetail = Record<string, never>
type WhenDetail = { a: number; b: number }
type ThenExpected = { result: number }
describe('add', () => {
const cases: TestCase<GivenDetail, WhenDetail, ThenExpected>[] = [
{
scenario: 'add positive numbers',
given: 'two positive integers',
when: 'adding them',
then: 'returns their sum',
givenDetail: {},
whenDetail: { a: 1, b: 2 },
thenExpected: { result: 3 }
}
]
runCases(cases, (_given, when): ThenExpected => {
return { result: add(when.a, when.b) }
})
})
保持用例名称面向行为。测试名称应描述从用户或调用者角度来看哪里出了问题。
处理器测试
API 处理器测试应从 src/api-contract/ 导入共享契约,而不是在测试内部重复定义请求或响应格式。
推荐做法:
import type { CreatePaymentCheckoutResponse } from '$apiContract/payment'
不推荐做法:
type LocalCheckoutResponse = {
checkout_url: string
}
只有当响应有意对断言进行部分提取时,本地响应类型才可以接受。
配置测试
配置解析是业务逻辑,直接测试它。
项目中已有的例子:
| 文件 | 用途 |
|---|---|
scripts/prepare-cloudflare.test.mjs |
Cloudflare 配置渲染、bindings、fallback 密钥校验 |
src/backend/payment/config.test.ts |
支付产品配置解析 |
src/frontend/lib/i18n/locales.test.ts |
语言区域配置 |
规则:
- 配置错误应尽早失败
- 不要通过快照大型生成文件来测试,除非精确输出本身就是被测行为
- 对无效配置的测试与有效配置同等重要
外部 Provider 测试
Provider 单元测试不能调用真实的外部 API。对 fetch、provider SDK 调用、队列 binding、R2 binding 或 D1 调用进行 stub。
当前有单元测试的 provider 区域:
| 区域 | 示例 |
|---|---|
| AI | src/backend/ai/image/openai/index.test.ts |
src/backend/email/resend/index.test.ts |
|
| Payment | src/backend/payment/creem.test.ts |
| R2 | src/backend/r2/index.test.ts |
测试请求映射、响应映射、provider 错误映射和幂等本地状态更新。不要断言实现细节。
队列与 Cron 测试
队列和 cron 测试直接调用处理器。
现有文件:
| 文件 | 测试内容 |
|---|---|
src/backend/consumers/ai-image.test.ts |
图像队列任务执行和重试 |
src/backend/consumers/ai-tts.test.ts |
TTS 队列任务执行和重试 |
src/backend/consumers/ai-video.test.ts |
视频队列任务轮询、完成和重试 |
src/backend/jobs/index.test.ts |
定时任务分发和积分清理 |
队列测试中,伪造 Cloudflare 消息格式,足以验证 ack() 和 retry() 即可。
Cron 测试中,传入带有精确 cron 表达式的 ScheduledController 对象:
await handleScheduled(
{ cron: '*/10 * * * *', scheduledTime: 1890000000000 } as ScheduledController,
env,
ctx
)
E2E 测试
E2E 测试放在:
e2e/
aff.test.ts
auth-email.test.ts
beta.test.ts
client-config.test.ts
credits.test.ts
feedback.test.ts
notification.test.ts
payment.test.ts
r2.test.ts
sharding.test.ts
运行本地 E2E:
pnpm dev
pnpm test:e2e
pnpm dev 运行 Worker 和 Vite 开发服务器。本地 E2E 使用配置中的 APP_BASE_URL,通常指向 Vite 开发端口。
在隔离的空项目副本中运行真实首次安装验收:
pnpm test:e2e:first-run
这个命令会对空本地 D1 执行两次准备,证明初始管理员密码只显示一次,然后启动真实 Worker 和 Web Server,通过 HTTP 修改自动生成的密码,通过浏览器 Session 保存 General,通过 OAuth 读取 Credits,验证前台立即生效,撤销 Grant,最后运行完整本地 E2E。它不会读取当前项目的 secret 文件或本地数据库。
远端 E2E:
pnpm test:e2e:remote
远端 E2E 设置 E2E_REMOTE=1 并指向 https://APP_DOMAIN。
远端 E2E 限制
远端 E2E 只能通过公开 HTTP 访问已部署的环境。
允许:
- 调用公共 HTTP API
- 验证认证门控
- 验证已配置的功能
- 验证已部署的客户端配置
- 通过公开管理 API 保存配置并恢复原值
禁止:
- 执行部署
- 运行迁移
- 创建 Cloudflare 资源
- 修改分片数量
- 直接写入
d1_shards - 直接写入远端 D1
如果远端测试需要管理员操作,通过公共管理员 API 配合管理员认证调用。不要绕过应用程序。
E2E 配置
vitest.e2e.config.ts 派生应用地址和基础设施测试开关,然后将其作为 E2E_* 测试环境变量暴露。业务功能状态通过 HTTP API 读取。
重要的派生值:
| E2E 环境变量 | 含义 |
|---|---|
APP_BASE_URL |
本地或远端应用的 base URL |
E2E_REMOTE |
远端运行时为 1 |
E2E_ADMIN_EMAIL |
管理员 Session 测试使用的管理员邮箱 |
E2E_ADMIN_PASSWORD |
管理员 Session 测试使用的管理员密码 |
E2E_R2_ENABLED |
R2 测试是否可以运行 |
E2E_D1_SHARD_COUNT |
分片测试预期的分片数量 |
功能被禁用时,测试应跳过该功能相关的流程。功能被禁用不等于部署失败。
TDD 规则
功能开发:
- 先写定义用户可见行为的 E2E 测试
- 将实现拆分为模块
- 对每个模块写一个失败的单元测试
- 写最小的生产代码使其通过
- 只在测试保持绿色的情况下重构
- 模块组装完成后运行 E2E 测试
缺陷修复:
- 写一个复现 bug 的失败测试
- 用最小的改动修复 bug
- 保留回归测试
不要先写生产代码再补测试。那样会创建证明实现的测试,而不是证明需求的测试。
命令
# 完整的本地单元检查
pnpm test
# 单个单元测试文件
pnpm exec vitest src/backend/credits/index.test.ts
# 按名称运行单个测试
pnpm exec vitest src/backend/credits/index.test.ts -t "daily"
# 本地 E2E
pnpm test:e2e
# 空项目首次安装验收
pnpm test:e2e:first-run
# 远端 E2E
pnpm test:e2e:remote
依赖生成的 Env bindings 的测试运行前:
pnpm prepare:cloudflare:dev
pnpm exec wrangler types --config .wrangler/wrangler.types.jsonc --env-file .wrangler/runtime-secrets.env --strict-vars false
常见错误
使用错误的 BDD 辅助工具路径。 辅助工具在 src/backend/testing/bdd.ts,不是 src/testing/bdd.ts。
用真实 API 调用测试 provider 集成。 单元测试应 stub provider 调用。真实 provider 不稳定、慢且昂贵。
在处理器测试中添加本地契约类型。 使用 src/api-contract/,这样测试能捕获契约漂移。
将远端 E2E 作为部署自动化运行。 远端 E2E 验证已有的部署,不能准备资源或修改基础设施。
在一个测试中测试多个行为。 如果测试名称需要"并且",把它拆分。
对被测单元过度 mock。 mock 外部边界,不要 mock 你要证明其行为的函数。