Remix E2E Testing with Playwright: Full Stack Scenarios (2026)

Remix E2E Testing with Playwright: Full Stack Scenarios (2026)

Remix's file-based routing, nested routes, and server-side rendering require E2E tests that exercise the full stack. Playwright is the right tool: it runs tests in real browsers, handles JavaScript-rendered content, and integrates cleanly with Remix's dev server.

This guide covers Playwright setup for Remix, writing tests for real scenarios, handling auth, and testing Remix-specific patterns like nested routes and progressive enhancement.

Installing and Configuring Playwright

npm install -D @playwright/test
npx playwright install chromium
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: 'html',

  use: {
    baseURL: process.env.BASE_URL ?? 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },

  projects: [
    { name: 'setup', testMatch: /.*\.setup\.ts/ },
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
      dependencies: ['setup'],
    },
  ],

  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
    timeout: 30 * 1000,
  },
});

Authentication Setup

Most Remix apps require login. Log in once per test run and save the session state.

// e2e/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
import path from 'path';

const authFile = path.join(__dirname, '.auth/user.json');

setup('authenticate as test user', async ({ page }) => {
  await page.goto('/login');

  await page.getByLabel('Email').fill('test@example.com');
  await page.getByLabel('Password').fill('testpassword123');
  await page.getByRole('button', { name: /sign in/i }).click();

  // Wait for redirect to dashboard
  await page.waitForURL('/dashboard');
  await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible();

  // Save authentication state
  await page.context().storageState({ path: authFile });
});

Reference it from playwright.config.ts:

projects: [
  { name: 'setup', testMatch: /.*\.setup\.ts/ },
  {
    name: 'chromium',
    use: {
      ...devices['Desktop Chrome'],
      storageState: 'e2e/.auth/user.json',
    },
    dependencies: ['setup'],
  },
],

Tests in the chromium project start already authenticated.

Testing Remix Nested Routes

Remix's nested routing is one of its distinguishing features. E2E tests verify that nested outlets render correctly and that parent data passes down properly.

// e2e/dashboard.test.ts
import { test, expect } from '@playwright/test';

test.describe('Dashboard nested routes', () => {
  test('shows overview by default', async ({ page }) => {
    await page.goto('/dashboard');

    await expect(page.getByRole('heading', { name: /overview/i })).toBeVisible();
    await expect(page.getByTestId('recent-activity')).toBeVisible();
  });

  test('navigates to settings subroute', async ({ page }) => {
    await page.goto('/dashboard');

    await page.getByRole('link', { name: /settings/i }).click();

    await expect(page).toHaveURL('/dashboard/settings');
    await expect(page.getByRole('heading', { name: /settings/i })).toBeVisible();
  });

  test('settings changes persist after navigation', async ({ page }) => {
    await page.goto('/dashboard/settings');

    await page.getByLabel('Display name').fill('Updated Name');
    await page.getByRole('button', { name: /save/i }).click();

    await expect(page.getByText(/saved/i)).toBeVisible();

    // Navigate away and back
    await page.goto('/dashboard');
    await page.goto('/dashboard/settings');

    await expect(page.getByLabel('Display name')).toHaveValue('Updated Name');
  });

  test('parent navigation persists across subroutes', async ({ page }) => {
    await page.goto('/dashboard');

    // The sidebar (from parent route) should always be visible
    const sidebar = page.getByRole('navigation', { name: /dashboard/i });
    await expect(sidebar).toBeVisible();

    await page.getByRole('link', { name: /settings/i }).click();

    // Sidebar still visible on subroute
    await expect(sidebar).toBeVisible();
  });
});

Testing Form Submissions

Remix forms with progressive enhancement need E2E tests for both JavaScript-enabled and JavaScript-disabled scenarios.

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

test.describe('Create post form', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/posts/new');
  });

  test('shows validation errors for empty submission', async ({ page }) => {
    await page.getByRole('button', { name: /publish/i }).click();

    await expect(page.getByText(/title is required/i)).toBeVisible();
    await expect(page.getByText(/content is required/i)).toBeVisible();
  });

  test('creates post and redirects on valid submission', async ({ page }) => {
    await page.getByLabel('Title').fill('My Test Post');
    await page.getByLabel('Content').fill(
      'This is a complete article with enough content to pass validation. It covers the topic thoroughly.'
    );
    await page.getByRole('button', { name: /publish/i }).click();

    await expect(page).toHaveURL(/\/posts\/.+/);
    await expect(page.getByRole('heading', { name: 'My Test Post' })).toBeVisible();
  });

  test('preserves form values after validation failure', async ({ page }) => {
    await page.getByLabel('Title').fill('My Draft Title');
    await page.getByRole('button', { name: /publish/i }).click();

    // Title should be preserved even after the form error
    await expect(page.getByLabel('Title')).toHaveValue('My Draft Title');
  });

  test('works without JavaScript (progressive enhancement)', async ({ browser }) => {
    const context = await browser.newContext({ javaScriptEnabled: false });
    const page = await context.newPage();

    await page.goto('/posts/new');
    await page.getByLabel('Title').fill('No-JS Post');
    await page.getByLabel('Content').fill(
      'Content for the no-JavaScript test scenario — long enough to pass validation requirements.'
    );
    await page.getByRole('button', { name: /publish/i }).click();

    // Should redirect even without JS (server-side action)
    await expect(page).toHaveURL(/\/posts\/.+/);

    await context.close();
  });
});

Testing Optimistic UI

Remix's useFetcher hook enables optimistic UI updates. Test that the UI updates immediately and handles failures gracefully.

// e2e/todos.test.ts
import { test, expect } from '@playwright/test';

test.describe('Todo list with optimistic updates', () => {
  test('item appears immediately when added', async ({ page }) => {
    await page.goto('/todos');

    await page.getByLabel('New todo').fill('Buy groceries');
    await page.getByRole('button', { name: /add/i }).click();

    // Should appear immediately without waiting for server round-trip
    await expect(page.getByText('Buy groceries')).toBeVisible();
  });

  test('completed state toggles immediately', async ({ page }) => {
    await page.goto('/todos');

    const firstTodo = page.getByRole('listitem').first();
    const checkbox = firstTodo.getByRole('checkbox');

    await checkbox.click();

    // Immediate visual feedback
    await expect(checkbox).toBeChecked();
    await expect(firstTodo).toHaveClass(/completed/);
  });

  test('deletion removes item from list immediately', async ({ page }) => {
    await page.goto('/todos');

    const firstTodo = page.getByRole('listitem').first();
    const itemText = await firstTodo.textContent();

    await firstTodo.getByRole('button', { name: /delete/i }).click();

    await expect(page.getByText(itemText!.trim())).not.toBeVisible();
  });
});

Testing Error Boundaries

Remix provides built-in error boundaries. Test that they render correctly when loaders or actions throw.

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

test.describe('Error boundaries', () => {
  test('shows 404 page for nonexistent resource', async ({ page }) => {
    await page.goto('/posts/this-post-absolutely-does-not-exist-in-the-database');

    await expect(page.getByText(/not found/i)).toBeVisible();
    // Navigation should still work — the parent layout is intact
    await expect(page.getByRole('navigation')).toBeVisible();
  });

  test('shows error boundary when server errors occur', async ({ page }) => {
    // This assumes /api/force-error exists for testing
    await page.goto('/force-server-error');

    await expect(page.getByRole('alert')).toBeVisible();
    // Should show a useful message, not a blank page
    await expect(page.getByText(/something went wrong/i)).toBeVisible();
  });

  test('can navigate away from error page', async ({ page }) => {
    await page.goto('/posts/nonexistent-post-slug');
    await page.getByRole('link', { name: /back to posts/i }).click();

    await expect(page).toHaveURL('/posts');
  });
});

Testing with Intercepted Network Requests

For tests that depend on external APIs, intercept requests to return consistent data:

// e2e/payments.test.ts
import { test, expect } from '@playwright/test';

test('checkout succeeds with valid payment', async ({ page }) => {
  // Intercept the payment processor call
  await page.route('**/api/payments/process', async (route) => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({ success: true, transactionId: 'test-tx-123' }),
    });
  });

  await page.goto('/cart');
  await page.getByRole('button', { name: /checkout/i }).click();
  await page.getByLabel('Card number').fill('4242424242424242');
  await page.getByLabel('Expiry').fill('12/28');
  await page.getByLabel('CVV').fill('123');
  await page.getByRole('button', { name: /pay now/i }).click();

  await expect(page).toHaveURL(/\/orders\/.+\/confirmation/);
  await expect(page.getByText(/order confirmed/i)).toBeVisible();
});

CI Integration

# .github/workflows/e2e.yml
name: E2E Tests

on:
  push:
    branches: [main]
  pull_request:

jobs:
  e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm

      - run: npm ci

      - name: Install Playwright browsers
        run: npx playwright install --with-deps chromium

      - name: Run E2E tests
        run: npx playwright test
        env:
          DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}
          SESSION_SECRET: test-session-secret-for-ci

      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 7

Production Monitoring with HelpMeTest

E2E tests run in CI against your local build. After deployment to production, different things can break:

  • Production environment variables may differ from CI
  • A database migration may not have run yet
  • CDN caching may serve stale HTML after deployment
  • Third-party services like payment processors behave differently in production

HelpMeTest monitors your live Remix app continuously with plain-English tests:

Go to https://myapp.com/posts
Verify a list of post headings is visible
Click the first heading
Verify the post content is visible
Verify the author name is visible

When your Remix app breaks in production, HelpMeTest catches it within minutes.

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


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

Read more