Test a loader without a browser
You'll test a loader with no browser, no backend, no Node and no test framework to install. The test runs your loader through the same interpreter that serves requests, so it runs where your code runs.
Before you start
Straight from crates.io. No Node, no package manager.
cargo install snapfire_compiler
cargo install snapfire_fsr_cli
fsr --version
Every command and screenshot on this page was captured with fsr 0.x.
Write the test
Tests live under app/tests/, mirroring routes/. Save this as 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
$ 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
No watcher to configure, no transform, no jsdom. It lowers the test, lowers the loader the way a build does and replays it.
Your mocks can't lie
Your mocks sit behind the same contract the real service does. A mock returning a shape the API never could is a failure, not a passing test.
Drop a required field and see:
services: { shop: { getProduct: () => ({ id: "1", name: "Chore coat" }) } },$ 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. Compare that with a hand-rolled mock in most suites, which happily returns whatever object you typed and lets the test pass while production breaks.
The same check catches a mock for a method the contract doesn't declare. So is a call to a method you forgot to mock. That last one is easy to miss. Without it 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. Imports, const fixtures shared across tests, 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 less to test there than you're used to. The build already refuses a page that destructures a field the loader doesn't return.
No test runner to install
No test runner dependency, no transform, no jsdom, no mock library, no separate tsconfig. CI runs the same binary that builds the app.
Next up: 110. Ship it.