Package

remix/test/cli

A test framework for JavaScript and TypeScript projects.

Features

  • describe/it test structure with before/after/beforeEach/afterEach hooks
  • Server-side unit testing
  • Playwright E2E testing via t.serve
  • In-browser component testing (pair with render from remix/ui/test)
  • Mock functions and method spies via t.mock.fn / t.mock.method
  • Per-test and hook timeouts with t.signal abort support
  • Unified code coverage reporting across unit and E2E tests
  • Watch mode
  • Config file support (remix-test.config.ts)

Installation

npm i remix

Usage

Write test files that import from remix/test:

import * as assert from 'remix/assert'
import { describe, it } from 'remix/test'

describe('My Test Suite', () => {
  it('tests a function', () => {
    let result = something()
    assert.equal(result, 42)
  })
})

Run tests with the CLI:

remix test

By default, remix test discovers all files matching **/*.test{,.e2e}.{ts,tsx}. Pass one or more globs as positional arguments to override:

remix test "src/**/*.test.ts"
remix test "src/**/*.test.ts" "tests/**/*.test.tsx"

Or, you may control via the glob.test config field/CLI arg. Each glob.* field accepts a single string or an array of patterns, and --glob.* flags can be repeated on the CLI.

Config File

Create a remix-test.config.ts (or .js) file at the root of your project (shown with default values):

import type { RemixTestConfig } from 'remix/test'

export default {
  // Browser options for E2E tests
  browser: {
    // Echo browser console output to the terminal
    echo: false,
    // Open browser (via playwright `headless:false`) and keep it open after tests
    // complete (useful for debugging)
    open: false,
  },

  // Max number of concurrent test workers (default `os.availableParallelism()`)
  concurrency: 2,

  // Pool for server and E2E test files ("forks", "threads")
  pool: 'forks',

  // Code coverage options
  coverage: {
    // Enable coverage reporting
    enabled: true,
    // Output directory (default: ".coverage")
    dir: '.coverage',
    // Glob pattern(s) to include/exclude
    include: 'src/**',
    exclude: 'src/**/*.test.ts',
    // Minimum thresholds (%)
    statements: 80,
    lines: 80,
    branches: 80,
    functions: 80,
  },

  // Glob pattern(s) identifying test files
  glob: {
    // All test files (default: "**/*.test{,.browser,.e2e}.{ts,tsx}").
    test: '**/*.test{,.browser,.e2e}.ts',
    // Browser test files (default: "**/*.test.browser.{ts,tsx}")
    browser: '**/*.test.browser.ts',
    // E2E test files (default: "**/*.test.e2e.{ts,tsx}")
    e2e: '**/*.test.e2e.ts',
  },

  // Playwright configuration for E2E tests, or string path to an existing
  // config file on disk
  playwrightConfig: {
    projects: [
      { name: 'chromium', use: { browserName: 'chromium' } },
      { name: 'firefox', use: { browserName: 'firefox' } },
    ],
    use: {
      navigationTimeout: 5_000,
      actionTimeout: 5_000,
    },
  },

  // Playwright project(s) to run E2E tests for
  project: 'chromium',

  // Test reporter ("spec", "files", "tap", "dot")
  reporter: 'spec',

  // Do not print skipped tests in reporter output
  quiet: false,

  // Path to a setup module (see Setup section below)
  setup: './test/setup.ts',

  // Test type(s) to run ("server", "browser", "e2e")
  type: ['server', 'browser', 'e2e'],

  // Watch for file changes and re-run
  watch: false,
} satisfies RemixTestConfig

CLI Options

You can point to a different config file location with the --config flag:

remix test --config ./tests/config.ts

You may also specify any config field as a CLI flag which will take precedence over config file values:

| Flag | Short | | --------------------------- | --------- | --- | | --browser.echo | | | --browser.open | | | --concurrency <n> | -c | | --coverage | | | --coverage.dir <path> | | | --coverage.include | | | --coverage.exclude | | | --coverage.statements | | | --coverage.lines | | | --coverage.branches | | | --coverage.functions | | | --glob.test | | | --glob.browser | | | --glob.e2e | | | --playwrightConfig <path> | | | --only <pattern> | | | --pool <forks | threads> | | | --project <name> | -p | | --quiet | -q | | --reporter <name> | -r | | --setup <path> | -s | | --type <name> | -t | | --watch | -w |

Focusing Tests

Use .only to focus a suite or test while developing:

describe.only('Cart routes', () => {
  it('loads cart items', () => {})
})

describe('Checkout routes', () => {
  it.only('redirects anonymous users', () => {})
})

Use --only <pattern> to focus tests from the CLI without editing source. Plain patterns are case-insensitive JavaScript regular expressions matched against suite names and full test names:

remix test --only 'Cart routes'
remix test --only 'Checkout routes > redirects anonymous users'
remix test --only '/anonymous users$/'

Slash-delimited CLI patterns preserve their flags, so /pattern/ is case-sensitive and /pattern/i is case-insensitive. Plain string patterns in remix-test.config.ts are also case-insensitive; slash-delimited strings and RegExp values preserve their flags.

Full test names join nested describe names and the test name with >. For example, describe('Cart routes', () => describe('loader', () => it('loads cart items', ...))) has the full test name Cart routes > loader > loads cart items.

Setup

The setup option points to a module that can export globalSetup and/or globalTeardown functions, called once before and after the entire test run respectively:

// ./test/setup.ts
export async function globalSetup() {
  await db.migrate()
}

export async function globalTeardown() {
  await db.close()
}

API

Test framework

import { beforeAll, afterAll, beforeEach, afterEach, describe, it } from 'remix/test'

beforeAll(() => {})
afterAll(() => {})

describe('My Test Suite', () => {
  beforeEach(() => {})
  afterEach(() => {})

  it('tests something', () => {})
  it('skips with a reason', { skip: 'requires API credentials' }, () => {})
  it('tracks planned work', { todo: 'add retry coverage' }, () => {})
  it('fails if it takes too long', { timeout: 5_000 }, async (t) => {
    await fetchSomething({ signal: t.signal })
  })
  it('tests something else', () => {})
})

suite and test are aliases for describe and it.

import { suite, test } from 'remix/test'

suite('My Test Suite', () => {
  test('tests something', () => {})
})

Programmatic runner

remix/test/cli exports runRemixTest() for tools that want to run the test runner without exiting the current process:

import { runRemixTest } from 'remix/test/cli'

let exitCode = await runRemixTest({
  argv: ['--type', 'server'],
  cwd: process.cwd(),
})

runRemixTest() returns the runner exit code. The remix test bin wrapper calls process.exit() with that code when the run finishes so open workers, browsers, or project handles cannot keep the CLI alive.

Test Context

Each test callback receives a TestContext (t) as its first argument with helpful test utilities.

// from 'remix/test'
interface TestContext {
  // Aborts when the test times out or when the user-provided test signal aborts
  signal: AbortSignal

  // Register a cleanup function to run after the test completes
  after(fn: () => void): void

  // Mock tracker, mirroring the shape of Node's `t.mock` from `node:test`
  mock: {
    // Create a mock function with an optional implementation
    fn<T extends (...args: any[]) => any>(impl?: T): MockFunction<T>

    // Mock an object method with an optional implementation override
    method<T extends object, K extends keyof T>(
      obj: T,
      methodName: K,
      impl?: Function,
    ): MockFunction
  }

  // Replace global timer functions with controllable fakes
  useFakeTimers(): FakeTimers

  // E2E only: connect a running test server to a Playwright Page
  serve(server: { baseUrl: string; close(): Promise<void> }): Promise<Page>
}

Mocks and Spies

Use t.mock.fn()/t.mock.method() to set up mocks and method spies. This is preferred over the standalone mock import because TestContext method mocks are automatically restored after the test runs.

it('mocks and spies', (t) => {
  // Create a mock function
  let fn = t.mock.fn((x: number) => x * 2)
  fn(3)
  fn.mock.calls[0].result // 6

  // Mock an existing method
  let spy = t.mock.method(console, 'warn')
  console.warn('test')
  spy.mock.calls.length // 1
  // spy is restored automatically when the test ends
})

Cleanup

You can register local test cleanup logic with t.after():

it('cleanup', (t) => {
  let conn = db.connect()
  t.after(() => conn.close())
  // ...
})

Timeouts and Signals

Pass { timeout: ms } to it() or after any lifecycle hook callback to fail that work if it takes too long. Timed-out tests abort t.signal, so async code that accepts an AbortSignal can cancel promptly.

it('loads data', { timeout: 5_000 }, async (t) => {
  let response = await fetch('/api/data', { signal: t.signal })
  assert.equal(response.status, 200)
})

beforeEach(
  async () => {
    await resetDatabase()
  },
  { timeout: 1_000 },
)

Fake Timers

t.useFakeTimers() replaces the global timer functions (setTimeout, setInterval, etc.) with controllable fakes that are automatically restored after the test. It works in any test environment — server unit tests, browser tests, or E2E setup code.

it('debounces a callback', (t) => {
  let timers = t.useFakeTimers()
  let calls = 0
  let debounced = debounce(() => calls++, 300)

  debounced()
  timers.advance(299)
  assert.equal(calls, 0)
  timers.advance(1)
  assert.equal(calls, 1)
})
Method Description
advance(ms) Advance the clock by ms milliseconds, firing any elapsed timers
restore() Restore the original timer functions (called automatically after each test)

E2E

In E2E test files, t.serve() connects a running test server to a Playwright Page. See E2E Testing for details.

import { createTestServer } from 'remix/node-fetch-server/test'

it('navigates to home', async (t) => {
  let router = createRouter()
  let server = await createTestServer(router.fetch)
  let page = await t.serve(server)
  await page.goto('/')
})

Standalone mocks (module scope)

When you need a mock outside of a test body, import mock directly and call restore() manually:

import { mock } from 'remix/test'

let spy = mock.method(console, 'log')
// ...
spy.mock.restore?.()

Browser Testing

Browser tests run components in an actual browser environment via Playwright and are discovered by the **/*.test.browser.{ts,tsx} glob pattern (configurable via glob.browser). They use the same describe/it API as unit tests. Each in-browser test suite runs in an isolated iframe so it has access to its own document instance.

render()

render, exported from remix/ui/test, mounts a component into the DOM and returns a RenderResult:

import * as assert from 'remix/assert'
import { describe, it } from 'remix/test'
import { render } from 'remix/ui/test'
import { Counter } from './counter.tsx'

describe('Counter', () => {
  it('increments on click', async (t) => {
    let { $, act, cleanup } = render(<Counter />)
    t.after(cleanup)

    assert.equal($('[data-count]')?.textContent, '0')
    await act(() => $('[data-action="increment"]')?.click())
    assert.equal($('[data-count]')?.textContent, '1')
  })
})

RenderResult provides:

Property/Method Description
container The HTMLElement the component is mounted into
root The Remix VirtualRoot the component is rendered in
$(selector) Alias for container.querySelector()
$$(selector) Alias for container.querySelectorAll()
act(fn) Runs fn and flushes pending component updates
cleanup() Unmounts and removes the container (pass to t.after for auto-cleanup)

E2E Testing

End-to-end (E2E) tests use Playwright and are discovered by the **/*.test.e2e.{ts,tsx} glob pattern (configurable via glob.e2e). They use the same describe/it API as unit tests.

E2E tests receive t.serve() on the test context, which accepts a running test server and returns a Playwright Page whose baseURL points at that server. The server and page are automatically closed after each test.

import * as assert from 'remix/assert'
import { createTestServer } from 'remix/node-fetch-server/test'
import { describe, it } from 'remix/test'
import { createRouter } from './router.ts'

describe('checkout', () => {
  it('adds an item to the cart', async (t) => {
    let router = createRouter()
    let server = await createTestServer(router.fetch)
    let page = await t.serve(server)

    await page.goto('/')
    await page.getByRole('button', { name: 'Add to Cart' }).click()
    await page.getByRole('link', { name: 'Cart' }).click()
    await page.getByRole('heading', { name: 'Shopping Cart' }).waitFor()

    assert.equal(await page.locator('[data-test-cart-quantity]').innerText(), 1)
  })
})

Configure Playwright (browsers, timeouts, viewport, etc.) via playwrightConfig in your config file:

export default {
  playwrightConfig: {
    projects: [
      { name: 'chromium', use: { browserName: 'chromium' } },
      { name: 'firefox', use: { browserName: 'firefox' } },
      { name: 'webkit', use: { browserName: 'webkit' } },
    ],
    use: {
      navigationTimeout: 5_000,
      actionTimeout: 5_000,
    },
  },

  // Or, point to an existing playwright config file
  // playwrightConfig: './playwright.config.ts'
} satisfies RemixTestConfig

Set browser.open: true to keep the browser open after tests finish — useful for debugging failures.

License

See LICENSE