---
title: "Test your code"
description: "Run your code against an in-memory forge or recorded responses."
canonical_url: "https://forges.link/guides/testing"
---
# Test your code

> Run your code against an in-memory forge or recorded responses.

Code that talks to forges is awkward to test. Real forges are slow, they rate-limit you, and you probably don't want a test to close a real issue. To help with this, `forges` ships two test helpers. `forges/fake` is an in-memory forge that you can run your code against, and `forges/testing` replays HTTP responses that were recorded earlier. Both are meant for tests only, so keep them out of your production bundles.

## Use the in-memory forge

`fake()` gives you a forge that lives entirely in memory and has the full provider API. You seed it with the data your code needs, run your code and then check the result in `store`. Every write updates the `store` and adds a `ForgeEvent` to `store.events`, so you can assert on what your code did:

```ts
import { fake } from 'forges/fake'
import { expect, it } from 'vitest'

it('closes stale issues', async () => {
  const forge = fake({
    seed: {
      threads: [
        {
          repo: 'acme/widgets',
          kind: 'issue',
          title: 'Crash on start',
          labels: ['stale'],
        },
      ],
    },
  })

  await closeStale(forge.create())

  expect(forge.store.events.map(event => event.action)).toEqual([
    'closed',
  ])
})
```

By default, the in-memory forge supports almost every operation, which can hide problems that only appear on a real forge. To make it behave like one, pass `capabilities` with the capabilities of a real provider. You can also use `support` to override single verbs, for example `{ 'threads.addLabels': false }`. Either way, the fallback paths in your code run in your tests as well.

Every read and write returns a copy, so changing a result in your test leaves the forge as it was. The `store` itself stays yours to change, which is how you set up a state that no seed covers, such as a pull request whose head moved.

`sources.subscribe()` yields each event as `store.events` holds it, with `source: 'subscribe'`. `threads.events()`, `repos.events()` and `users.events()` read the same events with `source: 'poll'` and an `id` of their own listing, so code that merges sources has to match events by their `detail`, as it does with a real forge. When `notifications.markDone` is `'emulated'`, for example with the capabilities of a Forgejo provider, `markDone()` marks the notification read and keeps it.

Every write of a provider from `create()` is by the viewer. To make a change as someone else, use the provider that `as(login)` returns. Its writes notify the viewer the way a forge does: a mention of the viewer, an assignment or review request for the viewer, or any comment, review or state change on a thread that the viewer is subscribed to adds a notification for the thread, or marks the existing one unread again:

```ts
const factory = fake({
  seed: { threads: [{ repo: 'acme/widgets', kind: 'issue', title: 'Crash on start' }] },
})
const repo = { forge: 'fake', instance: 'fake.test', owner: 'acme', name: 'widgets' }
const issue = { forge: 'fake', instance: 'fake.test', repo, kind: 'issue', number: '1' } as const

await factory.as('octocat').threads.comment(issue, 'Any news?')

const { items } = await factory.create().notifications.listPage()
// [{ reason: 'author', unread: true, ... }]
```

## Test an offline queue

If your app applies a change locally before it sends it, you need to test what happens when the send fails. The in-memory forge can fail on purpose, so you can run each path of your queue without a network:

- `failNext(verb, error)` rejects the next call of `verb` with `error`. Pass `{ times: 3 }` to fail several calls in a row.
- `failNext(verb, error, { applied: true })` makes the write first and then rejects. This is what a timeout looks like when the forge received the request but the response never arrived, and it's the case where a retry can post twice.
- `offline()` rejects every call with `ForgeNetworkError` and closes open subscriptions, until you call `online()`.
- `latency` delays each request. A request that takes longer than `timeout` rejects with `ForgeTimeoutError`, and the `signal` of a call aborts the wait.
- `now` sets the clock behind every timestamp, so the order of events and `since` filters don't depend on when the test runs.
- `rateLimit` gives the forge a request budget. Each page reports it in `page.rateLimit`, and once it runs out, calls reject with `RateLimitedError` until the window resets on the clock. `provider.rateLimits()` reports the budget as the latest request left it, until the window ends on the clock, as it does on a real forge. `setRateLimit()` replaces the budget during a test.
- A secondary limit rejects a request while the budget still has requests left. To test one, fail the verb with a `RateLimitedError` that has `secondary: true`, and leave out `resetAt` for a limit that names no wait:

```ts
forge.failNext(
  'threads.comment',
  new RateLimitedError('Secondary rate limit', 403, '', { secondary: true }),
)
```

The following test sends a comment through `upsertComment()` with the id of the queued change as its key. The first send times out after the forge stored the comment, and the retry finds that comment by its key instead of posting a second one:

```ts
import { ForgeTimeoutError } from 'forges'
import { fake } from 'forges/fake'
import { expect, it } from 'vitest'

it('posts a queued comment once', async () => {
  const forge = fake({
    seed: {
      threads: [
        { repo: 'acme/widgets', kind: 'issue', title: 'Crash on start' },
      ],
    },
  })
  const provider = forge.create()
  const repo = { forge: 'fake', instance: 'fake.test', owner: 'acme', name: 'widgets' }
  const issue = { forge: 'fake', instance: 'fake.test', repo, kind: 'issue', number: '1' } as const
  const change = { key: 'change-1', body: 'Thanks for the report' }

  forge.failNext(
    'threads.comment',
    new ForgeTimeoutError('Request timed out', 30_000),
    { applied: true },
  )
  const first = provider.threads.upsertComment(issue, change)
  await expect(first).rejects.toThrow(ForgeTimeoutError)
  await provider.threads.upsertComment(issue, change)

  const widgets = forge.store.repos.get('acme/widgets')!
  expect(widgets.threads.get('1')!.comments).toHaveLength(1)
})
```

To make a pull request fail to merge, seed it with `mergeable: 'blocked'` or `mergeable: 'conflict'`, or set `mergeable` in the `store` during the test. A merge with a `sha` that no longer matches the head of the pull request rejects with `MergeConflictError`, as it does on a real forge. A merge that succeeds resolves with a made-up merge commit `sha`, which a following read reports as `branches.mergeCommitSha`. A merge with `whenChecksPass` resolves with `status: 'queued'` and leaves the pull request open.

A write that the forge has already applied, such as a second `close()` or `react()`, leaves the same state and records no new event. A second open pull request from the same `head` to the same `base` rejects with a `ForgeApiError` with `status` 422.

The listings that real forges serve with an ETag, such as `notifications.listPage()` and `threads.commentsPage()`, return `page.etag` on their first page. If you pass it back as `cursor: { etag }` and the page hasn't changed, you get an empty page with `notModified: true`, which doesn't spend the request budget.

## Replay recorded responses

The in-memory forge doesn't exercise the parsing of a real provider. To test that part too, use `fixtureFetch()`. It serves recorded responses to a real provider, so the provider's own code runs in your test, and only the network is replaced. For webhooks, `signDelivery()` signs a body with the signature scheme of the forge, which lets you send a realistic delivery through `ingest()`:

```ts
import { readFile } from 'node:fs/promises'
import { github } from 'forges/github'
import {
  fixtureFetch,
  loadFixtures,
  signDelivery,
} from 'forges/testing'

const { fetch, calls } = fixtureFetch(
  await loadFixtures(new URL('./fixtures/', import.meta.url)),
)
const forge = github({ fetch, webhookSecret: 'secret' }).create()

const body = await readFile(
  new URL('./fixtures/issue-opened.json', import.meta.url),
  'utf8',
)
const headers = await signDelivery('github', body, 'secret', {
  'x-github-event': 'issues',
})
const events = await forge.webhooks.ingest({ headers, body })
```

`calls` records every request that the provider made, including the ones that no fixture matched. This makes it useful for proving that something didn't happen, such as a capability check preventing a write.

## Record your own fixtures

To create fixtures for your own code, wrap `fetch` with `recordingFetch()`. It collects each response as a fixture that you can save and replay later. It doesn't record request headers, but it does record URLs and response bodies, and those can contain tokens. Before you save a fixture, pass a `redact` function to clean it.


## Sitemap

See the full [sitemap](https://forges.link/sitemap.md) for all pages.
