# Write Vitest Tests for React Router Loaders and Actions

Yes. An agent adds Vitest to your React Router app, calls each loader and action with a real Request, checks data and redirects, and opens a PR.

[Run this on Rush](https://cloud.getrush.ai/run/write-vitest-tests/react-router/), in a Project that has your repository.

## What the agent does

The agent starts from the route config, not from guesses about file names. In an app built from remix-run/react-router-templates it reads `app/routes.ts`, which in the `default` template declares one route, `index("routes/home.tsx")`, and then opens each module that config points at. `home.tsx` there exports `meta` and a default component but no `loader` and no `action`; the `node-custom-server` template's `home.tsx` does export a `loader` that returns `{ message: context.get(valueFromExpressContext) }`. The agent lists every exported `loader`, `action`, `clientLoader` and `clientAction` it finds this way, and the pull request says which routes had none, so a reviewer can see nothing was skipped.

Then it reads how the project is built, because a test runner has to fit it. The templates repository is a pnpm workspace (`pnpm-workspace.yaml` lists `default`, `node-custom-server`, the two `unstable_rsc` templates and `.tests`) with a `pnpm-lock.yaml` at `lockfileVersion: '9.0'` that resolves `react-router` to 8.4.0, Vite to 8.0.3 and TypeScript to 5.9.3. The only tests are Playwright files under `.tests/`, such as `test.default.ts`, which run `pnpm typecheck`, `pnpm dev` and `pnpm build` and then load the page in a browser; nothing calls a loader directly. `vite.config.ts` registers `tailwindcss()` and `reactRouter()` and sets `resolve.tsconfigPaths`, so `~/` imports resolve through `tsconfig.json`.

It adds Vitest as a dev dependency with pnpm, respecting the workspace's `minimumReleaseAge: 10800` setting, and writes a `vitest.config.ts` that keeps `resolve.tsconfigPaths` but leaves out the `reactRouter()` plugin, which expects to drive a dev server or a build rather than a test run. Each test imports the route module and calls its function the way the framework does: a `new Request("http://localhost/...")` for a loader, a `POST` Request with a `FormData` body for an action, `params` for dynamic segments, and a context built the way the server builds it. For the `node-custom-server` loader that means a `RouterContextProvider` with `context.set(valueFromExpressContext, ...)`, mirroring `server/app.ts`, so the test checks the real wiring instead of a stub.

Assertions target what the server decides. A loader test checks the returned data; an action test checks the redirect `Response`, its status and its `Location` header, or the validation errors it returns for a bad form; a thrown `Response` is caught and its status checked, the way `ErrorBoundary` in `root.tsx` would see it through `isRouteErrorResponse`. The agent runs the new `test` script and `pnpm typecheck` (`react-router typegen && tsc`) in its sandbox, because the `+types` imports in each route only exist after typegen and `tsconfig.json` includes every file, tests included.

## Start it with one API call

`POST https://api.prix.dev/o/{org}/sessions`

```json
{
  "agent": "claude",
  "input": "Add Vitest to this React Router framework-mode app and write tests for every route loader and action. Read app/routes.ts to find every route module, and list each exported loader, action, clientLoader and clientAction. Add a vitest.config.ts that keeps the tsconfig path aliases but does not load the reactRouter() Vite plugin, and a test script in package.json, using the package manager the lockfile shows. Call each loader and action directly with real Request objects (FormData bodies for actions), real params, and the same context the server sets up, and assert the returned data, redirects (status and Location) and thrown responses. Do not mock the route modules themselves. Run the tests and the typecheck script, fix anything that fails, and open a pull request whose description lists every loader and action, the test that covers it, and any route that has neither.",
  "resources": [
    {
      "url": "https://github.com/your-org/your-app"
    }
  ]
}
```

1. **Get an API key.** In the console, open Settings, then API keys, and create a key for your org. Send it in the Authorization header as a Bearer token.
2. **Send the request.** POST the body in request.json to https://api.prix.dev/o/{org}/sessions, with your repository as the resources URL. The run starts in its own cloud sandbox.
3. **Read what came back.** Follow the run on its live stream or wait for the run.completed webhook, then read the result and its trajectory.

## What comes back

The pull request adds `vitest` to `devDependencies` and the lockfile, a `vitest.config.ts`, a `test` script in `package.json`, and one test file beside each route module that has a loader or an action, such as `app/routes/home.test.ts`. Each file holds a `describe` block per exported function with cases for the normal path, the redirect or thrown response, and bad input where the action validates a form. When a route has no server function, as in the `default` template, the description says so instead of adding a test that checks nothing.

The proof is the commands and their output. The trajectory keeps the `pnpm install`, the Vitest run and the `react-router typegen && tsc` run from the sandbox with their full output, and the pull request's own checks run on the branch if the repository has CI. A reviewer reads the assertions rather than the count: each loader test should pin data the UI depends on, each action test should pin where a successful submit redirects, and a context or session the server builds should be built the same way in the test, not replaced by a hand-written object.

## Run it on every new route module

Listen for GitHub's `push` webhook on your default branch and, when a commit adds a file under `app/routes/` with no `.test.ts` beside it, have your server send this request with the repository in `resources` and the new file's path added to `input`. Each route gets its own run in its own sandbox and its own pull request, so the tests arrive while the author still remembers what the loader is meant to return.

With a webhook URL set on the API key, Rush POSTs `run.completed` or `run.failed` with the session URL when each run's turn ends, so your server can link the pull request in the original author's thread. Keep the commit SHA with the `execution_id` the request returns, so a redelivered GitHub event finds the run already started instead of starting a second one.

## Why run it on Rush?

Writing the first tests for a framework-mode app is mostly setup: finding a Vitest config that works beside the React Router Vite plugin, getting path aliases and typegen right, and learning how to build a context the way the server does. On Rush the agent does that trial and error in its own sandbox with its own clone and its own `node_modules`, so your checkout, your dev server and your pnpm store are untouched while it works.

What comes back can be checked rather than trusted. Every install, test run and typecheck the agent ran is in the trajectory with its output, so a reviewer can see the tests failed before a fix and passed after it, and the pull request is an ordinary diff a person reviews before anything merges. Because each run is independent, the same request can cover several apps in a monorepo at once, one pull request per app.

## Frequently asked questions

### Does it test loaders through the browser or directly?

Directly. It imports the route module and calls `loader` or `action` with a real `Request`, `params` and context, which is fast and pins the server's decisions. Your existing Playwright tests, like the ones under `.tests/` in the templates, stay as they are for the full page flow.

### Will the React Router Vite plugin break Vitest?

It can, which is why the agent writes a separate `vitest.config.ts` without `reactRouter()` and keeps only what the tests need, such as `resolve.tsconfigPaths` for `~/` imports. If your `vite.config.ts` already skips the plugin under `process.env.VITEST`, it reuses that file instead.

### What about loaders that read a session cookie or a database?

It builds the same inputs the server would: a `Request` with a `Cookie` header from your own session storage, or a test database when the repository has one configured. It does not replace your session or data modules with mocks, and when a dependency cannot run in the sandbox it says so in the pull request rather than faking it.

### Does it work with npm, Yarn or Bun instead of pnpm?

Yes. It uses whichever lockfile the repository has and adds Vitest with that package manager, so the lockfile in the pull request matches what your CI installs.

### My app was built with Remix v2. Does that count?

React Router framework mode is what Remix v2 became, so the same `loader` and `action` exports apply. If the app still imports from `@remix-run/node`, the agent writes the tests against those imports and does not migrate the app as part of this job.
