---
title: "Test a loader"
audience: "anyone writing tests for loaders and actions"
description: "You'll test a loader with fsr test. The test runs your loader through the same interpreter that serves requests, with its services stubbed, so nothing else…"
snippet_caption: "app/tests/product/loader.test.ts"
snippet: "import { load } from \"@routes/product/[id]/page.loader\";\nimport { assert, ctx, test } from \"@snapfire/fsr/testing\";\n\nconst coat = { id: \"1\", name: \"Chore coat\", blurb: \"Waxed cotton, four pockets.\", price_cents: 14800n };\n\ntest(\"the loader hands the page the product the id names\", async () => {\n  const c = ctx<void, \"/product/{id}\">({\n    params: { id: \"1\" },\n    services: { shop: { getProduct: () => coat } },\n  });\n  const { product } = await load(c);\n  assert.equal(product.name, \"Chore coat\");\n  assert.equal(product.price_cents, 14800n);\n});"
---

import { Install } from "@src/ui/Install";

You'll test a loader with `fsr test`. The test runs your loader through the same interpreter that serves requests, with its services stubbed, so nothing else has to be running.

<Install captured="0.x" />

## Write the test

Tests live under `app/tests/`, mirroring `routes/`. Save this as `app/tests/product/loader.test.ts`:

```ts title="app/tests/product/loader.test.ts"
import { load } from "@routes/product/[id]/page.loader";
import { assert, ctx, test } from "@snapfire/fsr/testing";

const coat = { id: "1", name: "Chore coat", blurb: "Waxed cotton, four pockets.", price_cents: 14800n };

test("the loader hands the page the product the id names", async () => {
  const c = ctx<void, "/product/{id}">({
    params: { id: "1" },
    services: { shop: { getProduct: () => coat } },
  });
  const { product } = await load(c);
  assert.equal(product.name, "Chore coat");
  assert.equal(product.price_cents, 14800n);
});
```

`ctx()` builds what the request would have carried: `params`, `session`, `services`, `input`, `query`, `identity`, `locale`. The route in the type argument is what makes `params` type-check, so a typo in `id` is a build error.

A mocked service method is a function of its arguments. A plain value works when the arguments don't matter.

## Run it

```shell-session
$ fsr test shop/app
test tests/product/loader.test.ts: the loader hands the page the product the id names ... ok

test result: ok. 1 passed; 0 failed
```

It lowers the test, lowers the loader the way a build does and replays it.

## Mocks and the contract

Your mocks sit behind the same contract the real service does. A mock that returns a shape the API never could fails the test.

Drop a required field and see:

```ts title="app/tests/product/loader.test.ts"
services: { shop: { getProduct: () => ({ id: "1", name: "Chore coat" }) } },
```

```shell-session
$ fsr test shop/app
test tests/product/loader.test.ts: a mock that skips a field the contract requires fails ... FAILED
  line 9: `load` failed: internal: shop.getProduct(): missing field `blurb`

test result: FAILED. 0 passed; 1 failed
```

It names the method and the field. It fails before your assertion is reached. The mock has to match the contract, so a test that passes ran the loader against the shape production returns.

The same check catches a mock for a method the contract doesn't declare and a call to a method you forgot to mock. The second is easy to miss: without the check the loader fails, the route falls through to your error component, then a page test passes against the wrong page.

## What a test file can contain

The runner accepts a small dialect and refuses anything else with the line number, so a test never quietly does less than it appears to. It accepts imports, `const` fixtures shared across tests and `test` blocks. Inside a block you build a context, run a loader or an action, then assert.

That restriction is the same one your loaders live under, for the same reason: the runner has to execute it in Rust.

## What to test

Test the loader and the action, which is where the logic is. The page is a function of its props, so there's little to test there. The build already refuses a page that destructures a field the loader doesn't return.

Next up: [410. Ship it](https://snapfirers.com/fsr/learn/410-ship-it).
