AppView in a box as a Vite plugin thing hatk.dev

title: Testing description: Run tests and check code quality.#

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.test

If 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 Lights

Without 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: qux

The $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