Testing Remix Apps: Loaders, Actions, Components, and E2E
Why Remix Has a Better Testing Story Than Most Frameworks
React Router and Remix took a deliberate design decision that has a direct testing benefit: loaders and actions are plain async functions. They receive a Request object and return data or a Response. There's no lifecycle, no context injection, no component tree to mount. You call them, you get a result.
Compare this to Next.js, where getServerSideProps and API routes are structurally tied to Next's server internals. Testing a Remix loader is trivial. Testing the equivalent in Next requires significantly more ceremony.
This doesn't mean Remix testing is without complexity. Components still need routing context. Forms need to submit through Remix's action pipeline. And E2E tests still need a running app. But the architecture gives you a solid foundation.
Testing Loaders Directly
A Remix loader is just a function. Test it like one.
// app/routes/dashboard.tsx
export async function loader({ request }: LoaderFunctionArgs) {
const user = await getAuthenticatedUser(request);
if (!user) {
throw redirect("/login");
}
const projects = await db.project.findMany({
where: { userId: user.id },
});
return json({ user, projects });
}Test this with Jest or Vitest — no need to spin up a server:
import { loader } from "~/routes/dashboard";
import { createRequest } from "~/test-utils/requests";
describe("dashboard loader", () => {
it("returns user and projects for authenticated request", async () => {
const mockUser = { id: "user-1", email: "test@example.com" };
vi.mocked(getAuthenticatedUser).mockResolvedValue(mockUser);
vi.mocked(db.project.findMany).mockResolvedValue([
{ id: "proj-1", name: "Alpha", userId: "user-1" },
]);
const request = new Request("http://localhost/dashboard", {
headers: { Cookie: "session=valid-token" },
});
const response = await loader({ request, params: {}, context: {} });
const data = await response.json();
expect(data.user.id).toBe("user-1");
expect(data.projects).toHaveLength(1);
expect(data.projects[0].name).toBe("Alpha");
});
it("redirects unauthenticated users to /login", async () => {
vi.mocked(getAuthenticatedUser).mockResolvedValue(null);
const request = new Request("http://localhost/dashboard");
await expect(
loader({ request, params: {}, context: {} })
).rejects.toMatchObject({
headers: { Location: "/login" },
status: 302,
});
});
});Note how Remix's redirect() throws — catch it as a rejected promise, then assert on the response shape.
Testing Actions
Actions handle form submissions and mutations. Same pattern: plain function, plain test.
// app/routes/projects.new.tsx
export async function action({ request }: ActionFunctionArgs) {
const user = await getAuthenticatedUser(request);
if (!user) throw redirect("/login");
const formData = await request.formData();
const name = formData.get("name");
if (!name || typeof name !== "string" || name.trim().length === 0) {
return json({ error: "Project name is required" }, { status: 422 });
}
const project = await db.project.create({
data: { name: name.trim(), userId: user.id },
});
return redirect(`/projects/${project.id}`);
}Test it by constructing a real Request with FormData:
import { action } from "~/routes/projects.new";
describe("new project action", () => {
it("creates project and redirects on valid submission", async () => {
const mockUser = { id: "user-1" };
vi.mocked(getAuthenticatedUser).mockResolvedValue(mockUser);
vi.mocked(db.project.create).mockResolvedValue({
id: "proj-new",
name: "My Project",
userId: "user-1",
});
const formData = new FormData();
formData.append("name", "My Project");
const request = new Request("http://localhost/projects/new", {
method: "POST",
body: formData,
});
await expect(
action({ request, params: {}, context: {} })
).rejects.toMatchObject({
headers: { Location: "/projects/proj-new" },
});
expect(db.project.create).toHaveBeenCalledWith({
data: { name: "My Project", userId: "user-1" },
});
});
it("returns 422 when name is empty", async () => {
vi.mocked(getAuthenticatedUser).mockResolvedValue({ id: "user-1" });
const formData = new FormData();
formData.append("name", " ");
const request = new Request("http://localhost/projects/new", {
method: "POST",
body: formData,
});
const response = await action({ request, params: {}, context: {} });
const body = await response.json();
expect(response.status).toBe(422);
expect(body.error).toBe("Project name is required");
});
});This covers validation logic, database calls, and redirect behavior — all without a browser or HTTP server.
Testing Components with createRemixStub
Remix components that call useLoaderData, useActionData, or useNavigation need routing context. Without it, they throw. The official solution is createRemixStub from @remix-run/testing.
import { createRemixStub } from "@remix-run/testing";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import ProjectCard from "~/components/ProjectCard";
import DashboardRoute from "~/routes/dashboard";
describe("DashboardRoute", () => {
it("renders project list from loader data", async () => {
const RemixStub = createRemixStub([
{
path: "/dashboard",
Component: DashboardRoute,
loader() {
return {
user: { email: "test@example.com" },
projects: [
{ id: "1", name: "Alpha" },
{ id: "2", name: "Beta" },
],
};
},
},
]);
render(<RemixStub initialEntries={["/dashboard"]} />);
expect(await screen.findByText("Alpha")).toBeInTheDocument();
expect(screen.getByText("Beta")).toBeInTheDocument();
});
});The stub handles the routing context your components expect. You define the loader inline — no mock of useLoaderData required.
Testing Form Submissions Through Actions
The more interesting case: testing that a form submission triggers the action and updates the UI.
describe("new project form", () => {
it("submits form and shows success state", async () => {
const user = userEvent.setup();
const RemixStub = createRemixStub([
{
path: "/projects/new",
Component: NewProjectRoute,
action() {
return redirect("/projects/proj-123");
},
},
{
path: "/projects/:id",
Component: ProjectDetailRoute,
loader() {
return { project: { id: "proj-123", name: "My Project" } };
},
},
]);
render(<RemixStub initialEntries={["/projects/new"]} />);
await user.type(screen.getByLabelText("Project name"), "My Project");
await user.click(screen.getByRole("button", { name: "Create" }));
// After redirect, should land on project detail
expect(await screen.findByText("proj-123")).toBeInTheDocument();
});
it("displays validation error on empty submission", async () => {
const user = userEvent.setup();
const RemixStub = createRemixStub([
{
path: "/projects/new",
Component: NewProjectRoute,
action() {
return json({ error: "Project name is required" }, { status: 422 });
},
},
]);
render(<RemixStub initialEntries={["/projects/new"]} />);
await user.click(screen.getByRole("button", { name: "Create" }));
expect(
await screen.findByText("Project name is required")
).toBeInTheDocument();
});
});This is real integration testing — the form submits through the full Remix action pipeline inside the stub, and the component re-renders with useActionData populated. No manual event firing, no mocking of Remix internals.
Playwright E2E for Remix
Unit tests and component tests cover logic and rendering. Playwright covers the app as a whole — real browser, real cookies, real network.
A minimal Playwright config for a Remix app:
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
use: {
baseURL: process.env.BASE_URL || "http://localhost:3000",
},
webServer: {
command: "npm run dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "mobile", use: { ...devices["iPhone 14"] } },
],
});A meaningful Playwright test for the project creation flow:
// e2e/projects.spec.ts
import { test, expect } from "@playwright/test";
test.describe("project creation", () => {
test.beforeEach(async ({ page }) => {
// Log in once before each test in this suite
await page.goto("/login");
await page.getByLabel("Email").fill("test@example.com");
await page.getByLabel("Password").fill("password123");
await page.getByRole("button", { name: "Sign In" }).click();
await page.waitForURL("/dashboard");
});
test("creates a new project and shows it in the list", async ({ page }) => {
await page.goto("/projects/new");
await page.getByLabel("Project name").fill("E2E Test Project");
await page.getByRole("button", { name: "Create" }).click();
await page.waitForURL(/\/projects\/[^/]+$/);
await expect(page.getByRole("heading")).toContainText("E2E Test Project");
await page.goto("/dashboard");
await expect(page.getByText("E2E Test Project")).toBeVisible();
});
test("shows error when project name is empty", async ({ page }) => {
await page.goto("/projects/new");
await page.getByRole("button", { name: "Create" }).click();
await expect(page.getByRole("alert")).toContainText(
"Project name is required"
);
});
});Set up auth once per test file with test.beforeAll + storageState for larger suites — re-logging in before every test is slow and wasteful.
Setting Up the Test Environment
A working vitest.config.ts for Remix:
import { defineConfig } from "vitest/config";
import { installGlobals } from "@remix-run/node";
import tsconfigPaths from "vite-tsconfig-paths";
installGlobals();
export default defineConfig({
plugins: [tsconfigPaths()],
test: {
globals: true,
environment: "happy-dom",
setupFiles: ["./test/setup.ts"],
},
});Your test/setup.ts is where you put global mocks — the database client, external API clients, anything that should never make real calls during unit tests.
What Tests Won't Catch
Your unit tests will tell you the loader returns the right data. Your Playwright tests will confirm the form submits correctly in Chrome. Neither tells you what's happening in production right now.
Things that routinely fail in production and slip past a complete test suite:
- Auth header stripping at the CDN layer. Your Remix session cookie works locally. A CDN misconfiguration drops it. Authenticated routes return 401s for real users. No test caught it because the CDN isn't in your test environment.
- Database connection pool exhaustion. Works fine under 10 concurrent Playwright tests. Falls over under 200 real users. Load testing catches some of this; most teams don't run it before every deploy.
- Third-party service timeouts. Your loader calls an external API. That API starts returning 504s under load. Your Remix loader throws. No test caught it because the test environment mocks the API client.
- Environment-specific secrets and config. The SMTP service key is correct in
.env.local. It's wrong in the production secret. Password reset flows fail for every user. Your tests use a mock mailer.
The pattern is consistent: tests are isolated. Production is not.
Production Monitoring for Remix Apps
The gap between "tests pass" and "production works" is where HelpMeTest sits. It runs your critical flows against the real deployed app on a schedule — every 5 minutes, every hour, whatever cadence matters for your app.
curl -fsSL https://helpmetest.com/install | bash
helpmetest loginWrite a test in plain English against your production URL:
Open https://myapp.com/login
Type "monitor@example.com" into email field
Type "testpassword" into password field
Click "Sign In"
Wait for dashboard
Verify "Welcome back" text is visible
Verify URL contains "/dashboard"If the login flow starts failing in production — bad deploy, expired secret, database issue — you get alerted immediately. Not when a user tweets about it.
Free tier: 10 tests, unlimited health checks, CI integration included. Pro: $100/month for unlimited tests and monitoring at 5-minute intervals.
# Install and connect your app in under 2 minutes
curl -fsSL https://helpmetest.com/install <span class="hljs-pipe">| bash
helpmetest login
helpmetest proxy start :3000 <span class="hljs-comment"># if testing locally firstStart free at helpmetest.com →
Your test suite tells you the code looks right. HelpMeTest tells you the app actually works for the people using it.