# Test your quests (/docs/build/testing)



Before a quest reaches your members, your developer can check that it decides the way you expect. A good submission earns its reward, a bad one is rejected, and an unclear one goes to your team for review. These checks run on their computer, without your project, a sign-in or any member's data.

* **Catch mistakes early.** A test fails when a quest stops deciding the way the test expects.
* **Rerun a real attempt.** Take an attempt from the test environment and see whether new quest code would decide it differently.
* **Check your app's screens.** Your developer can run your app against made-up members, quests and points, so a screen test shows the same thing every time.
* **Try it by hand instead.** To click through a quest yourself, use [test members](/docs/use/quests#try-quests-with-test-members) in Console. No code needed.
* **Rehearse the whole campaign.** Your developer or agent can play several test members over several days with the CLI; see [test a campaign with test members](/docs/reference/campaign-testing).

The rest of this page is for your developer. `@domino-sdk/relay/testing` runs a quest's evaluator in your test runner the way Relay runs it when a member submits. You choose the evidence, the settings and what each capability answers; the kit returns the decision. No project, sign-in or network is involved.

Every starter with quests includes `tests/quests.test.ts`, which tests them with the kit. Run it with `pnpm test`.

## Write a quest test [#write-a-quest-test]

### 1. Import the quest and the kit [#1-import-the-quest-and-the-kit]

Import the quest's default export and the kit in a test file. Keep tests out of the `quests/` discovery patterns, such as in `tests/`. The kit works in any runner: the starters use Vitest, and this page uses `node:test`.

### 2. Run the quest and assert the decision [#2-run-the-quest-and-assert-the-decision]

`runQuest` evaluates the quest and resolves with its decision. `facts` answers the capabilities the evaluator reads, here an AI photo review:

```ts
import assert from "node:assert/strict"
import { test } from "node:test"
import {
  defineLedger,
  defineQuest,
  evaluators,
  manual,
  once,
  points,
  settings,
} from "@domino-sdk/relay/authoring"
import {
  expectAccept,
  expectReject,
  expectReview,
  runQuest,
} from "@domino-sdk/relay/testing"

const xp = defineLedger({ key: "xp", name: "XP" })
const showYourDesk = defineQuest({
  key: "show-your-desk",
  title: "Show us your desk",
  settings: settings.object({
    humanReview: settings.boolean({ label: "Send unclear photos to staff", default: true }),
  }),
  trigger: manual({ input: "photo" }),
  completion: once(),
  review: ({ settings }) => ({ enabled: settings.humanReview }),
  rewards: [points(xp, 30)],
  evaluate: evaluators.photo(({ settings }) => ({
    criteria: "A desk with a monitor or laptop is visible.",
    unclear: settings.humanReview ? "human-review" : "reject",
  })),
})

test("pays for a photo the review passes", async () => {
  const run = await runQuest(showYourDesk, {
    input: { photo: "evidence_desk" },
    facts: { review: { photo: { kind: "pass", reason: "A desk and a laptop" } } },
  })

  assert.deepEqual(expectAccept(run).rewards, [
    { kind: "points", balance: "xp", amount: 30 },
  ])
})

test("sends unclear photos to staff unless review is off", async () => {
  const input = { photo: "evidence_desk" }
  const facts = { review: { photo: { kind: "unclear" as const, reason: "Too dark" } } }

  expectReview(await runQuest(showYourDesk, { input, facts }), "Too dark")
  expectReject(
    await runQuest(showYourDesk, { input, facts, settings: { humanReview: false } }),
    "Too dark"
  )
})
```

### 3. Run your tests [#3-run-your-tests]

```sh
pnpm test
```

A failed assertion names what the quest decided, such as `Expected the quest to accept, but it rejected: "Too dark"`.

## What runQuest does like Relay [#what-runquest-does-like-relay]

A check is only useful if the kit decides the way Relay would. This table lists what it copies from a hosted evaluation. For the hosted behavior itself, see [Evaluation time and randomness](/docs/reference/quest-rules#evaluation-time-and-randomness).

| Hosted evaluation                                                                                                                   | `runQuest`                                                                                                                         |
| ----------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| The release's setting values, rewards and review switch                                                                             | Computed from the quest with `settings`, as publication does. Omitted settings take their defaults.                                |
| `ctx.now`, `Date.now()`, `new Date()` frozen at the run's start; `performance.now()` returns 0                                      | Frozen at `now`, 2026-01-01T00:00:00Z unless you set it, and restored when the run ends.                                           |
| `Math.random()`, `crypto.getRandomValues()`, `crypto.randomUUID()` seeded by the attempt                                            | Seeded by `seed` with the sandbox's generators, so one seed always draws the same values.                                          |
| `ctx.calendar` counts days in the project's time zone                                                                               | Counts days in `timeZone`, UTC unless you set it, or in the `calendar` time-zone changes you pass.                                 |
| Capability requests and results are validated                                                                                       | The same checks: an invalid request throws `CapabilityError` with code `author-error`, as hosted.                                  |
| `ctx.x` and `ctx.discord` exist only for declared integrations                                                                      | The same. An image check of an X image needs the `x` integration declared.                                                         |
| At most 8 capability calls per evaluation run ([the limit](/docs/reference/verification#evaluator-readers-null-errors-and-pinning)) | The same: the ninth call throws `CapabilityError` with code `capability-limit`, and `limits.capabilityCalls` exports the limit.    |
| Text and URL submissions are checked against the trigger's limits                                                                   | The same: a submission Relay refuses fails the run with a `TestSetupError`.                                                        |
| A review becomes a reject with `requestedReview: true` when human review is off                                                     | The same.                                                                                                                          |
| A custom reward selection can only reduce the published rewards                                                                     | The same: a larger or unpublished reward fails the run with Relay's message, such as `Evaluator exceeded published reward bounds`. |

Because the clock and randomness are process-wide, `runQuest` runs one evaluation at a time, even when your runner runs tests concurrently, and fails an evaluation that does not finish within 15 seconds, as Relay does. Code running beside it in the same process sees the frozen clock too.

`runQuest` also refuses contexts Relay never builds: `input` for a quest that takes no evidence, and `eligibleAt` for a tracker-triggered quest, whose attempt is eligible when its record occurred.

> [!NOTE] What the kit does not run
> `runQuest` evaluates one attempt. It does not check prerequisites, availability windows, budgets or completion limits, and it does not record completions or deliver rewards. It does not know which chains and tokens Relay reads balances on, and module-scope code in your quest file runs at import, with the real clock. Test those with test members in the [test environment](/docs/use/quests#try-quests-with-test-members).

## Run options [#run-options]

| Option        | Sets                                                                                                                 |
| ------------- | -------------------------------------------------------------------------------------------------------------------- |
| `settings`    | Setting values as an operator enters them in Console                                                                 |
| `input`       | The submitted evidence: `{ value }` for text and URL quests, `{ photo }` (an evidence ID), `{ answers }` for quizzes |
| `tracker`     | The pinned observation of a tracker-triggered quest; build it with `trackerObservation`                              |
| `performance` | A content quest's performance stage: `{ metrics, observedAt?, rewards? }`                                            |
| `facts`       | Fake capability answers (below)                                                                                      |
| `member`      | The member ID; defaults to the observation's member, else `member_test`                                              |
| `now`         | The run's start time, `ctx.now`                                                                                      |
| `eligibleAt`  | When a manual quest's attempt was admitted; defaults to `now`. A tracker quest takes the observation's `occurredAt`  |
| `timeZone`    | The project time zone `ctx.calendar` uses                                                                            |
| `calendar`    | Project time-zone changes as `{ effectiveAt, timeZone }`, oldest first                                               |
| `seed`        | The randomness seed; a hosted run uses the attempt ID                                                                |

`runQuest` resolves with `{ decision, capabilityCalls, calls, release }`. `capabilityCalls` lists every capability call with its sequence number, request and outcome, in order: `{ sequence, request, outcome }`, where `request.kind` names the reader, such as `"wallet-events"` or `"telegram"`, and the rest is the request as Relay received it. A later release can add kinds, so compare a request whole or read the fields of the kinds you expect. `calls` is deprecated: it leaves out Telegram, NFT, contract call, delegation, wallet event and transaction reads, and the member's state. `release` holds the rewards and review switch the run used, so a test can check what an accepted attempt grants beyond points, such as the group membership reward in the loyalty starter's tests.

## Fake capability answers [#fake-capability-answers]

`facts` is keyed like the evaluation context. Each answer is a value, or a function of the call's arguments:

```ts
import { CapabilityError, type TestFacts } from "@domino-sdk/relay/testing"

export const facts: TestFacts = {
  x: {
    connection: { userId: "7" },
    follows: ({ userId }) => userId === "42",
    post: () => {
      throw new CapabilityError("provider-unavailable", "X is unavailable")
    },
  },
  review: { text: { kind: "pass", reason: "Mentions the launch" } },
}
```

| Reader                                                                                           | Without an answer                                                                        |
| ------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------- |
| `member.identities`, `wallet.list`, `discord.member`                                             | `null`: the member has linked nothing                                                    |
| `x.connection`                                                                                   | `null`: the member has not linked X                                                      |
| `x.profile`, `x.follows`, `x.reposted`                                                           | `null` while `x.connection` is unset; required once you link an X account                |
| `wallet.balance`                                                                                 | `null` while `wallet.list` is unset; required once you link a wallet on the read's chain |
| `member.balances`, `member.completions`, `member.groups`, `member.campaigns`, `member.referrals` | Required                                                                                 |
| `x.post`, `discord.message`, `trackers`, `images`, `review`                                      | Required                                                                                 |

The linked accounts are pinned for the run, as in a hosted run ([Evaluator readers: null, errors and pinning](/docs/reference/verification#evaluator-readers-null-errors-and-pinning) has the hosted rules):

* **`member.identities`, `wallet.list` and `x.connection` answer once per run**, even when you answer with a function.

* **Reads of the member's own accounts follow those pins.** An answer for `x.follows` without `x.connection`, or for `wallet.balance` without `wallet.list`, fails the run: the member it describes has nothing linked. A balance read of an `address` the member never linked throws `CapabilityError` with code `author-error`, and an image check of `{ x: "profile" }` without a linked X account throws one with code `not-linked`.

* **`member.balances` and `member.completions` hold the member's whole state, answered once per run.** Give `member.balances` the member's balance in each ledger, as `relay.me.balances()` lists them, and `member.completions` every completion as `{ quest, key, completedAt }` with the quest's key. `ctx.member.balance(ledger)` picks the ledger by key or ID and throws `CapabilityError` with code `author-error` for one you did not list. `ctx.member.completions()` filters by `quest` and `since`, puts the newest first and applies `limit`, as Relay does.

* **`member.groups`, `member.campaigns` and `member.referrals` are answered once per run the same way.** Give `member.groups` the keys of the member's unarchived groups; `ctx.member.groups()` returns them in byte order. Give `member.campaigns` each campaign the project knows as `{ key, enrolled }`; `ctx.member.enrolled(campaign)` returns its `enrolled` and throws `CapabilityError` with code `author-error` for a campaign you did not list. Give `member.referrals` the member's progress in each program, as `relay.profiles.referral()` returns it; `ctx.member.referrals(program)` picks the program by key or ID and throws `author-error` for one you did not list.

* **A call with no answer fails the run** with a `TestSetupError` naming the fact to set, even if quest code catches errors.

* **Answers are checked** against the result shapes Relay delivers, and quest code reads them as JSON. Relay never returns an empty wallet list, for example, so `wallet: { list: [] }` fails; use `null`.

* **Throw a `CapabilityError` to fail a call**, as a provider outage does. If quest code lets it escape, `runQuest` rejects with it, where Relay would retry a retryable one later.

* **Anything else quest code throws rejects with an `EvaluatorError`**: code `evaluator-error`, the thrown error's name and message, such as `TypeError: Cannot read properties of undefined`, and what quest code threw as `cause`. Relay fails the attempt once with the same code and message, without retrying. A failure you received and rethrow, or wrap with `{ cause: error }`, still rejects as itself; `throw new Error(error.message)` does not. See [quest code that fails on its own](/docs/reference/verification#evaluator-readers-null-errors-and-pinning).

* **Functions receive the request as Relay does**: a wallet balance query's chain is canonical (`"solana"` arrives as its CAIP-2 ID), and a tracker read has its defaults and `asOf` filled.

## Tracker-triggered quests [#tracker-triggered-quests]

Pass the observation that admitted the attempt. `trackerObservation` types `data` from the tracker, and `trackerPage` builds an answer for `ctx.trackers.records`:

```ts
import { test } from "node:test"
import {
  automatic,
  customSource,
  decision,
  defineLedger,
  defineQuest,
  defineTracker,
  points,
  settings,
  z,
} from "@domino-sdk/relay/authoring"
import {
  expectAccept,
  runQuest,
  trackerObservation,
  trackerPage,
} from "@domino-sdk/relay/testing"

const deposits = defineTracker({
  key: "deposits",
  title: "Deposits",
  source: customSource("bank", {
    identities: ["member"],
    data: z.object({ amount: z.number() }),
  }),
  window: { start: Date.parse("2026-01-01T00:00:00Z") },
})
const saver = defineQuest({
  key: "saver",
  title: "Save 100",
  settings: settings.object({}),
  trigger: automatic({ tracker: deposits }),
  completion: ({ keyed }) => keyed((ctx) => ctx.tracker.record.id),
  rewards: [points(defineLedger({ key: "xp", name: "XP" }), 10)],
  async evaluate(ctx) {
    const earlier = await ctx.trackers.records(deposits, { member: ctx.member.id })
    const total = earlier.items.reduce((sum, item) => sum + item.data.amount, 0)

    return total + ctx.tracker.record.data.amount >= 100
      ? decision.accept()
      : decision.reject({ reason: `Saved ${total} so far` })
  },
})

test("pays once the deposits reach 100", async () => {
  const run = await runQuest(saver, {
    tracker: trackerObservation(deposits, { data: { amount: 60 }, member: "member_1" }),
    facts: {
      trackers: { deposits: { records: trackerPage(deposits, [{ data: { amount: 40 } }]) } },
    },
  })

  expectAccept(run)
})
```

A fixed `trackerPage` answers every query. To check the query the evaluator sends, answer with a function and inspect its argument.

## Assert decisions [#assert-decisions]

| Helper                         | Passes when the run                       | Returns                                          |
| ------------------------------ | ----------------------------------------- | ------------------------------------------------ |
| `expectAccept(run)`            | Accepted                                  | The decision, with `rewards` and `completionKey` |
| `expectReject(run, reason?)`   | Rejected, with a matching reason if given | The decision                                     |
| `expectReview(run, reason?)`   | Went to human review                      | The decision                                     |
| `expectDefer(run, reason?)`    | Deferred                                  | The decision                                     |
| `expectProgress(run, reason?)` | Recorded progress                         | The decision                                     |

`reason` is an exact string or a regular expression. The helpers throw a `DecisionAssertionError`, which every test runner reports as a failure.

A reject's [code](/docs/reference/quest-rules#reject-with-a-code) is on the returned decision: `assert.equal(expectReject(run).code, "wrong-answers")`. A code Relay would refuse, such as one with uppercase letters or one Domino reserves, rejects the run with an error naming the rule, so a test catches it before a deployment.

## Read diagnostics [#read-diagnostics]

`run.diagnostics` lists what the evaluation did that had no effect, without failing the run. Each entry has a `code` and a `message`:

| Code                     | What happened                                                                                                                                                                                           |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `completion-key-ignored` | The evaluator returned a `completionKey` that the quest's completion policy ignores. Only `keyed(...)` uses it; `once()`, `streak(...)`, `progress(...)` and content quests key completions themselves. |

To fail a test on any diagnostic, assert `run.diagnostics` is empty. Relay does not report diagnostics for hosted attempts, so tests are where they show up.

## Replay a captured attempt [#replay-a-captured-attempt]

Relay captures what each evaluation run read: the release's settings and rewards, the attempt's input, frozen clock, seed and calendar, and every capability answer. `replayAttempt` runs your current quest code against such a capture, so you can check whether a change alters an outcome a member already got.

> [!IMPORTANT] Test environment only
> Captures are served from the test environment only, including a staging project's test data. A live attempt holds a real member's data: the API refuses it with `422` and code `test-environment-required`, and the CLI refuses `--environment live` before sending a request.

### 1. Fetch the capture [#1-fetch-the-capture]

```sh
pnpm exec domino quests replay ATTEMPT_ID --environment test --out captures/ATTEMPT_ID.json --json
```

The command fetches the attempt's latest evaluation run, finds the quest among your discovered quests by its key, replays it and prints the result. `--out` also saves the capture for a test, in a file only you can read (mode 0600). Pass `--quest <file>` for a quest created in Console or kept outside discovery, and `--capture <file>` to replay a saved capture without a request. From code, `management.attempts.capture(attempt)` returns the same capture. Captures need `publish` permission, because they hold the providers' raw answers, such as X profiles and Discord messages. An attempt that has not been evaluated yet fails with `409` and code `attempt-not-evaluated`.

### 2. Replay it in a test [#2-replay-it-in-a-test]

```ts
import assert from "node:assert/strict"
import { readFile } from "node:fs/promises"
import { test } from "node:test"
import { defineQuest, decision, manual, once, settings } from "@domino-sdk/relay/authoring"
import { replayAttempt } from "@domino-sdk/relay/testing"

const welcome = defineQuest({
  key: "welcome",
  title: "Welcome",
  settings: settings.object({}),
  trigger: manual(),
  completion: once(),
  rewards: [],
  evaluate: async () => decision.accept(),
})

test("the welcome rewrite keeps the decision of attempt_123", async () => {
  const capture = JSON.parse(await readFile("captures/attempt_123.json", "utf8"))
  const replay = await replayAttempt(welcome, capture)

  assert.equal(replay.changed, false)
})
```

`replayAttempt` resolves with:

| Field                  | Holds                                                                                                                                                                   |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `decision`             | What the current code decides; `null` when the replay stopped at a divergence                                                                                           |
| `recorded`             | What quest code decided in the captured run, before Relay acted on it; `null` before it decided, and for runs decided before Relay kept decisions                       |
| `changed`              | Whether the decision differs from `recorded`: kind, reason, rewards or completion key; `null` when either is missing                                                    |
| `capabilityDivergence` | The first call the capture could not answer, of any reader: its `sequence`, `reason`, the `recorded` request and the `replayed` one; `null` when it answered every call |
| `capabilityCalls`      | Every capability call of the replay, with its answer, as in `runQuest`                                                                                                  |
| `divergence`, `calls`  | Deprecated: the same, for the readers 1.0 had only. `divergence` is `null` when the first divergence is a call of a newer reader                                        |

A capture answers a call by its position, as a retry of the run does. A call is a divergence when the captured run made no such call (`new`), sent another request (`changed`), or got a retryable failure Relay did not keep (`uncaptured`). The replay stops there unless you pass `facts`, which answer those calls; `capabilityDivergence` still names the first one. A capture of another quest key fails with `TestSetupError`.

The capture's answers are checked as Relay delivered them, but a field or a value a later Relay added to a result passes as captured, so a capture keeps replaying after Relay's results grow. The answers you pass in `facts` are checked strictly, as in `runQuest`.

## Call evaluate yourself [#call-evaluate-yourself]

`createTestContext(quest, options)` builds the host context `quest.evaluate` takes, with the same fake readers, without freezing the clock or checking rewards. Use it to test helpers that take readers, or to drive `quest.evaluate` step by step.

A test that assembles its own context needs a `ctx.member`. Build it with `fakeEvaluationMember`, passing the methods your code reads:

```ts
import { fakeEvaluationMember } from "@domino-sdk/relay/testing"

const member = fakeEvaluationMember({
  id: "member_1",
  groups: async () => ["vip"],
})
```

The methods you leave out answer for a member with no linked accounts, completions, groups or enrollments; `balance` and `referrals` fail with a `CapabilityError` of code `author-error`, as Relay does for an unknown ledger or program. A minor release can give `EvaluationMember` a method, which this helper answers too, so a member written by hand can stop compiling while one from `fakeEvaluationMember` does not.

For `ctx.wallet` and `ctx.telegram`, `fakeWalletReader(facts)` and `fakeTelegramReader(facts)` build readers with every method Relay supplies, answered from `facts.wallet` and `facts.telegram` as `runQuest` answers them:

```ts
import {
  fakeEvaluationMember,
  fakeTelegramReader,
  fakeWalletReader,
} from "@domino-sdk/relay/testing"

// Readers for a context you pass to quest.evaluate yourself.
export const readers = {
  member: fakeEvaluationMember(),
  wallet: fakeWalletReader(),
  telegram: fakeTelegramReader({ telegram: { connection: { userId: "42" } } }),
}
```

> [!NOTE] Readers added after 1.0 are optional in their types
> `ctx.telegram`, `ctx.telegram.connection` and the wallet's `nfts`, `call`, `delegations`, `events` and `transactions` are optional in the reader types only so a reader written by hand before them keeps compiling. Relay and `runQuest` supply them to every evaluation, so quest code calls them with a non-null assertion, as the snippets do: `await ctx.wallet.nfts!({ collection })`, `await ctx.telegram!.connection!()`. In a context you build, use the fakes above, which have every method.

> [!WARNING] Quest code cannot import the testing kit
> Building a quest that imports `@domino-sdk/relay/testing` fails with `Quest code cannot import @domino-sdk/relay/testing`. Import the kit only from test files.

## Test your app's UI [#test-your-apps-ui]

`createFakeRelay` answers the participant API from data you give it, so a UI test of an app built on `@domino-sdk/relay/react` sees the same member, quests and points on every run, without a project or a sign-in.

> [!NOTE] Experimental
> `createFakeRelay` and its `FakeRelay` types are experimental: a minor release can change their options, the routes the fake answers and its answers. Pin the SDK version your UI tests run against.

### 1. Seed the fake [#1-seed-the-fake]

```ts
import { createFakeRelay } from "@domino-sdk/relay/testing"

const xp = { id: "ledger_xp", key: "xp", name: "XP" }
const relay = createFakeRelay({
  member: "member_ada",
  balances: [{ ledger: xp, balance: 120, available: 120 }],
})
```

| Option                                  | Answers                                                                                                               |
| --------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `member`                                | `GET /v1/auth/session`: a session for this member ID, or `null` (the default) for a signed-out visitor                |
| `scope`                                 | The session's `organization`, `project` and `environment`; defaults to `org_test`, `project_test` and `test`          |
| `quests`, `collections`, `entitlements` | `GET /v1/me/experience`, as the client's `quests.experience()` reads it                                               |
| `balances`                              | `GET /v1/me/points`                                                                                                   |
| `ledgers`                               | `GET /v1/point-ledgers`; defaults to the ledgers in `balances`                                                        |
| `pointHistory`                          | `GET /v1/me/points/:ledger/history`, keyed by ledger key or ID, in one page                                           |
| `activity`                              | `GET /v1/me/activity`, oldest event first, paged by `cursor` and `limit`                                              |
| `leaderboards`                          | `GET /v1/leaderboards/:board`, keyed by board key or ID                                                               |
| `badges`                                | `GET /v1/badges/catalog` as `{ items }` and `GET /v1/badges` as an array, signed in or out                            |
| `routes`                                | Any other route, or a replacement for one above, keyed `"METHOD /v1/path"`; a `:name` segment matches any one segment |

An omitted seed answers empty: no quests, no balances, no events. The fake also answers `POST /v1/auth/logout`, which signs the member out, and `POST /v1/auth/exchange` with a server integration key (`di_...`) as the bearer token, which signs in the seeded member, or a member whose ID is the `subject` when none is seeded.

* **Signed out, member reads answer `401`** with code `not-signed-in`, as Relay does, and the session reads `null`.
* **Every answer is checked** against the shape the SDK reads, so a seed Relay could never send, such as a balance of `1.5`, throws a `TestSetupError` naming the route when you create the fake.
* **A participant route the fake does not answer fails loudly**: `501` with code `not-faked` and the method and path in the message, so a test cannot pass on an empty answer. Answer it with `routes`, as for a submission. A path that is not a participant route answers `404` with code `route-not-found`, as the [participant proxy](/docs/build/hosted-app) does.
* **`relay.requests`** lists the method and path of every request the fake answered, in order, so a test can check what the app read.

Paths answer the same with or without the `/relay` prefix that `RelayProvider` uses by default.

### 2. Route the app's requests to it [#2-route-the-apps-requests-to-it]

`relay.handle(request)` takes a `Request` and resolves with a `Response`. In Playwright, route the app's `/relay/v1/` requests to it:

```ts
import { expect, test } from "@playwright/test"
import { createFakeRelay } from "@domino-sdk/relay/testing"

test("shows the member's points", async ({ page }) => {
  const relay = createFakeRelay({
    member: "member_ada",
    balances: [
      { ledger: { id: "ledger_xp", key: "xp", name: "XP" }, balance: 120, available: 120 },
    ],
  })

  await page.route("**/relay/v1/**", async (route) => {
    const request = route.request()
    const response = await relay.handle(
      new Request(request.url(), {
        method: request.method(),
        headers: request.headers(),
        body: request.postData() ?? undefined,
      })
    )

    await route.fulfill({
      status: response.status,
      headers: Object.fromEntries(response.headers),
      body: Buffer.from(await response.arrayBuffer()),
    })
  })

  await page.goto("/")
  await expect(page.getByText("120 XP")).toBeVisible()
})
```

If your app uses its own login, run your server's `createParticipantBackend` handler against `relay.fetch` instead, at a local origin such as `http://127.0.0.1`: see [test the login in a browser](/docs/build/app-login#test-the-login-in-a-browser).

In a component test with Vitest or `node:test`, pass `relay.fetch` to the client instead: `<RelayProvider client={createRelayClient({ baseUrl: "https://app.test", fetch: relay.fetch })}>`. With MSW, return `relay.handle(request)` from a handler.

### What RelayProvider reads [#what-relayprovider-reads]

`RelayProvider` reads nothing until it mounts. Then:

| When                       | Requests                                                                                                                                                                          |
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| On mount                   | `GET /v1/auth/session`, twice: once directly and once before the experience                                                                                                       |
| On mount, signed in        | `GET /v1/me/experience`, and `GET /v1/me/activity?start=latest` to find the newest event                                                                                          |
| Every 5 seconds, signed in | `GET /v1/me/activity?cursor=...` for events after the last one (`polling.activityMs`)                                                                                             |
| Every 30 seconds           | `GET /v1/me/experience` when signed in, `GET /v1/auth/session` when signed out (`polling.snapshotMs`). A member read refused with `not-signed-in` reads the session again at once |
| On each activity event     | The reads of the hooks the event changes, such as `GET /v1/me/points` after `points.awarded`                                                                                      |
| On window focus            | The activity feed again, or the session for a signed-out visitor, unless either was read in the last 2 seconds; queries with `refetchOnFocus` read again too                      |

Each hook adds its own read when a component first uses it, such as `useBalances` (`GET /v1/me/points`), `useActivity` (`GET /v1/me/activity`) and `useLeaderboard` (`GET /v1/leaderboards/:board`). Reads pause while the tab is hidden.

`useActivity` reads the feed from its first event, so seeded `activity` reaches its `data.items` and `onEvent`. The provider's own feed starts at the newest event, so seeded events do not make it read hooks again. To test how the app reacts to a new event, answer `GET /v1/me/activity` with `routes`, returning the event once the cursor the first answer gave comes back.

## Where next [#where-next]

- [Your first quest](/docs/build/first-quest) — Write and publish the quest you are testing.
- [Quests](/docs/concepts/quests) — How Relay runs evaluations, retries and replays.
- [Authoring](/docs/reference/authoring) — Evaluator readers, errors and their pinning.
