~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/hatk

AT Protocol app framework

1
0
main
196 lines · 6.5 KB Raw History
---
title: Testing
description: Run tests and check code quality.
---

## `hatk test`

Run your project's test suite.

```bash
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.

```typescript
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

```typescript
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

```typescript
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:

```yaml
# 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:

```yaml
# 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:

```yaml
# 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:

```yaml
# 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:

```typescript
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     |