Remix Testing Guide: Loaders, Actions and Routes (2026)
Remix organizes your app around routes, loaders, and actions. Loaders fetch data before the route renders. Actions handle form submissions. Route components display the data. This structure makes testing straightforward — each piece has clear inputs and outputs you can verify independently.
This guide covers the full Remix testing stack: Vitest for loaders, actions, and utilities; @remix-run/testing for route component tests; and Playwright for end-to-end coverage.
The Remix Testing Stack
| Layer | Tool | What it covers |
|---|---|---|
| Unit | Vitest | Loaders, actions, utilities |
| Component | @remix-run/testing + React Testing Library | Route components with context |
| E2E | Playwright | Full app in a real browser |
Remix ships with @remix-run/testing — a package that provides test utilities for rendering route components with Remix context. Combine it with React Testing Library for behavior-focused assertions.
Installing Dependencies
npm install -D vitest @remix-run/testing @testing-library/react \
@testing-library/jest-dom @testing-library/user-event jsdomConfigure Vitest:
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [tsconfigPaths()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./test/setup.ts'],
},
});// test/setup.ts
import '@testing-library/jest-dom';The vite-tsconfig-paths plugin resolves Remix path aliases like ~/routes and ~/components.
Testing Loaders
Loaders are async functions that receive a LoaderFunctionArgs object and return data. Import and call them directly in tests.
// app/routes/posts.$slug.tsx
import { json } from '@remix-run/node';
import type { LoaderFunctionArgs } from '@remix-run/node';
import { db } from '~/db.server';
export async function loader({ params }: LoaderFunctionArgs) {
const post = await db.posts.findUnique({
where: { slug: params.slug },
include: { author: true },
});
if (!post) {
throw new Response('Not Found', { status: 404 });
}
return json({ post });
}// app/routes/posts.$slug.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { loader } from './posts.$slug';
vi.mock('~/db.server', () => ({
db: {
posts: { findUnique: vi.fn() },
},
}));
import { db } from '~/db.server';
const mockPost = {
id: 1,
slug: 'hello-remix',
title: 'Hello Remix',
content: 'Content here',
author: { id: 'author-1', name: 'Ada Lovelace' },
};
describe('posts.$slug loader', () => {
beforeEach(() => {
vi.mocked(db.posts.findUnique).mockResolvedValue(mockPost as any);
});
it('returns post data when slug exists', async () => {
const response = await loader({
params: { slug: 'hello-remix' },
request: new Request('http://localhost/posts/hello-remix'),
context: {},
});
const data = await response.json();
expect(data.post.title).toBe('Hello Remix');
expect(data.post.author.name).toBe('Ada Lovelace');
});
it('throws 404 response when post does not exist', async () => {
vi.mocked(db.posts.findUnique).mockResolvedValue(null);
await expect(
loader({
params: { slug: 'nonexistent' },
request: new Request('http://localhost/posts/nonexistent'),
context: {},
})
).rejects.toSatisfy((e: Response) => e.status === 404);
});
it('queries the database with the correct slug', async () => {
await loader({
params: { slug: 'specific-slug' },
request: new Request('http://localhost/posts/specific-slug'),
context: {},
});
expect(db.posts.findUnique).toHaveBeenCalledWith(
expect.objectContaining({
where: { slug: 'specific-slug' },
})
);
});
});Testing Actions
Actions handle form submissions. They receive a ActionFunctionArgs object with a request containing the form data.
// app/routes/posts.new.tsx
import { json, redirect } from '@remix-run/node';
import type { ActionFunctionArgs } from '@remix-run/node';
import { db } from '~/db.server';
import { requireUser } from '~/auth.server';
export async function action({ request }: ActionFunctionArgs) {
const user = await requireUser(request);
const formData = await request.formData();
const title = formData.get('title')?.toString().trim() ?? '';
const content = formData.get('content')?.toString().trim() ?? '';
const errors: Record<string, string> = {};
if (!title) errors.title = 'Title is required';
if (!content) errors.content = 'Content is required';
if (content.length < 50) errors.content = 'Content must be at least 50 characters';
if (Object.keys(errors).length > 0) {
return json({ errors }, { status: 422 });
}
const post = await db.posts.create({
data: { title, content, authorId: user.id },
});
return redirect(`/posts/${post.slug}`);
}// app/routes/posts.new.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { action } from './posts.new';
vi.mock('~/db.server', () => ({
db: { posts: { create: vi.fn() } },
}));
vi.mock('~/auth.server', () => ({
requireUser: vi.fn(),
}));
import { db } from '~/db.server';
import { requireUser } from '~/auth.server';
function makeFormRequest(fields: Record<string, string>) {
const formData = new FormData();
for (const [k, v] of Object.entries(fields)) formData.append(k, v);
return new Request('http://localhost/posts/new', { method: 'POST', body: formData });
}
const mockUser = { id: 'user-1', email: 'user@test.com' };
const validFields = {
title: 'My New Post',
content: 'This is the post content. It needs to be at least fifty characters long to be valid.',
};
describe('posts.new action', () => {
beforeEach(() => {
vi.mocked(requireUser).mockResolvedValue(mockUser as any);
vi.mocked(db.posts.create).mockResolvedValue({ slug: 'my-new-post' } as any);
});
it('returns 422 when title is missing', async () => {
const { title: _t, ...noTitle } = validFields;
const response = await action({ request: makeFormRequest(noTitle), params: {}, context: {} });
expect(response.status).toBe(422);
const data = await response.json();
expect(data.errors.title).toBe('Title is required');
});
it('returns 422 when content is too short', async () => {
const response = await action({
request: makeFormRequest({ ...validFields, content: 'Short content' }),
params: {},
context: {},
});
expect(response.status).toBe(422);
const data = await response.json();
expect(data.errors.content).toMatch(/50 characters/);
});
it('redirects to post page on valid submission', async () => {
const response = await action({
request: makeFormRequest(validFields),
params: {},
context: {},
});
expect(response.status).toBe(302);
expect(response.headers.get('Location')).toBe('/posts/my-new-post');
});
it('does not create post when validation fails', async () => {
await action({
request: makeFormRequest({ title: '', content: 'Short' }),
params: {},
context: {},
});
expect(db.posts.create).not.toHaveBeenCalled();
});
it('creates post with author ID from authenticated user', async () => {
await action({
request: makeFormRequest(validFields),
params: {},
context: {},
});
expect(db.posts.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ authorId: 'user-1' }),
})
);
});
});Testing Route Components
Route components receive loader data via the useLoaderData hook. The @remix-run/testing package provides createRemixStub to render them with a fake Remix context.
// app/routes/posts.$slug.tsx (component part)
import { useLoaderData } from '@remix-run/react';
import type { loader } from './posts.$slug';
export default function PostRoute() {
const { post } = useLoaderData<typeof loader>();
return (
<article>
<h1>{post.title}</h1>
<p className="author">By {post.author.name}</p>
<div className="content">{post.content}</div>
</article>
);
}// app/routes/posts.$slug.component.test.tsx
import { render, screen } from '@testing-library/react';
import { createRemixStub } from '@remix-run/testing';
import { describe, it, expect } from 'vitest';
import PostRoute from './posts.$slug';
describe('PostRoute component', () => {
it('renders post title and author', async () => {
const RemixStub = createRemixStub([
{
path: '/posts/:slug',
Component: PostRoute,
loader() {
return {
post: {
title: 'Hello Remix',
content: 'This is the post content.',
author: { name: 'Ada Lovelace' },
},
};
},
},
]);
render(<RemixStub initialEntries={['/posts/hello-remix']} />);
await screen.findByRole('heading', { name: 'Hello Remix' });
expect(screen.getByText('By Ada Lovelace')).toBeInTheDocument();
expect(screen.getByText('This is the post content.')).toBeInTheDocument();
});
it('renders content in an article element', async () => {
const RemixStub = createRemixStub([
{
path: '/posts/:slug',
Component: PostRoute,
loader() {
return {
post: {
title: 'Test Post',
content: 'Content',
author: { name: 'Author' },
},
};
},
},
]);
const { container } = render(
<RemixStub initialEntries={['/posts/test-post']} />
);
await screen.findByRole('heading', { name: 'Test Post' });
expect(container.querySelector('article')).toBeInTheDocument();
});
});E2E Tests with Playwright
Playwright tests verify the full app works end-to-end in a real browser.
npm install -D @playwright/test
npx playwright install chromium// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
use: { baseURL: 'http://localhost:3000' },
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});// e2e/posts.test.ts
import { test, expect } from '@playwright/test';
test('can view a blog post', async ({ page }) => {
await page.goto('/posts');
const firstPost = page.getByRole('article').first().getByRole('link');
await firstPost.click();
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
await expect(page.getByText(/by /i)).toBeVisible();
});
test('shows 404 for nonexistent post', async ({ page }) => {
await page.goto('/posts/this-post-does-not-exist-at-all');
await expect(page.getByText(/not found/i)).toBeVisible();
});What to Prioritize
Not all tests are equally valuable. In a Remix app:
Test first:
- Loader data fetching and error handling (404, 500 paths)
- Action validation — all error branches and the success path
- Authentication checks in loaders and actions
Test second:
- Route components with meaningful state variations
- Complex derived data (totals, filtered lists, formatted dates)
Skip or defer:
- Simple pass-through loaders that just call one DB method
- UI-only components with no logic
Production Monitoring with HelpMeTest
Unit tests verify each piece works in isolation. After deployment:
- Loader data may be missing because an environment variable isn't set
- A database migration may break queries that worked in tests
- Third-party services your loaders call may be slow or unavailable
HelpMeTest monitors your Remix app continuously:
Go to https://myremixapp.com/posts
Verify a list of post titles is visible
Click the first post title
Verify the author name is visible
Verify the post content is visibleFree tier: 10 tests, 5-minute intervals.
Pro: $100/month — unlimited tests, 24/7 monitoring.
Start free at helpmetest.com — no credit card required.