Rules for writing tests, and for asking for them in review. AGENTS.md carries the same writing rules under Verifying; change both together.
- Test meaningful logic or observable behavior. Do not render components to static markup to assert props or attributes, or add tests that merely assert callback wiring or mirror the implementation.
- In review, ask for a missing test only when it would catch a real regression. Do not ask for one assertion per constant, label, or
switchcase. - Backend behavior changes ship with focused tests for that behavior.
- Proof for a bug fix depends on the bug. Logic, data, state, and protocol bugs get a failing test first (the fewest that fail on
mainfor that reason, in the module's existing test file), then the fix. Purely visual bugs (spacing, colour, alignment, a layout shift) get before/after captures as their proof and no test: do not add UI tests for nitpicks, and do not ask for one in review when a capture already shows it. A server-only fix with no visible surface ships the test plus a short runtime transcript.
- The server is event-sourced, and side effects run after the command commits. In tests, drain the effect worker (
OrchestrationEffectWorkerV2.drain) or await the specific persisted event orDeferredthat marks the milestone. Never wait on sleeps or polling. A test that needs a timeout to pass is wrong. Concretely:it.effectruns on aTestClock;Effect.sleeponly advances throughTestClock.adjust.it.liveis wall-clock and only for subprocess or network fixtures.- No
yieldNoworsleeppolling loops. Await theDeferred, take from theQueue, or drain the worker. - No per-test timeout argument or
retry. The project timeout is the ceiling, and flaky tests are fixed, not retried. - No
performance.now()orDate.now()deltas. Assert on events and counts, not elapsed time. - Browser tests assert through
expect.elementorvi.waitFor, neverrequestAnimationFrameortransitionend. vi.mockfactories must not spread a real module that pulls in Shiki or the diff workers.it.skipand friends need a tracking issue on the line above:// quarantine: #1234 <why>.- Lint enforces this:
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 browser tests are
*.browser.test.tsxor*.browser.test.tsand render withawait using screen = await render(...)fromapps/web/test/render.tsx.t3code/require-disposable-rendererrors on the old patterns.- Acquire what the test drives after mounting (fake timers it advances, spies it sets up after mounting) after the render, so it disposes before the unmount. Anything the first render reads comes before it.
- A helper that renders and then waits or clicks puts the screen in an
AsyncDisposableStackandmove()s it out only on success, so a failed wait still unmounts. rerenderneeds a fresh element on every call.- No lifecycle hooks and no module-level mutable state. Shared mock state that
vi.mockfactories read is reset by asetup()call at the top of the test. routeris on by default. Passrouter: falseonly with a one-line comment saying why.
it("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"); });