--- name: "Remix Testing" description: "Testing patterns for Remix applications covering loader testing, action testing, route testing, form submission testing, and nested route integration testing." version: 1.0.0 author: thetestingacademy license: MIT tags: [remix, loaders, actions, forms, nested-routes, error-boundaries, defer, streaming, sessions, msw] testingTypes: [unit, integration, e2e] frameworks: [vitest, playwright, jest] languages: [typescript, javascript] domains: [web, api] agents: [claude-code, cursor, github-copilot, windsurf, codex, aider, continue, cline, zed, bolt, gemini-cli, amp] --- # Remix Testing You are an expert QA engineer specializing in Remix application testing patterns. When the user asks you to write, review, debug, or set up tests for Remix applications -- including loaders, actions, routes, forms, error boundaries, deferred data, sessions, and cookies -- follow these detailed instructions. You understand Remix's server-first architecture, nested routing, progressive enhancement, and the boundary between server and client concerns. ## Core Principles 1. **Loader/Action Isolation** -- Loaders and actions are plain async functions that receive a Request and return a Response. Test them as pure functions without rendering any UI. This is the most valuable layer of Remix testing. 2. **Progressive Enhancement Testing** -- Remix forms work without JavaScript. Test form submissions both with and without client-side JavaScript to ensure progressive enhancement is not broken. 3. **Nested Route Testing** -- Remix's nested routing means parent loaders run in parallel with child loaders. Test that parent and child data loading work correctly together, including error boundary cascading. 4. **Request/Response Fidelity** -- Loaders and actions use standard Web Request and Response objects. Create realistic request objects in tests with proper headers, cookies, and form data. 5. **Error Boundary Coverage** -- Every route should have error boundary tests. Test both expected errors (404, 403) and unexpected errors (thrown exceptions) to verify the correct boundary catches each error. 6. **Session Testing** -- Remix sessions are cookie-based by default. Test session creation, reading, updating, and destruction with proper cookie handling in test requests. 7. **Type-Safe Testing** -- Leverage Remix's TypeScript-first design. Use `LoaderFunctionArgs` and `ActionFunctionArgs` types in tests to ensure request construction matches the real runtime. ## When to Use This Skill - When testing Remix loader functions that fetch and return data - When testing Remix action functions that handle form submissions - When testing route modules as complete units (loader + action + component) - When testing form submissions with and without JavaScript - When testing nested route data flow and error boundary cascading - When testing deferred data loading with `defer` and `Await` - When testing session and cookie management - When writing Playwright E2E tests for full Remix application flows ## Project Structure ``` project-root/ ├── app/ │ ├── routes/ │ │ ├── _index.tsx # Homepage route │ │ ├── login.tsx # Login route (action + loader) │ │ ├── dashboard.tsx # Dashboard layout route │ │ ├── dashboard._index.tsx # Dashboard index │ │ ├── dashboard.posts.tsx # Posts list route │ │ ├── dashboard.posts.$id.tsx # Post detail route │ │ ├── dashboard.posts.new.tsx # Create post route │ │ ├── dashboard.settings.tsx # Settings route │ │ └── api.posts.tsx # Resource route (API) │ ├── components/ │ │ ├── PostForm.tsx # Post form component │ │ ├── PostCard.tsx # Post card component │ │ └── ErrorFallback.tsx # Error boundary component │ ├── lib/ │ │ ├── db.server.ts # Database client (server-only) │ │ ├── auth.server.ts # Auth utilities (server-only) │ │ ├── session.server.ts # Session management │ │ └── validation.ts # Form validation schemas │ ├── root.tsx # Root route │ └── entry.server.tsx # Server entry │ ├── tests/ │ ├── setup/ │ │ ├── test-utils.tsx # Test wrapper utilities │ │ ├── request-helpers.ts # Request construction helpers │ │ ├── session-helpers.ts # Session test helpers │ │ ├── msw-handlers.ts # MSW handlers │ │ └── msw-server.ts # MSW server setup │ ├── unit/ │ │ ├── loaders/ │ │ │ ├── index.test.ts # Homepage loader tests │ │ │ ├── login.test.ts # Login loader tests │ │ │ ├── dashboard.test.ts # Dashboard loader tests │ │ │ └── posts.test.ts # Posts loader tests │ │ ├── actions/ │ │ │ ├── login.test.ts # Login action tests │ │ │ ├── create-post.test.ts # Create post action tests │ │ │ └── settings.test.ts # Settings action tests │ │ └── validation/ │ │ └── forms.test.ts # Form validation tests │ ├── integration/ │ │ ├── routes/ │ │ │ ├── login.test.tsx # Login route integration │ │ │ ├── dashboard.test.tsx # Dashboard route integration │ │ │ └── posts.test.tsx # Posts route integration │ │ ├── error-boundaries.test.tsx # Error boundary tests │ │ ├── sessions.test.ts # Session management tests │ │ └── nested-routes.test.tsx # Nested route tests │ ├── e2e/ │ │ ├── auth.spec.ts # Auth flow E2E │ │ ├── posts.spec.ts # Post CRUD E2E │ │ ├── forms.spec.ts # Form submission E2E │ │ └── navigation.spec.ts # Navigation E2E │ └── vitest.config.ts │ ├── playwright.config.ts ├── vite.config.ts └── package.json ``` ## Source Code ### Session Management ```typescript // app/lib/session.server.ts import { createCookieSessionStorage, redirect } from '@remix-run/node'; const sessionSecret = process.env.SESSION_SECRET; if (!sessionSecret) throw new Error('SESSION_SECRET must be set'); const storage = createCookieSessionStorage({ cookie: { name: '__session', httpOnly: true, maxAge: 60 * 60 * 24 * 30, // 30 days path: '/', sameSite: 'lax', secrets: [sessionSecret], secure: process.env.NODE_ENV === 'production', }, }); export async function createUserSession(userId: string, redirectTo: string) { const session = await storage.getSession(); session.set('userId', userId); return redirect(redirectTo, { headers: { 'Set-Cookie': await storage.commitSession(session), }, }); } export async function getUserSession(request: Request) { return storage.getSession(request.headers.get('Cookie')); } export async function getUserId(request: Request): Promise { const session = await getUserSession(request); const userId = session.get('userId'); if (!userId || typeof userId !== 'string') return null; return userId; } export async function requireUserId(request: Request): Promise { const userId = await getUserId(request); if (!userId) { throw redirect('/login'); } return userId; } export async function destroySession(request: Request) { const session = await getUserSession(request); return redirect('/login', { headers: { 'Set-Cookie': await storage.destroySession(session), }, }); } ``` ### Login Route ```typescript // app/routes/login.tsx import type { LoaderFunctionArgs, ActionFunctionArgs } from '@remix-run/node'; import { json, redirect } from '@remix-run/node'; import { useActionData, useLoaderData, Form, useNavigation } from '@remix-run/react'; import { getUserId, createUserSession } from '~/lib/session.server'; import { validateLogin } from '~/lib/auth.server'; import { z } from 'zod'; const loginSchema = z.object({ email: z.string().email('Invalid email address'), password: z.string().min(8, 'Password must be at least 8 characters'), redirectTo: z.string().optional().default('/dashboard'), }); export async function loader({ request }: LoaderFunctionArgs) { const userId = await getUserId(request); if (userId) return redirect('/dashboard'); const url = new URL(request.url); const redirectTo = url.searchParams.get('redirectTo') || '/dashboard'; return json({ redirectTo }); } export async function action({ request }: ActionFunctionArgs) { const formData = await request.formData(); const rawData = Object.fromEntries(formData); const result = loginSchema.safeParse(rawData); if (!result.success) { return json( { errors: result.error.flatten().fieldErrors, values: rawData }, { status: 400 }, ); } const { email, password, redirectTo } = result.data; const user = await validateLogin(email, password); if (!user) { return json( { errors: { email: ['Invalid email or password'], password: [] }, values: { email, redirectTo }, }, { status: 401 }, ); } return createUserSession(user.id, redirectTo); } export default function LoginRoute() { const { redirectTo } = useLoaderData(); const actionData = useActionData(); const navigation = useNavigation(); const isSubmitting = navigation.state === 'submitting'; return (

Login

{actionData?.errors?.email && (

{actionData.errors.email[0]}

)}
{actionData?.errors?.password && (

{actionData.errors.password[0]}

)}
); } export function ErrorBoundary() { return (

Login Error

Something went wrong. Please try again.

); } ``` ### Posts Routes ```typescript // app/routes/dashboard.posts.tsx import type { LoaderFunctionArgs } from '@remix-run/node'; import { json } from '@remix-run/node'; import { useLoaderData, Outlet, Link } from '@remix-run/react'; import { requireUserId } from '~/lib/session.server'; import { getPosts } from '~/lib/db.server'; export async function loader({ request }: LoaderFunctionArgs) { const userId = await requireUserId(request); const url = new URL(request.url); const page = Number(url.searchParams.get('page') || '1'); const search = url.searchParams.get('search') || ''; const { posts, total } = await getPosts({ userId, page, search, limit: 10 }); return json({ posts, total, page, search }); } export default function PostsRoute() { const { posts, total, page, search } = useLoaderData(); return (

Posts ({total})

New Post
{posts.map((post) => (

{post.title}

{post.excerpt}

{post.published ? 'Published' : 'Draft'} ))}
); } ``` ```typescript // app/routes/dashboard.posts.new.tsx import type { ActionFunctionArgs } from '@remix-run/node'; import { json, redirect } from '@remix-run/node'; import { useActionData, Form, useNavigation } from '@remix-run/react'; import { requireUserId } from '~/lib/session.server'; import { createPost } from '~/lib/db.server'; import { z } from 'zod'; const postSchema = z.object({ title: z.string().min(1, 'Title is required').max(200, 'Title too long'), content: z.string().min(10, 'Content must be at least 10 characters'), published: z.coerce.boolean().optional().default(false), }); export async function action({ request }: ActionFunctionArgs) { const userId = await requireUserId(request); const formData = await request.formData(); const rawData = Object.fromEntries(formData); const result = postSchema.safeParse(rawData); if (!result.success) { return json( { errors: result.error.flatten().fieldErrors, values: rawData }, { status: 400 }, ); } const post = await createPost({ ...result.data, authorId: userId, }); return redirect(`/dashboard/posts/${post.id}`); } export default function NewPostRoute() { const actionData = useActionData(); const navigation = useNavigation(); const isSubmitting = navigation.state === 'submitting'; return (

Create New Post

{actionData?.errors?.title && (

{actionData.errors.title[0]}

)}