AppView in a box as a Vite plugin thing
hatk.dev
1---
2title: Testing
3description: Run tests and check code quality.
4---
5
6## `hatk test`
7
8Run your project's test suite.
9
10```bash
11hatk test # Run all tests
12hatk test --unit # Unit tests only
13hatk test --integration # Integration tests only
14hatk test --browser # Playwright browser tests
15```
16
17| Flag | Description |
18| --------------- | ------------------------------------------------ |
19| `--unit` | Run unit tests in `test/feeds/` and `test/xrpc/` |
20| `--integration` | Run integration tests in `test/integration/` |
21| `--browser` | Run Playwright browser tests in `test/browser/` |
22
23Without flags, all test types are run.
24
25## Writing unit tests
26
27Unit tests use `createTestContext()` from `hatk/test` to boot an in-memory hatk — lexicons, SQLite, feeds, and XRPC handlers — with no HTTP server, no PDS, and no indexer.
28
29```typescript
30import { describe, test, expect, beforeAll, afterAll } from 'vitest'
31import { createTestContext } from 'hatk/test'
32
33let ctx: Awaited<ReturnType<typeof createTestContext>>
34
35beforeAll(async () => {
36 ctx = await createTestContext()
37 await ctx.loadFixtures()
38})
39
40afterAll(async () => ctx?.close())
41```
42
43### Test context API
44
45| Method | Description |
46| ---------------------------- | -------------------------------------------------------------------- |
47| `ctx.loadFixtures(dir?)` | Load YAML fixture files from `test/fixtures/` (or a custom path) |
48| `ctx.loadFeed(name)` | Load a feed by name. Returns `{ generate(feedContext) }` |
49| `ctx.loadXrpc(name)` | Load an XRPC handler by name. Returns `{ handler(ctx) }` |
50| `ctx.feedContext(opts?)` | Create a feed context with `limit`, `cursor`, `viewer`, and `params` |
51| `ctx.db.query(sql, params?)` | Run a SQL query against the in-memory database |
52| `ctx.db.run(sql, ...params)` | Execute a SQL statement |
53| `ctx.close()` | Shut down the database |
54
55### Testing a feed
56
57```typescript
58test('returns statuses in reverse chronological order', async () => {
59 const feed = ctx.loadFeed('recent')
60 const result = await feed.generate(ctx.feedContext({ limit: 10 }))
61 expect(result.items).toHaveLength(6)
62})
63
64test('respects limit and cursor', async () => {
65 const feed = ctx.loadFeed('recent')
66 const page1 = await feed.generate(ctx.feedContext({ limit: 3 }))
67 expect(page1.cursor).toBeDefined()
68
69 const page2 = await feed.generate(ctx.feedContext({ limit: 3, cursor: page1.cursor }))
70 expect(page2.items).toHaveLength(3)
71})
72```
73
74### Testing an XRPC handler
75
76```typescript
77test('returns profile for known user', async () => {
78 const handler = ctx.loadXrpc('xyz.statusphere.getProfile')
79 const result = await handler.handler({
80 params: { actor: 'did:plc:alice' },
81 })
82 expect(result.handle).toBe('alice.test')
83})
84```
85
86## Fixtures
87
88Fixtures are YAML files in `test/fixtures/` that populate the in-memory database before tests run. Each file is named after the table it populates.
89
90### Account fixtures
91
92Create a `_repos.yaml` to register test accounts with handles. This file is loaded first, before any collection fixtures:
93
94```yaml
95# test/fixtures/_repos.yaml
96- did: did:plc:alice
97 handle: alice.test
98- did: did:plc:bob
99 handle: bob.test
100- did: did:plc:carol
101 handle: carol.test
102```
103
104If a DID appears in a collection fixture but not in `_repos.yaml`, it is auto-registered with a default handle (`<did-suffix>.test`).
105
106### Collection fixtures
107
108A file named after a collection inserts records into that collection. Only `did` is required — `uri` and `cid` are auto-generated if omitted:
109
110```yaml
111# test/fixtures/xyz.statusphere.status.yaml
112- did: did:plc:alice
113 status: "\U0001F680"
114 createdAt: $now(-5m)
115
116- did: did:plc:bob
117 status: "\U0001F9D1\u200D\U0001F4BB"
118 createdAt: $now(-10m)
119```
120
121Use the `rkey` field when a record needs a specific record key (e.g., singleton records like profiles) or when other records reference it by URI:
122
123```yaml
124# test/fixtures/app.bsky.actor.profile.yaml
125- did: did:plc:alice
126 rkey: self
127 displayName: Alice
128
129# test/fixtures/fm.teal.alpha.feed.play.yaml
130- did: did:plc:alice
131 rkey: '1'
132 trackName: Blinding Lights
133```
134
135Without `rkey`, URIs are generated using the record's index (`at://<did>/<collection>/0`, `at://<did>/<collection>/1`, etc.).
136
137### Custom table fixtures
138
139A YAML file whose name doesn't match a known collection creates a custom table with VARCHAR columns derived from the first record's keys:
140
141```yaml
142# test/fixtures/my_lookup_table.yaml
143- key: foo
144 value: bar
145- key: baz
146 value: qux
147```
148
149### The `$now` helper
150
151Use `$now` in fixture values to generate timestamps relative to the current time:
152
153| Expression | Result |
154| ----------- | ------------------- |
155| `$now` | Current time |
156| `$now(-5m)` | 5 minutes ago |
157| `$now(-2h)` | 2 hours ago |
158| `$now(-1d)` | 1 day ago |
159| `$now(30s)` | 30 seconds from now |
160
161This keeps fixtures time-relative so tests for "recent" feeds and time-based sorting always work.
162
163## Integration tests
164
165Integration tests use `startTestServer()` to boot a full HTTP server on a random port:
166
167```typescript
168import { describe, test, expect, beforeAll, afterAll } from 'vitest'
169import { startTestServer } from 'hatk/test'
170
171let server: Awaited<ReturnType<typeof startTestServer>>
172
173beforeAll(async () => {
174 server = await startTestServer()
175 await server.loadFixtures()
176})
177
178afterAll(async () => server?.close())
179
180test('GET /xrpc/dev.hatk.getFeed returns items', async () => {
181 const res = await server.fetch('/xrpc/dev.hatk.getFeed?feed=recent&limit=5')
182 const data = await res.json()
183 expect(data.items.length).toBeGreaterThan(0)
184})
185```
186
187### Test server API
188
189The test server extends the test context with:
190
191| Method | Description |
192| ---------------------------------- | ------------------------------------------------------------ |
193| `server.url` | The base URL (e.g., `http://127.0.0.1:54321`) |
194| `server.fetch(path, init?)` | Fetch a path on the test server |
195| `server.fetchAs(did, path, init?)` | Fetch as an authenticated user (sets `x-test-viewer` header) |
196| `server.seed(opts?)` | Get seed helpers for creating records against a real PDS |