hatk test
Run your project's test suite.
hatk test # Run all tests
hatk test --unit # Unit tests only
hatk test --integration # Integration tests only
hatk test --browser # Playwright browser tests| Flag | Description |
|---|---|
--unit | Run unit tests in test/feeds/ and test/xrpc/ |
--integration | Run integration tests in test/integration/ |
--browser | Run Playwright browser tests in test/browser/ |
Without flags, all test types are run.
Writing unit tests
Unit 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.
import { describe, test, expect, beforeAll, afterAll } from 'vitest'
import { createTestContext } from 'hatk/test'
let ctx: Awaited<ReturnType<typeof createTestContext>>
beforeAll(async () => {
ctx = await createTestContext()
await ctx.loadFixtures()
})
afterAll(async () => ctx?.close())Test context API
| Method | Description |
|---|---|
ctx.loadFixtures(dir?) | Load YAML fixture files from test/fixtures/ (or a custom path) |
ctx.loadFeed(name) | Load a feed by name. Returns { generate(feedContext) } |
ctx.loadXrpc(name) | Load an XRPC handler by name. Returns { handler(ctx) } |
ctx.feedContext(opts?) | Create a feed context with limit, cursor, viewer, and params |
ctx.db.query(sql, params?) | Run a SQL query against the in-memory database |
ctx.db.run(sql, ...params) | Execute a SQL statement |
ctx.close() | Shut down the database |
Testing a feed
test('returns statuses in reverse chronological order', async () => {
const feed = ctx.loadFeed('recent')
const result = await feed.generate(ctx.feedContext({ limit: 10 }))
expect(result.items).toHaveLength(6)
})
test('respects limit and cursor', async () => {
const feed = ctx.loadFeed('recent')
const page1 = await feed.generate(ctx.feedContext({ limit: 3 }))
expect(page1.cursor).toBeDefined()
const page2 = await feed.generate(ctx.feedContext({ limit: 3, cursor: page1.cursor }))
expect(page2.items).toHaveLength(3)
})Testing an XRPC handler
test('returns profile for known user', async () => {
const handler = ctx.loadXrpc('xyz.statusphere.getProfile')
const result = await handler.handler({
params: { actor: 'did:plc:alice' },
})
expect(result.handle).toBe('alice.test')
})Fixtures
Fixtures are YAML files in test/fixtures/ that populate the in-memory database before tests run. Each file is named after the table it populates.
Account fixtures
Create a _repos.yaml to register test accounts with handles. This file is loaded first, before any collection fixtures:
# test/fixtures/_repos.yaml
- did: did:plc:alice
handle: alice.test
- did: did:plc:bob
handle: bob.test
- did: did:plc:carol
handle: carol.testIf a DID appears in a collection fixture but not in _repos.yaml, it is auto-registered with a default handle (<did-suffix>.test).
Collection fixtures
A file named after a collection inserts records into that collection. Only did is required — uri and cid are auto-generated if omitted:
# test/fixtures/xyz.statusphere.status.yaml
- did: did:plc:alice
status: "\U0001F680"
createdAt: $now(-5m)
- did: did:plc:bob
status: "\U0001F9D1\u200D\U0001F4BB"
createdAt: $now(-10m)Use 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:
# test/fixtures/app.bsky.actor.profile.yaml
- did: did:plc:alice
rkey: self
displayName: Alice
# test/fixtures/fm.teal.alpha.feed.play.yaml
- did: did:plc:alice
rkey: '1'
trackName: Blinding LightsWithout rkey, URIs are generated using the record's index (at://<did>/<collection>/0, at://<did>/<collection>/1, etc.).
Custom table fixtures
A YAML file whose name doesn't match a known collection creates a custom table with VARCHAR columns derived from the first record's keys:
# test/fixtures/my_lookup_table.yaml
- key: foo
value: bar
- key: baz
value: quxThe $now helper
Use $now in fixture values to generate timestamps relative to the current time:
| Expression | Result |
|---|---|
$now | Current time |
$now(-5m) | 5 minutes ago |
$now(-2h) | 2 hours ago |
$now(-1d) | 1 day ago |
$now(30s) | 30 seconds from now |
This keeps fixtures time-relative so tests for "recent" feeds and time-based sorting always work.
Integration tests
Integration tests use startTestServer() to boot a full HTTP server on a random port:
import { describe, test, expect, beforeAll, afterAll } from 'vitest'
import { startTestServer } from 'hatk/test'
let server: Awaited<ReturnType<typeof startTestServer>>
beforeAll(async () => {
server = await startTestServer()
await server.loadFixtures()
})
afterAll(async () => server?.close())
test('GET /xrpc/dev.hatk.getFeed returns items', async () => {
const res = await server.fetch('/xrpc/dev.hatk.getFeed?feed=recent&limit=5')
const data = await res.json()
expect(data.items.length).toBeGreaterThan(0)
})Test server API
The test server extends the test context with:
| Method | Description |
|---|---|
server.url | The base URL (e.g., http://127.0.0.1:54321) |
server.fetch(path, init?) | Fetch a path on the test server |
server.fetchAs(did, path, init?) | Fetch as an authenticated user (sets x-test-viewer header) |
server.seed(opts?) | Get seed helpers for creating records against a real PDS |