Chapter 13
Testing
Remix includes remix/test, whose three runner types (server, browser, and e2e) determine how and where a test runs. They do not determine how much application code the test exercises. remix/assert provides assertions in all three.
A Remix app already exposes its main test boundary in app/router.ts. The router accepts a Web Request and returns a Web Response, so most request behavior can be tested without starting a server or opening a browser.
This chapter starts at that boundary, then moves outward to browser component tests and full end-to-end flows.
Choose the narrowest useful test
Choose the smallest boundary that includes the behavior you want to prove:
| Test boundary | Use it for | How to test it | Runner type |
|---|---|---|---|
| Unit test | A data helper, schema, utility, or other isolated module | Import it, call it, and assert on the result | server |
| Router test | An action, response, middleware, session, or database-backed request | Send a request through router.fetch(...) |
server |
| Browser component test | A component event, DOM update, or browser API | Render the component with render() from remix/component/test |
browser |
| End-to-end test | Navigation or a complete browser/server flow | Run the router behind a test server and drive it with Playwright | e2e |
A controller that returns the wrong status belongs in a router test. A submit button that does not enter its pending state belongs in a browser component test. Use an end-to-end test when browser and server behavior must work together, such as submitting a form and following its redirect to the updated page.
Run tests with remix test
Add a test script to package.json:
{
"scripts": {
"test": "remix test"
}
}Then run the suite with:
npm testBy default, the runner maps each runner type to a file pattern and execution model:
| Runner type | File pattern | Execution model |
|---|---|---|
server |
**/*.test.{ts,tsx} |
Server-side test worker |
browser |
**/*.test.browser.{ts,tsx} |
Isolated browser frame via Playwright |
e2e |
**/*.test.e2e.{ts,tsx} |
Test worker driving a Playwright browser |
The server runner type covers both unit tests and router tests. It describes the execution model, not the test boundary.
All three use the same describe(...) and it(...) API from remix/test, which also provides lifecycle hooks, test context, mocks, fake timers, and runner configuration. Assertion functions and expect(...) matchers come from remix/assert. Both work in every test environment. Tests written for Node's built-in runner port with few changes. The describe, it, hook, and t.mock APIs follow the shape of node:test, and remix/assert is a compatible subset of node:assert/strict.
Each it(...) callback receives a test context, usually named t, as its first argument. It holds per-test helpers such as t.mock for spies, t.after(...) for cleanup, and t.serve(...) for end-to-end tests, and it undoes their effects when the test finishes. A test that doesn't need them can omit the argument:
import * as assert from "remix/assert";
import { describe, it } from "remix/test";
import { getAlbum } from "./data.ts";
describe("getAlbum", () => {
it("finds an album by id", async () => {
let album = await getAlbum("thriller");
assert.equal(album?.title, "Thriller");
});
});Keep a plain module's test beside that module. Route behavior belongs beside its controller, while helpers shared by several tests can live under test/:
app/
└── actions/
└── albums/
├── controller.tsx
├── controller.test.ts
└── edit/
├── controller.tsx
└── controller.test.ts
test/
└── utils.ts # helpers shared by several testsTest routes with router.fetch
A router test sends the same Request that a runtime adapter would send in production. Router, controller, and action middleware run normally, and the test receives the final Response. App tests only need this Fetch boundary, not the rest of the remix/router API.
This test requests an album page without opening a network socket:
import * as assert from "remix/assert";
import { describe, it } from "remix/test";
import { router } from "../../router.ts";
import { routes } from "../../routes.ts";
const origin = "https://albums.test";
describe("album show", () => {
it("renders the requested album", async () => {
let request = new Request(new URL(routes.albums.show.href({ albumId: "thriller" }), origin));
let response = await router.fetch(request);
assert.equal(response.status, 200);
assert.equal(response.headers.get("Content-Type"), "text/html; charset=UTF-8");
assert.match(await response.text(), /Thriller/);
});
});Build test URLs with the same routes.<name>.href(...) helpers used by links, forms, and redirects. The test keeps the route name and params checked against the app's route map while the origin supplies the absolute URL required by Request.
Mutation tests construct the request body with Web APIs. For an edit action that reads FormData and redirects on success, build the form and assert on the redirect:
import * as assert from "remix/assert";
import { describe, it } from "remix/test";
import { router } from "../../../router.ts";
import { routes } from "../../../routes.ts";
const origin = "https://albums.test";
describe("album editing", () => {
it("redirects to the album after an update", async () => {
let albumId = "thriller";
let formData = new FormData();
formData.set("title", "Thriller");
formData.set("artist", "Michael Jackson");
formData.set("year", "1982");
let response = await router.fetch(
new Request(new URL(routes.albums.edit.action.href({ albumId }), origin), {
method: "POST",
body: formData,
}),
);
assert.equal(response.status, 303);
assert.equal(response.headers.get("Location"), routes.albums.show.href({ albumId }));
});
});The same FormData can carry an upload:
let formData = new FormData();
formData.set("cover", new File(["fake image bytes"], "cover.jpg", { type: "image/jpeg" }));Pass that form directly as the request body. Do not set Content-Type yourself because Request adds the multipart boundary when it serializes the FormData.
Middleware does not need a separate app test harness. Send a request through the router and assert on the middleware's observable result, such as a rejection status, a response header, a persisted session value, or a context value used by the action. A reusable middleware package may use a small router with one test route, but app middleware should usually run in the same stack as the app's controllers.
Isolate stateful app tests
The router itself is request-driven, but the values supplied by its middleware may be stateful. Sessions, databases, upload storage, caches, and module-level arrays can all leak changes into the next test.
Stateful tests are easier when app/router.ts exports a createAppRouter(options) factory in addition to the production router. Tests then get the same middleware and controller mappings as production, with infrastructure such as session storage swapped out. A test helper can pass in memory-backed session storage from remix/session-storage/memory:
import { createCookie } from "remix/cookie";
import { createMemorySessionStorage } from "remix/session-storage/memory";
import { createAppRouter } from "../app/router.ts";
export function createTestRouter() {
return createAppRouter({
sessionCookie: createCookie("session", {
secrets: ["test-secret"],
}),
sessionStorage: createMemorySessionStorage(),
});
}Create a fresh test router inside a test when it mutates session or middleware state. A suite may share one when its dependencies are read-only or reset between tests.
A browser manages cookies automatically in an end-to-end test. To keep a multi-request session flow in a router test, read the cookie from one response and send its name/value pair in the next request:
import * as assert from "remix/assert";
export function getResponseCookie(response: Response, name: string): string {
let setCookie = response.headers.getSetCookie().find((header) => header.startsWith(`${name}=`));
assert.ok(setCookie, `Expected a ${name} cookie`);
return setCookie.split(";", 1)[0];
}// inside a multi-request test:
let cookie = getResponseCookie(loginResponse, "session");
let accountResponse = await router.fetch(
new Request(accountUrl, {
headers: { Cookie: cookie },
}),
);Database state needs the same boundary. For SQLite, create a database backed by :memory:, apply the app's migrations, and seed only the records the test needs. PostgreSQL and MySQL tests should use a dedicated test database or schema and reset changed rows between tests. Do not point tests at development or production data.
For upload tests, remix/file-storage/memory provides createMemoryFileStorage(). A fresh temporary directory also works. Register cleanup with t.after(...) when a fixture opens a database connection, starts a server, or creates files that outlive the request.
When a test needs a function spy or method override, use t.mock.fn(...) or t.mock.method(object, "methodName", impl) on the test context. Method spies created through t.mock are restored automatically when the test finishes, or early with spy.mock.restore?.().
Test Remix components in the browser
Use a browser component test when the behavior depends on an event, a DOM property, or a component update. These tests run in isolated browser frames rather than a simulated DOM.
Browser and end-to-end tests require Playwright, a library that launches and controls real browsers such as Chromium and Firefox, plus its browser binaries:
npm i -D playwright
npx playwright installTake a small client component that tracks whether an album is a favorite:
import { clientEntry, on } from "remix/component";
import type { Handle } from "remix/component";
export const FavoriteButton = clientEntry(
import.meta.url,
function FavoriteButton(handle: Handle<{ albumTitle: string }>) {
let favorite = false;
return () => (
<button
aria-pressed={favorite}
mix={[
on("click", () => {
favorite = !favorite;
handle.update();
}),
]}
type="button"
>
{favorite
? `Remove ${handle.props.albumTitle} from favorites`
: `Add ${handle.props.albumTitle} to favorites`}
</button>
);
},
);render(...) from remix/component/test mounts the component, flushes its initial render, and returns helpers for querying and interacting with the DOM:
import * as assert from "remix/assert";
import { describe, it } from "remix/test";
import { render } from "remix/component/test";
import { FavoriteButton } from "./public/favorite-button.tsx";
describe("FavoriteButton", () => {
it("toggles an album as a favorite", async (t) => {
let { $, act, cleanup } = render(<FavoriteButton albumTitle="Thriller" />);
t.after(cleanup);
let button = $("button");
assert.ok(button instanceof HTMLButtonElement);
assert.equal(button.getAttribute("aria-pressed"), "false");
assert.equal(button.textContent, "Add Thriller to favorites");
await act(() => button.click());
assert.equal(button.getAttribute("aria-pressed"), "true");
assert.equal(button.textContent, "Remove Thriller from favorites");
});
});Use act(...) around an interaction that may call handle.update() or complete asynchronous component work. It flushes pending updates before the next assertion. Registering cleanup with t.after(...) disposes the component even when an assertion fails.
The button has no request behavior to prove, so this test stays at the browser component boundary.
Test complete flows end to end
An end-to-end test runs the router behind a real local HTTP server and drives the app through a Playwright Page, an object representing one browser tab that can navigate, find elements, and click or type into them. Use this boundary for a small number of flows where navigation, browser behavior, server rendering, and persistence must work together.
createTestServer(...) from remix/node-fetch-server/test adapts the router to a server on an available local port. t.serve(...) gives the Playwright page that server as its base URL and closes both after the test. This example uses a createTestRouter() helper so the edit doesn't touch shared state:
import * as assert from "remix/assert";
import { createTestServer } from "remix/node-fetch-server/test";
import { describe, it } from "remix/test";
import { routes } from "./routes.ts";
import { createTestRouter } from "../test/router.ts";
describe("album editing", () => {
it("updates an album from the edit page", async (t) => {
let router = createTestRouter();
let page = await t.serve(await createTestServer(router.fetch));
let albumId = "thriller";
await page.goto(routes.albums.edit.index.href({ albumId }));
await page.getByLabel("Year").fill("1982");
await page.getByRole("button", { name: "Save album" }).click();
await page.getByRole("heading", { name: "Thriller" }).waitFor();
assert.equal(new URL(page.url()).pathname, routes.albums.show.href({ albumId }));
await page.getByText("Michael Jackson · 1982").waitFor();
});
});The router and browser are cleaned up automatically. Database or file-storage fixtures created outside t.serve(...) still need their own cleanup.
Keep validation statuses, redirects, and middleware branches in router tests. One representative end-to-end flow can prove that the form, pending state, POST, redirect, and rendered result work together without repeating every server-side case in Playwright.
End to end tests for a SPA
A client-only SPA, including one that runs the router in the browser, has no server router for createTestServer(...) to wrap. Instead, pass t.serve(...) your own server object with a baseUrl and an async close() method. This example builds the app and serves it with Vite's preview server:
import { fileURLToPath } from "node:url";
import * as assert from "remix/assert";
import { beforeAll, describe, it } from "remix/test";
import { build, preview } from "vite";
const root = fileURLToPath(new URL("../", import.meta.url));
async function createSpaTestServer() {
let vite = await preview({
root,
logLevel: "silent",
server: { host: "127.0.0.1", port: 0 },
});
let address = vite.httpServer?.address();
if (address == null || typeof address === "string") {
await vite.close();
throw new Error("Vite did not bind to a TCP port");
}
return {
baseUrl: `http://127.0.0.1:${address.port}`,
async close() {
await vite.close();
},
};
}
describe("SPA navigation", () => {
beforeAll(async () => {
await build({ root, logLevel: "silent" });
});
it("loads a client route and navigates", async (t) => {
let page = await t.serve(await createSpaTestServer());
await page.goto("/");
await page.getByRole("heading", { name: "Home" }).waitFor();
await page.goto("/about");
await page.getByRole("heading", { name: "About" }).waitFor();
assert.equal(new URL(page.url()).pathname, "/about");
});
});Configure discovery, coverage, and CI
The default discovery rules cover the .test, .test.browser, and .test.e2e file names. Add a static remix.json when the app needs custom browser projects, excluded paths, global setup, or coverage settings. The file uses JSONC, so comments and trailing commas are allowed:
{
"$schema": "./node_modules/remix/schema/remix.json",
"test": {
"exclude": ["node_modules/**", "tmp/**"],
"setup": "./test/setup.ts",
"playwright": {
"configFile": "./playwright.config.ts",
"projects": ["chromium", "firefox"],
},
"coverage": {
// Keep local runs fast; `--coverage` enables these settings in CI.
"enabled": false,
"dir": ".coverage",
"include": ["app/**/*.{ts,tsx}"],
"exclude": ["app/**/*.test{,.browser,.e2e}.{ts,tsx}"],
"statements": 80,
"lines": 80,
"branches": 70,
"functions": 80,
},
},
}The playwright.projects list names which Playwright projects to run. A project is a named group of browser settings, usually one per browser, so browser and end-to-end tests run once in each listed project. The setup module can export globalSetup() and globalTeardown() functions that run once before and after the whole test run, such as migrating a test database. The statements, lines, branches, and functions values are minimum coverage percentages. When coverage is enabled and any of them isn't met, the test run fails.
Keep Playwright's executable settings in its own config and define the projects there, then reference that file from remix.json:
import { defineConfig } from "playwright/test";
export default defineConfig({
projects: [
{ name: "chromium", use: { browserName: "chromium" } },
{ name: "firefox", use: { browserName: "firefox" } },
],
});Explicit flags override remix.json, so a config with coverage disabled keeps local runs fast while CI turns it on with --coverage. In a project without coverage settings, the same flag produces a one-off report:
npm test -- --coverageDuring development, run only the boundary you are changing:
npm test -- --type server
npm test -- --type browser --project chromium
npm test -- --type e2e --project chromium
npm test -- --watchCI can run the same npm test command to cover all configured test types. Install Playwright's system dependencies and browser binaries first when the CI image does not include them:
npx playwright install --with-deps
npm test -- --coverage
npm run typecheckUse remix test --config ./config/remix.ci.json when CI needs a completely separate static config.
Both the --config path and the default remix.json are selected by the Remix CLI. Relative paths
inside either file resolve from that file's directory.
Keep type checking as a separate command. The test runner executes TypeScript, but it does not replace the compiler's project-wide checks. The CLI guide lists the remix test flags alongside the other Remix commands.