Testing Remix Forms: Validation, Error States and Submissions (2026)

Testing Remix Forms: Validation, Error States and Submissions (2026)

Forms in Remix work differently from most React frameworks. Remix forms submit to server actions, which return structured data or throw redirects. Testing this pattern requires understanding both sides: the action logic and the component that displays action errors.

This guide covers everything you need to test Remix forms thoroughly: action validation, error display components, useFetcher forms, and integration with Conform (the standard validation library for Remix).

How Remix Forms Work

A Remix form has two parts:

  1. The action — a server function that validates input, processes it, and returns errors or redirects
  2. The route component — reads action data with useActionData and displays errors

Test them separately:

  • Action tests: fast, pure TypeScript, no rendering needed
  • Component tests: use @remix-run/testing + React Testing Library to render with fake action data

Testing the Action Side

The action validates form data and returns either json({ errors }) for validation failures or a redirect for success.

// app/routes/signup.tsx
import { json, redirect } from '@remix-run/node';
import type { ActionFunctionArgs } from '@remix-run/node';
import { z } from 'zod';
import { db } from '~/db.server';
import { hashPassword, createSession } from '~/auth.server';

const SignupSchema = z.object({
  email: z.string().email('Valid email address required'),
  username: z
    .string()
    .min(3, 'Username must be at least 3 characters')
    .max(20, 'Username cannot exceed 20 characters')
    .regex(/^[a-z0-9_-]+$/, 'Username can only contain lowercase letters, numbers, - and _'),
  password: z.string().min(8, 'Password must be at least 8 characters'),
  confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
  message: 'Passwords do not match',
  path: ['confirmPassword'],
});

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const raw = Object.fromEntries(formData);

  const result = SignupSchema.safeParse(raw);

  if (!result.success) {
    return json(
      {
        errors: result.error.flatten().fieldErrors,
        values: { email: raw.email, username: raw.username },
      },
      { status: 422 }
    );
  }

  const { email, username, password } = result.data;

  const [existingEmail, existingUsername] = await Promise.all([
    db.users.findUnique({ where: { email } }),
    db.users.findUnique({ where: { username } }),
  ]);

  if (existingEmail) {
    return json(
      { errors: { email: ['This email is already registered'] }, values: { email, username } },
      { status: 422 }
    );
  }

  if (existingUsername) {
    return json(
      { errors: { username: ['This username is taken'] }, values: { email, username } },
      { status: 422 }
    );
  }

  const user = await db.users.create({
    data: { email, username, password: await hashPassword(password) },
  });

  const session = await createSession(user.id);
  return redirect('/welcome', {
    headers: { 'Set-Cookie': session },
  });
}
// app/routes/signup.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { action } from './signup';

vi.mock('~/db.server', () => ({
  db: { users: { findUnique: vi.fn(), create: vi.fn() } },
}));
vi.mock('~/auth.server', () => ({
  hashPassword: vi.fn().mockResolvedValue('hashed'),
  createSession: vi.fn().mockResolvedValue('session=token; HttpOnly'),
}));

import { db } from '~/db.server';

function makeSignupRequest(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/signup', { method: 'POST', body: formData });
}

const validData = {
  email: 'ada@example.com',
  username: 'ada_lovelace',
  password: 'securepassword',
  confirmPassword: 'securepassword',
};

describe('signup action', () => {
  beforeEach(() => {
    vi.mocked(db.users.findUnique).mockResolvedValue(null);
    vi.mocked(db.users.create).mockResolvedValue({ id: 'new-user-id' } as any);
  });

  describe('email validation', () => {
    it('returns error for missing email', async () => {
      const { email: _, ...noEmail } = validData;
      const res = await action({ request: makeSignupRequest(noEmail), params: {}, context: {} });
      const data = await res.json();
      expect(data.errors.email).toBeTruthy();
    });

    it('returns error for invalid email format', async () => {
      const res = await action({
        request: makeSignupRequest({ ...validData, email: 'not-an-email' }),
        params: {},
        context: {},
      });
      const data = await res.json();
      expect(data.errors.email[0]).toMatch(/valid email/i);
    });

    it('returns error when email already exists', async () => {
      vi.mocked(db.users.findUnique).mockImplementation(({ where }: any) =>
        where.email ? ({ id: 'existing' } as any) : null
      );

      const res = await action({ request: makeSignupRequest(validData), params: {}, context: {} });
      const data = await res.json();
      expect(data.errors.email[0]).toMatch(/already registered/i);
    });
  });

  describe('username validation', () => {
    it('returns error when username is too short', async () => {
      const res = await action({
        request: makeSignupRequest({ ...validData, username: 'ab' }),
        params: {},
        context: {},
      });
      const data = await res.json();
      expect(data.errors.username[0]).toMatch(/3 characters/);
    });

    it('returns error when username has invalid characters', async () => {
      const res = await action({
        request: makeSignupRequest({ ...validData, username: 'Ada Lovelace' }),
        params: {},
        context: {},
      });
      const data = await res.json();
      expect(data.errors.username[0]).toMatch(/lowercase/i);
    });

    it('returns error when username is taken', async () => {
      vi.mocked(db.users.findUnique).mockImplementation(({ where }: any) =>
        where.username ? ({ id: 'existing' } as any) : null
      );

      const res = await action({ request: makeSignupRequest(validData), params: {}, context: {} });
      const data = await res.json();
      expect(data.errors.username[0]).toMatch(/taken/i);
    });
  });

  describe('password validation', () => {
    it('returns error when password is too short', async () => {
      const res = await action({
        request: makeSignupRequest({ ...validData, password: 'short', confirmPassword: 'short' }),
        params: {},
        context: {},
      });
      const data = await res.json();
      expect(data.errors.password[0]).toMatch(/8 characters/);
    });

    it('returns error when passwords do not match', async () => {
      const res = await action({
        request: makeSignupRequest({ ...validData, confirmPassword: 'different' }),
        params: {},
        context: {},
      });
      const data = await res.json();
      expect(data.errors.confirmPassword[0]).toMatch(/do not match/i);
    });
  });

  describe('success path', () => {
    it('redirects to /welcome on valid submission', async () => {
      const res = await action({ request: makeSignupRequest(validData), params: {}, context: {} });
      expect(res.status).toBe(302);
      expect(res.headers.get('Location')).toBe('/welcome');
    });

    it('sets session cookie on success', async () => {
      const res = await action({ request: makeSignupRequest(validData), params: {}, context: {} });
      expect(res.headers.get('Set-Cookie')).toContain('session=token');
    });

    it('does not create user when validation fails', async () => {
      await action({
        request: makeSignupRequest({ ...validData, password: 'short', confirmPassword: 'short' }),
        params: {},
        context: {},
      });
      expect(db.users.create).not.toHaveBeenCalled();
    });
  });
});

Testing the Component Side (Error Display)

Use @remix-run/testing to render the form component with fake action data:

// app/routes/signup.tsx (component part)
import { Form, useActionData } from '@remix-run/react';

export default function Signup() {
  const actionData = useActionData<typeof action>();

  return (
    <Form method="post">
      <div>
        <label htmlFor="email">Email</label>
        <input id="email" name="email" type="email" defaultValue={actionData?.values?.email} />
        {actionData?.errors?.email && (
          <p role="alert" className="error">{actionData.errors.email[0]}</p>
        )}
      </div>

      <div>
        <label htmlFor="username">Username</label>
        <input id="username" name="username" defaultValue={actionData?.values?.username} />
        {actionData?.errors?.username && (
          <p role="alert" className="error">{actionData.errors.username[0]}</p>
        )}
      </div>

      <div>
        <label htmlFor="password">Password</label>
        <input id="password" name="password" type="password" />
        {actionData?.errors?.password && (
          <p role="alert" className="error">{actionData.errors.password[0]}</p>
        )}
      </div>

      <div>
        <label htmlFor="confirmPassword">Confirm Password</label>
        <input id="confirmPassword" name="confirmPassword" type="password" />
        {actionData?.errors?.confirmPassword && (
          <p role="alert" className="error">{actionData.errors.confirmPassword[0]}</p>
        )}
      </div>

      <button type="submit">Create Account</button>
    </Form>
  );
}
// app/routes/signup.component.test.tsx
import { render, screen } from '@testing-library/react';
import { createRemixStub } from '@remix-run/testing';
import { describe, it, expect } from 'vitest';
import SignupRoute from './signup';

describe('Signup component', () => {
  function renderWithActionData(actionData: unknown) {
    const RemixStub = createRemixStub([
      {
        path: '/signup',
        Component: SignupRoute,
        action: async () => actionData,
      },
    ]);
    return render(<RemixStub initialEntries={['/signup']} />);
  }

  it('renders empty form with no errors initially', async () => {
    renderWithActionData(null);

    await screen.findByLabelText('Email');
    expect(screen.queryByRole('alert')).not.toBeInTheDocument();
  });

  it('shows email error when provided in action data', async () => {
    renderWithActionData({
      errors: { email: ['Valid email address required'] },
      values: { email: 'bad-email', username: '' },
    });

    await screen.findByRole('alert');
    expect(screen.getByRole('alert')).toHaveTextContent('Valid email address required');
  });

  it('preserves field values from action data', async () => {
    renderWithActionData({
      errors: { username: ['Username is taken'] },
      values: { email: 'ada@example.com', username: 'ada_taken' },
    });

    await screen.findByLabelText('Email');
    expect(screen.getByLabelText('Email')).toHaveValue('ada@example.com');
    expect(screen.getByLabelText('Username')).toHaveValue('ada_taken');
  });
});

Testing useFetcher Forms

Fetcher forms submit without page navigation. They're used for inline edits, like/unlike buttons, and real-time updates.

// e2e/inline-edit.test.ts
import { test, expect } from '@playwright/test';

test('inline edit saves without full page reload', async ({ page }) => {
  await page.goto('/posts/my-post');

  const heading = page.getByRole('heading', { level: 1 });
  const originalTitle = await heading.textContent();

  // Double-click to enter edit mode
  await heading.dblclick();

  const editInput = page.getByRole('textbox', { name: /edit title/i });
  await editInput.clear();
  await editInput.fill('Updated Title');
  await editInput.press('Enter');

  // Should update inline without navigation
  await expect(page.getByRole('heading', { name: 'Updated Title' })).toBeVisible();
  await expect(page).toHaveURL(/\/posts\/my-post/); // URL unchanged
});

test('like button toggles without page reload', async ({ page }) => {
  await page.goto('/posts/my-post');

  const likeButton = page.getByRole('button', { name: /like/i });
  const initialCount = await page.getByTestId('like-count').textContent();

  await likeButton.click();

  // Count should increment
  const newCount = await page.getByTestId('like-count').textContent();
  expect(Number(newCount)).toBe(Number(initialCount) + 1);
  await expect(likeButton).toHaveAttribute('aria-pressed', 'true');
});

Testing Conform Integration

Conform is the recommended form validation library for Remix. Test its output the same way you'd test any action.

// app/routes/contact.tsx
import { useForm, getFormProps, getInputProps } from '@conform-to/react';
import { parseWithZod } from '@conform-to/zod';
import { Form, useActionData } from '@remix-run/react';
import { z } from 'zod';
import type { ActionFunctionArgs } from '@remix-run/node';
import { json } from '@remix-run/node';

const ContactSchema = z.object({
  email: z.string().email(),
  message: z.string().min(10),
});

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const submission = parseWithZod(formData, { schema: ContactSchema });

  if (submission.status !== 'success') {
    return json({ lastResult: submission.reply() }, { status: 422 });
  }

  // process...
  return json({ lastResult: submission.reply(), success: true });
}
// app/routes/contact.test.ts
import { describe, it, expect } from 'vitest';
import { action } from './contact';

function makeRequest(fields: Record<string, string>) {
  const fd = new FormData();
  for (const [k, v] of Object.entries(fields)) fd.append(k, v);
  return new Request('http://localhost/contact', { method: 'POST', body: fd });
}

describe('contact action (Conform)', () => {
  it('returns 422 for invalid email', async () => {
    const res = await action({
      request: makeRequest({ email: 'not-an-email', message: 'Long enough message here' }),
      params: {},
      context: {},
    });
    expect(res.status).toBe(422);
  });

  it('returns 422 when message is too short', async () => {
    const res = await action({
      request: makeRequest({ email: 'user@test.com', message: 'Short' }),
      params: {},
      context: {},
    });
    expect(res.status).toBe(422);
  });

  it('returns success for valid data', async () => {
    const res = await action({
      request: makeRequest({ email: 'user@test.com', message: 'This is a long enough message for the contact form.' }),
      params: {},
      context: {},
    });
    const data = await res.json();
    expect(data.success).toBe(true);
  });
});

Production Monitoring with HelpMeTest

Form tests in Vitest and Playwright verify your forms work on localhost. After deploying to production:

  • CSRF configuration may differ from local
  • Email confirmation flows may fail silently
  • Rate limiting may break form submission in prod but not tests
  • Session cookie settings may be misconfigured for HTTPS

HelpMeTest monitors form functionality continuously against your live app.

Free tier: 10 tests, 5-minute monitoring intervals.
Pro: $100/month
— unlimited tests, 24/7 monitoring.


Start free at helpmetest.com — no credit card required.

Read more