test
A test framework for JavaScript and TypeScript projects.
Features
describe/ittest structure withbefore/after/beforeEach/afterEachhooks- Server-side unit testing
- Playwright E2E testing via
t.serve - In-browser component testing (pair with
renderfromremix/ui/test) - Mock functions and method spies via
t.mock.fn/t.mock.method - Unified code coverage reporting across unit and E2E tests
- Watch mode
- Config file support (
remix-test.config.ts)
Installation
npm i remixUsage
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 testBy 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',
// 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 RemixTestConfigCLI Options
You can point to a different config file location with the --config flag:
remix test --config ./tests/config.tsYou 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> | |
| --pool <forks | threads> | |
| --project <name> | -p |
| --reporter <name> | -r |
| --setup <path> | -s |
| --type <name> | -t |
| --watch | -w |
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('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 {
// 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())
// ...
})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 RemixTestConfigSet browser.open: true to keep the browser open after tests finish — useful for debugging failures.
License
See LICENSE