测试
编写测试的规则,以及在评审里怎样要求补测试。AGENTS.md 在 Verifying(验证)一节有相同的编写规则;两处一起改。
测什么
- 测试可能坏掉的行为,例如编排和事件溯源、提供方(provider)协议转换、认证和权限守卫、持久化与迁移、外部格式解析、竞态修复,以及某次缺陷修复改动的逻辑。不要为这些写测试:类型已经保证的事、琐碎逻辑、文案、冒烟运行、对 mock 调用的镜像、回调接线,以及渲染成静态标记的 props。
- 先扩展,再新增。已有的测试或表格覆盖了这个行为,就在那里加一行或一条断言。只有没有任何用例合适时,才新写一个
it。 - 只在一层测:会在
main上失败的最低那一层。不要在包装测试里、每个调用点,或同一套逻辑的 Web 与移动端副本里把这个用例再写一遍。 - 评审时,只有缺的这条测试能抓住真正的回归,才要求补上。不要要求每个常量、每条标签或每个
switch分支都有一条断言。新测试若违反上面的规则,指出来,并写明改法:并进已有的用例或表格、删掉,或只留一层。 - 后端行为有改动时,带上针对这个行为的聚焦测试一起交付。
- 缺陷修复拿什么当证明,取决于缺陷本身。逻辑、数据、状态和协议上的缺陷,先写失败的测试(条数取最少,而且它们是因为这个原因在
main上失败;该模块测试文件里已有合适的用例时,就加在那里),然后再修复。纯视觉缺陷(间距、颜色、对齐、布局偏移)用改前和改后的截图当证明,不写测试:不要为细小的视觉问题加 UI 测试;截图已经显示出来时,评审里也不要再要一条。没有可见界面的纯服务器修复,交付测试,再附一段简短的运行时记录。
等待工作完成
- 服务器采用事件溯源,副作用在命令(command)提交之后才运行。测试里要排空效应工作器(
OrchestrationEffectWorkerV2.drain),或者等待标志着这个里程碑的那条已持久化事件(event),或等待Deferred。不要靠 sleep 或轮询来等。得靠超时才能通过的测试就是错的。具体如下:it.effect在TestClock上运行;Effect.sleep只能通过TestClock.adjust向前推进。it.live走墙上时钟,只用于子进程或网络夹具。- 不要写
yieldNow或sleep的轮询循环。去等待Deferred,从Queue取出,或者排空工作器。 - 不要给单个测试加超时参数,也不要
retry。项目的超时是上限;不稳定的测试要修,不要重试。 - 不要用
performance.now()或Date.now()的差值。断言事件和计数,不要断言经过的时间。 - 浏览器测试通过
expect.element或vi.waitFor做断言,绝不用requestAnimationFrame或transitionend。 vi.mock工厂不得展开一个会拉进 Shiki 或 diff 工作器的真实模块。it.skip以及同类写法,上一行要有跟踪 issue:// quarantine: #1234 <why>。- Lint 强制这些规则:
t3code/no-test-timeout-or-unreasoned-skip、t3code/no-frame-waits-in-browser-tests、t3code/no-wall-clock-in-tests、t3code/no-real-time-wait-in-tests、t3code/no-polling-loop-in-tests。
Web 浏览器测试
Web 浏览器测试是
*.browser.test.tsx或*.browser.test.ts,并用apps/web/test/render.tsx里的await using screen = await render(...)来渲染。旧写法会让t3code/require-disposable-render报错。- 测试在挂载之后才去驱动的东西(要往前拨的模拟定时器、挂载之后才设置的 spy),在渲染之后再取得,这样它们会在卸载之前释放。第一次渲染就会读到的东西,放在渲染之前。
- 先渲染再等待或点击的辅助函数,把界面放进
AsyncDisposableStack,并且只在成功时才用move()把它移出来,所以等待失败时仍然会卸载。 rerender每次调用都要一个新元素。- 不要用生命周期 hook,也不要模块级的可变状态。
vi.mock工厂会读到的共享 mock 状态,在测试开头用一次setup()调用重置。 router默认开启。只有旁边用一行注释说明原因时,才传入router: false。
tsxit("saves on Enter", async () => { const onSave = vi.fn(); using _now = vi.spyOn(Date, "now").mockReturnValue(0); await using screen = await render(<TitleEditor onSave={onSave} />, { router: "/threads/1" }); await userEvent.type(screen.getByRole("textbox"), "Title{Enter}"); expect(onSave).toHaveBeenCalledWith("Title"); });