|
| 1 | +--- |
| 2 | +name: vitest-patterns |
| 3 | +description: Patterns and conventions for writing vitest tests in this project. This skill should be used when writing new tests, adding test cases to existing test files, or reviewing test code for correctness. Trigger whenever the user asks to write tests, add test coverage, create test files, or mentions vitest/testing. |
| 4 | +--- |
| 5 | + |
| 6 | +# Vitest Test Patterns |
| 7 | + |
| 8 | +Conventions and patterns for writing vitest tests in this codebase. |
| 9 | + |
| 10 | +## Imports |
| 11 | + |
| 12 | +Always import test utilities from `vitest`. Use named imports: |
| 13 | + |
| 14 | +```ts |
| 15 | +import { assert, describe, expect, it, vi } from 'vitest' |
| 16 | +``` |
| 17 | + |
| 18 | +Only import what you use. Add `vi` only when using mock functions. Add `assert` only when narrowing types. |
| 19 | + |
| 20 | +## Test Structure |
| 21 | + |
| 22 | +### Describe labels |
| 23 | + |
| 24 | +Pass the function/class under test directly as the `describe` label when possible. Use string labels for conceptual groupings: |
| 25 | + |
| 26 | +```ts |
| 27 | +// Function reference as label (preferred when testing a single export) |
| 28 | +describe(parseCid, () => { ... }) |
| 29 | +describe(isLexValue, () => { ... }) |
| 30 | + |
| 31 | +// String label for conceptual groups or when testing multiple related behaviors |
| 32 | +describe('roundtrip toBase64 <-> fromBase64', () => { ... }) |
| 33 | +describe('isObject', () => { ... }) |
| 34 | +``` |
| 35 | + |
| 36 | +### Grouping |
| 37 | + |
| 38 | +Nest logical groupings inside the top-level describe. Common groupings: |
| 39 | + |
| 40 | +```ts |
| 41 | +describe(someFunction, () => { |
| 42 | + describe('valid inputs', () => { ... }) |
| 43 | + describe('invalid inputs', () => { ... }) |
| 44 | + describe('edge cases', () => { ... }) |
| 45 | +}) |
| 46 | +``` |
| 47 | + |
| 48 | +For features with a default behavior and an override, cover both: |
| 49 | + |
| 50 | +```ts |
| 51 | +describe('validateResponse', () => { |
| 52 | + it('rejects invalid response body by default', ...) |
| 53 | + it('accepts invalid response body when disabled', ...) |
| 54 | + it('succeeds with valid response body when enabled', ...) |
| 55 | +}) |
| 56 | +``` |
| 57 | + |
| 58 | +For safety-critical code, group edge cases under a `'safety'` or `'edge cases'` describe: |
| 59 | + |
| 60 | +```ts |
| 61 | +describe('safety', () => { |
| 62 | + it('handles cyclic structures without infinite loops', () => { ... }) |
| 63 | + it('handles deep structures without exceeding call stack', () => { ... }) |
| 64 | +}) |
| 65 | +``` |
| 66 | + |
| 67 | +## Parameterized Tests |
| 68 | + |
| 69 | +Use `for...of` loops over test case arrays instead of `it.each`: |
| 70 | + |
| 71 | +```ts |
| 72 | +describe(isLexScalar, () => { |
| 73 | + for (const { note, value, expected } of [ |
| 74 | + { note: 'string', value: 'hello', expected: true }, |
| 75 | + { note: 'boolean', value: true, expected: true }, |
| 76 | + { note: 'null', value: null, expected: true }, |
| 77 | + { note: 'number (float)', value: 3.14, expected: false }, |
| 78 | + { note: 'undefined', value: undefined, expected: false }, |
| 79 | + ]) { |
| 80 | + it(note, () => { |
| 81 | + expect(isLexScalar(value)).toBe(expected) |
| 82 | + }) |
| 83 | + } |
| 84 | +}) |
| 85 | +``` |
| 86 | + |
| 87 | +This also works for running the same test suite against multiple implementations: |
| 88 | + |
| 89 | +```ts |
| 90 | +for (const utf8Len of [utf8LenNode!, utf8LenCompute!] as const) { |
| 91 | + describe(utf8Len, () => { |
| 92 | + it('computes utf8 string length', () => { |
| 93 | + expect(utf8Len('a')).toBe(1) |
| 94 | + }) |
| 95 | + }) |
| 96 | +} |
| 97 | +``` |
| 98 | + |
| 99 | +## Assertions |
| 100 | + |
| 101 | +### Type narrowing with `assert` |
| 102 | + |
| 103 | +Use `assert()` from vitest for type narrowing and boolean checks. **Always prefer `assert(result.success)` over `expect(result.success).toBe(true)`** — the `assert` provides type narrowing in TypeScript, which allows the rest of the test to access narrowed properties without additional type guards. |
| 104 | + |
| 105 | +```ts |
| 106 | +// Narrow to a specific type before further assertions |
| 107 | +assert(err instanceof XrpcFetchError) |
| 108 | +expect(err.cause).toBeInstanceOf(TypeError) |
| 109 | + |
| 110 | +// Discriminated union checks - PREFERRED |
| 111 | +assert(result.success) |
| 112 | +expect(result.body).toEqual({ value: 'hello' }) |
| 113 | +// TypeScript now knows result.body exists |
| 114 | + |
| 115 | +assert(!result.success) |
| 116 | +expect(result).toBeInstanceOf(XrpcResponseError) |
| 117 | +// TypeScript now knows result has error properties |
| 118 | + |
| 119 | +// DON'T do this - it doesn't narrow types |
| 120 | +expect(result.success).toBe(true) // ❌ No type narrowing |
| 121 | +if (result.success) { |
| 122 | + expect(result.body).toEqual({ value: 'hello' }) // Still need type guard |
| 123 | +} |
| 124 | +``` |
| 125 | + |
| 126 | +### Error assertions with `rejects.toSatisfy` |
| 127 | + |
| 128 | +For thrown errors, prefer `rejects.toSatisfy()` over `rejects.toThrow()` when you need multiple detailed assertions: |
| 129 | + |
| 130 | +```ts |
| 131 | +await expect( |
| 132 | + someAsyncFn(), |
| 133 | +).rejects.toSatisfy((err) => { |
| 134 | + assert(err instanceof SomeError) |
| 135 | + expect(err.cause).toBeInstanceOf(TypeError) |
| 136 | + expect(err.message).toContain('failed') |
| 137 | + return true // must return true |
| 138 | +}) |
| 139 | +``` |
| 140 | + |
| 141 | +Always `return true` at the end of `toSatisfy` callbacks. |
| 142 | + |
| 143 | +For simple "it throws" checks, `toThrow()` is fine: |
| 144 | + |
| 145 | +```ts |
| 146 | +expect(() => parseCid(invalidStr)).toThrow() |
| 147 | +expect(() => cidForRawHash(new Uint8Array(31))).toThrow('Invalid SHA-256 hash length') |
| 148 | +``` |
| 149 | + |
| 150 | +## Mock Functions |
| 151 | + |
| 152 | +### Use `vi.fn()` with type parameters |
| 153 | + |
| 154 | +When you need to inspect how a function was called, use `vi.fn<Type>()`: |
| 155 | + |
| 156 | +```ts |
| 157 | +const fetchHandler = vi.fn<FetchHandler>(async () => |
| 158 | + Response.json({ value: 'ok' }), |
| 159 | +) |
| 160 | + |
| 161 | +await xrpc(fetchHandler, testQuery, { params: { limit: 25 } }) |
| 162 | + |
| 163 | +expect(fetchHandler).toHaveBeenCalledOnce() |
| 164 | +const [path, init] = fetchHandler.mock.calls[0] |
| 165 | +expect(path).toContain('/xrpc/io.example.testQuery') |
| 166 | +``` |
| 167 | + |
| 168 | +When you don't need to inspect calls, use a plain typed function: |
| 169 | + |
| 170 | +```ts |
| 171 | +const fetchHandler: FetchHandler = async () => Response.json({ value: 'hello' }) |
| 172 | +``` |
| 173 | + |
| 174 | +## Test Fixtures |
| 175 | + |
| 176 | +Define reusable fixtures at the top of the file, outside describe blocks: |
| 177 | + |
| 178 | +```ts |
| 179 | +const invalidCidStr = 'invalidcidstring' |
| 180 | +const cborCidStr = 'bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a' |
| 181 | +const cborCid = parseCid(cborCidStr, { flavor: 'cbor' }) |
| 182 | +``` |
| 183 | + |
| 184 | +Keep fixtures minimal and focused on what the tests need. |
| 185 | + |
| 186 | +## TypeScript in Tests |
| 187 | + |
| 188 | +### Intentionally invalid arguments |
| 189 | + |
| 190 | +Use `// @ts-expect-error` with a description: |
| 191 | + |
| 192 | +```ts |
| 193 | +await xrpc(fetchHandler, testQuery, { |
| 194 | + // @ts-expect-error intentionally passing invalid params |
| 195 | + params: { limit: 'not-a-number' }, |
| 196 | + validateRequest: true, |
| 197 | +}) |
| 198 | +``` |
| 199 | + |
| 200 | +## Global Stubbing |
| 201 | + |
| 202 | +When testing code that uses a global, temporarily replace it and restore in a `finally` block: |
| 203 | + |
| 204 | +```ts |
| 205 | +it('throws TypeError when fetch is not available', () => { |
| 206 | + const originalFetch = globalThis.fetch |
| 207 | + try { |
| 208 | + // @ts-expect-error removing fetch to simulate missing environment |
| 209 | + globalThis.fetch = undefined |
| 210 | + expect(() => buildAgent({ service: 'https://example.com' })).toThrow(TypeError) |
| 211 | + } finally { |
| 212 | + globalThis.fetch = originalFetch |
| 213 | + } |
| 214 | +}) |
| 215 | +``` |
| 216 | + |
| 217 | +Use `try/finally` (not `beforeEach`/`afterEach`) when the stub is scoped to a single test. |
| 218 | + |
| 219 | +## Roundtrip Tests |
| 220 | + |
| 221 | +When testing encode/decode or serialize/deserialize pairs, add a dedicated roundtrip describe: |
| 222 | + |
| 223 | +```ts |
| 224 | +describe('roundtrip toBase64 <-> fromBase64', () => { |
| 225 | + it('roundtrips empty array', () => { |
| 226 | + const original = new Uint8Array(0) |
| 227 | + expect(ui8Equals(fromBase64(toBase64(original)), original)).toBe(true) |
| 228 | + }) |
| 229 | + |
| 230 | + it('roundtrips all byte values', () => { |
| 231 | + const allBytes = new Uint8Array(256) |
| 232 | + for (let i = 0; i < 256; i++) allBytes[i] = i |
| 233 | + expect(ui8Equals(fromBase64(toBase64(allBytes)), allBytes)).toBe(true) |
| 234 | + }) |
| 235 | +}) |
| 236 | +``` |
| 237 | + |
| 238 | +## Workflow |
| 239 | + |
| 240 | +- Do not worry about code formatting or lint issues. The user will review changes in their editor, which applies formatting automatically on save. |
| 241 | +- Do not commit test changes. Leave them as unstaged modifications for the user to review. |
| 242 | + |
| 243 | +## Running Tests |
| 244 | + |
| 245 | +```bash |
| 246 | +pnpm exec vitest run path/to/file.test.ts |
| 247 | +``` |
0 commit comments