Testing Remix Loaders and Actions with Vitest (2026)
Remix loaders and actions are the backend of your application. Loaders fetch data; actions handle mutations. Both are pure functions that take a request and return a response — which makes them easy to test without a running server.
This guide goes deep on testing Remix loaders and actions: constructing requests, mocking dependencies, asserting redirects, and handling authentication.
Why Test Loaders and Actions Directly
The alternative — testing through the UI — is slow and brittle. A loader test that mocks the database runs in milliseconds. The same coverage through E2E tests takes 10–30 seconds and fails for unrelated reasons (network, timing, browser state).
Direct loader and action tests give you:
- Fast feedback (milliseconds vs seconds)
- Precise error messages ("expected 422, got 200" vs "button click had no effect")
- Easy coverage of all edge cases (you don't have to set up UI state for each one)
- Tests that don't break when the UI changes
Setup
npm install -D vitest vite-tsconfig-paths// vitest.config.ts
import { defineConfig } from 'vitest/config';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [tsconfigPaths()],
test: {
globals: true,
environment: 'node',
},
});Use environment: 'node' for loader and action tests — no DOM needed, and it's faster.
Constructing Test Requests
Remix loaders and actions receive a LoaderFunctionArgs or ActionFunctionArgs with a Request object. Construct test requests with the native Request and FormData APIs.
// Utility helpers for building test requests
function makeGetRequest(url: string, headers: Record<string, string> = {}) {
return new Request(url, { headers });
}
function makeFormRequest(url: string, fields: Record<string, string>) {
const formData = new FormData();
for (const [key, value] of Object.entries(fields)) {
formData.append(key, value);
}
return new Request(url, { method: 'POST', body: formData });
}
function makeJsonRequest(url: string, body: unknown) {
return new Request(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}
// Usage:
const request = makeFormRequest('http://localhost/subscribe', {
email: 'user@example.com',
plan: 'pro',
});For loaders, you often need URL search parameters:
function makeSearchRequest(baseUrl: string, params: Record<string, string>) {
const url = new URL(baseUrl);
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value);
}
return new Request(url.toString());
}
// Usage:
const request = makeSearchRequest('http://localhost/api/users', {
page: '2',
limit: '10',
sort: 'name',
});Deep-Diving Loader Tests
Pagination Logic
// app/routes/users._index.tsx
import { json } from '@remix-run/node';
import type { LoaderFunctionArgs } from '@remix-run/node';
import { db } from '~/db.server';
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const page = Math.max(1, Number(url.searchParams.get('page') ?? '1'));
const limit = Math.min(50, Math.max(1, Number(url.searchParams.get('limit') ?? '20')));
const search = url.searchParams.get('q')?.trim() ?? '';
const where = search
? { OR: [{ name: { contains: search } }, { email: { contains: search } }] }
: undefined;
const [users, total] = await Promise.all([
db.users.findMany({
where,
skip: (page - 1) * limit,
take: limit,
orderBy: { name: 'asc' },
}),
db.users.count({ where }),
]);
return json({
users,
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
});
}// app/routes/users._index.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { loader } from './users._index';
vi.mock('~/db.server', () => ({
db: {
users: {
findMany: vi.fn().mockResolvedValue([]),
count: vi.fn().mockResolvedValue(0),
},
},
}));
import { db } from '~/db.server';
describe('users loader', () => {
beforeEach(() => {
vi.mocked(db.users.findMany).mockResolvedValue([]);
vi.mocked(db.users.count).mockResolvedValue(0);
});
it('uses page 1 and limit 20 by default', async () => {
await loader({
request: new Request('http://localhost/users'),
params: {},
context: {},
});
expect(db.users.findMany).toHaveBeenCalledWith(
expect.objectContaining({ skip: 0, take: 20 })
);
});
it('respects explicit page parameter', async () => {
await loader({
request: new Request('http://localhost/users?page=3'),
params: {},
context: {},
});
expect(db.users.findMany).toHaveBeenCalledWith(
expect.objectContaining({ skip: 40, take: 20 })
);
});
it('clamps limit to maximum 50', async () => {
await loader({
request: new Request('http://localhost/users?limit=100'),
params: {},
context: {},
});
expect(db.users.findMany).toHaveBeenCalledWith(
expect.objectContaining({ take: 50 })
);
});
it('treats page 0 as page 1', async () => {
await loader({
request: new Request('http://localhost/users?page=0'),
params: {},
context: {},
});
expect(db.users.findMany).toHaveBeenCalledWith(
expect.objectContaining({ skip: 0 })
);
});
it('passes search filter to database query', async () => {
await loader({
request: new Request('http://localhost/users?q=ada'),
params: {},
context: {},
});
expect(db.users.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
OR: [
{ name: { contains: 'ada' } },
{ email: { contains: 'ada' } },
],
},
})
);
});
it('computes totalPages correctly', async () => {
vi.mocked(db.users.count).mockResolvedValue(45);
const response = await loader({
request: new Request('http://localhost/users?limit=10'),
params: {},
context: {},
});
const data = await response.json();
expect(data.pagination.totalPages).toBe(5);
});
});Loader with Authentication
// app/routes/dashboard.tsx
import { json, redirect } from '@remix-run/node';
import type { LoaderFunctionArgs } from '@remix-run/node';
import { getSession } from '~/sessions.server';
import { db } from '~/db.server';
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'));
const userId = session.get('userId');
if (!userId) {
throw redirect('/login?returnTo=/dashboard');
}
const [user, recentActivity] = await Promise.all([
db.users.findUnique({ where: { id: userId } }),
db.activityLog.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
take: 10,
}),
]);
if (!user) {
throw redirect('/login');
}
return json({ user, recentActivity });
}// app/routes/dashboard.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { loader } from './dashboard';
vi.mock('~/sessions.server', () => ({
getSession: vi.fn(),
}));
vi.mock('~/db.server', () => ({
db: {
users: { findUnique: vi.fn() },
activityLog: { findMany: vi.fn() },
},
}));
import { getSession } from '~/sessions.server';
import { db } from '~/db.server';
const mockSession = (userId: string | null) => ({
get: (key: string) => (key === 'userId' ? userId : null),
});
describe('dashboard loader', () => {
beforeEach(() => {
vi.mocked(db.activityLog.findMany).mockResolvedValue([]);
});
it('redirects unauthenticated users to login', async () => {
vi.mocked(getSession).mockResolvedValue(mockSession(null) as any);
await expect(
loader({ request: new Request('http://localhost/dashboard'), params: {}, context: {} })
).rejects.toSatisfy((r: Response) => {
return r.status === 302 && r.headers.get('Location')?.includes('/login');
});
});
it('includes returnTo parameter in redirect for unauthenticated users', async () => {
vi.mocked(getSession).mockResolvedValue(mockSession(null) as any);
await expect(
loader({ request: new Request('http://localhost/dashboard'), params: {}, context: {} })
).rejects.toSatisfy((r: Response) => {
return r.headers.get('Location') === '/login?returnTo=/dashboard';
});
});
it('returns user and activity for authenticated user', async () => {
const mockUser = { id: 'user-1', name: 'Ada', email: 'ada@test.com' };
vi.mocked(getSession).mockResolvedValue(mockSession('user-1') as any);
vi.mocked(db.users.findUnique).mockResolvedValue(mockUser as any);
const response = await loader({
request: new Request('http://localhost/dashboard'),
params: {},
context: {},
});
const data = await response.json();
expect(data.user.name).toBe('Ada');
expect(data.recentActivity).toEqual([]);
});
});Deep-Diving Action Tests
Multi-Step Form Actions
// app/routes/checkout.tsx
import { json, redirect } from '@remix-run/node';
import type { ActionFunctionArgs } from '@remix-run/node';
import { db } from '~/db.server';
import { processPayment } from '~/payment.server';
import { requireUser } from '~/auth.server';
export async function action({ request }: ActionFunctionArgs) {
const user = await requireUser(request);
const formData = await request.formData();
const items = formData.getAll('itemId') as string[];
const paymentMethod = formData.get('paymentMethod')?.toString() ?? '';
const shippingAddress = formData.get('shippingAddress')?.toString().trim() ?? '';
if (items.length === 0) {
return json({ error: 'Cart is empty' }, { status: 422 });
}
if (!paymentMethod) {
return json({ error: 'Payment method is required' }, { status: 422 });
}
if (!shippingAddress) {
return json({ error: 'Shipping address is required' }, { status: 422 });
}
const cartItems = await db.products.findMany({
where: { id: { in: items } },
});
if (cartItems.length !== items.length) {
return json({ error: 'Some items are no longer available' }, { status: 422 });
}
const total = cartItems.reduce((sum, item) => sum + item.price, 0);
const { success, transactionId } = await processPayment({
amount: total,
method: paymentMethod,
userId: user.id,
});
if (!success) {
return json({ error: 'Payment failed. Please try again.' }, { status: 422 });
}
const order = await db.orders.create({
data: {
userId: user.id,
items: { connect: items.map((id) => ({ id })) },
total,
transactionId,
shippingAddress,
},
});
return redirect(`/orders/${order.id}/confirmation`);
}// app/routes/checkout.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { action } from './checkout';
vi.mock('~/db.server', () => ({
db: {
products: { findMany: vi.fn() },
orders: { create: vi.fn() },
},
}));
vi.mock('~/payment.server', () => ({ processPayment: vi.fn() }));
vi.mock('~/auth.server', () => ({ requireUser: vi.fn() }));
import { db } from '~/db.server';
import { processPayment } from '~/payment.server';
import { requireUser } from '~/auth.server';
function makeCheckoutRequest(fields: Record<string, string | string[]>) {
const formData = new FormData();
for (const [key, value] of Object.entries(fields)) {
if (Array.isArray(value)) {
value.forEach((v) => formData.append(key, v));
} else {
formData.append(key, value);
}
}
return new Request('http://localhost/checkout', { method: 'POST', body: formData });
}
describe('checkout action', () => {
const mockUser = { id: 'user-1' };
beforeEach(() => {
vi.mocked(requireUser).mockResolvedValue(mockUser as any);
vi.mocked(db.products.findMany).mockResolvedValue([
{ id: 'p1', price: 10 },
{ id: 'p2', price: 20 },
] as any);
vi.mocked(processPayment).mockResolvedValue({ success: true, transactionId: 'tx-123' });
vi.mocked(db.orders.create).mockResolvedValue({ id: 'order-1' } as any);
});
it('returns 422 when cart is empty', async () => {
const res = await action({
request: makeCheckoutRequest({ paymentMethod: 'card', shippingAddress: '123 Main St' }),
params: {},
context: {},
});
const data = await res.json();
expect(res.status).toBe(422);
expect(data.error).toMatch(/cart is empty/i);
});
it('returns 422 when payment method is missing', async () => {
const res = await action({
request: makeCheckoutRequest({ itemId: ['p1'], shippingAddress: '123 Main St' }),
params: {},
context: {},
});
expect(res.status).toBe(422);
});
it('returns 422 when payment fails', async () => {
vi.mocked(processPayment).mockResolvedValue({ success: false, transactionId: '' });
const res = await action({
request: makeCheckoutRequest({
itemId: ['p1', 'p2'],
paymentMethod: 'card',
shippingAddress: '123 Main St',
}),
params: {},
context: {},
});
expect(res.status).toBe(422);
const data = await res.json();
expect(data.error).toMatch(/payment failed/i);
});
it('redirects to confirmation page on success', async () => {
const res = await action({
request: makeCheckoutRequest({
itemId: ['p1', 'p2'],
paymentMethod: 'card',
shippingAddress: '123 Main St',
}),
params: {},
context: {},
});
expect(res.status).toBe(302);
expect(res.headers.get('Location')).toBe('/orders/order-1/confirmation');
});
it('does not process payment when item validation fails', async () => {
// findMany returns fewer items than requested → some are unavailable
vi.mocked(db.products.findMany).mockResolvedValue([{ id: 'p1', price: 10 }] as any);
await action({
request: makeCheckoutRequest({
itemId: ['p1', 'p2'],
paymentMethod: 'card',
shippingAddress: '123 Main St',
}),
params: {},
context: {},
});
expect(processPayment).not.toHaveBeenCalled();
});
});Asserting Redirects
Remix uses thrown Response objects for redirects. Test them with .rejects:
// Test that a redirect happens and check the destination
await expect(
loader({ request, params: {}, context: {} })
).rejects.toSatisfy((r: Response) => {
return r.status === 302 && r.headers.get('Location') === '/login';
});
// Shorthand using toMatchObject on the error
await expect(action({ request, params: {}, context: {} }))
.rejects.toMatchObject({ status: 302 });Production Monitoring with HelpMeTest
Loader and action tests catch bugs in your server logic. After deployment, verify the integration holds together.
HelpMeTest runs tests against your live Remix app:
Go to https://myapp.com/login
Fill in email with test@example.com
Fill in password with testpassword123
Click Log in
Verify the URL is /dashboard
Verify the username is visibleFree tier: 10 tests, 5-minute monitoring intervals.
Pro: $100/month — unlimited tests, 24/7 monitoring.
Start free at helpmetest.com — no credit card required.