Skip to content

test

The test package provides a built-in test runner for Remix applications. It offers a familiar describe/it API, lifecycle hooks, mock functions, and spies. No additional test framework is needed.

Installation

The test package is included with Remix. No additional installation is needed.

Import

ts
import {
  describe,
  it,
  suite,
  before,
  after,
  beforeEach,
  afterEach,
  beforeAll,
  afterAll,
} from 'remix/test'

API

describe(name, fn) / suite(name, fn)

Groups related tests. Can be nested to any depth. suite is an alias for describe.

ts
describe('User', () => {
  describe('validation', () => {
    it('requires an email', (t) => {
      // ...
    })
  })
})

it(name, fn)

Defines a single test case. The callback receives a test context t with assertion and mocking utilities.

ts
it('adds two numbers', (t) => {
  t.assert.equal(1 + 1, 2)
})

Async tests are supported by returning a promise:

ts
it('fetches a user', async (t) => {
  let user = await getUser('123')
  t.assert.ok(user)
  t.assert.equal(user.name, 'Alice')
})

Test Context (t)

Every test callback receives a context object t with the following properties:

PropertyDescription
t.assertAssertion object with equal, deepEqual, ok, throws, etc.
t.mockCreate mock functions.
t.spyOnSpy on methods of an object.

Lifecycle Hooks

Hooks run at specific points in the test lifecycle. They can be placed inside describe blocks or at the top level.

before(fn) / beforeAll(fn)

Runs once before all tests in the current describe block.

ts
describe('database', () => {
  before(async () => {
    await db.migrate()
  })

  it('queries data', async (t) => {
    // database is migrated
  })
})

after(fn) / afterAll(fn)

Runs once after all tests in the current describe block.

ts
after(async () => {
  await db.close()
})

beforeEach(fn)

Runs before each test in the current describe block.

ts
beforeEach(async () => {
  await db.truncate('users')
})

afterEach(fn)

Runs after each test in the current describe block.

ts
afterEach(() => {
  cleanup()
})

Assertions

The t.assert object provides assertion methods compatible with the assert package:

ts
it('assertions', (t) => {
  t.assert.ok(true)
  t.assert.equal(1 + 1, 2)
  t.assert.notEqual(1, 2)
  t.assert.strictEqual('a', 'a')
  t.assert.deepEqual([1, 2], [1, 2])
  t.assert.throws(() => { throw new Error() })
  t.assert.match('hello', /ell/)
})

Mocking

t.mock(fn?)

Creates a mock function that records its calls. Optionally wraps an existing function.

ts
it('calls the handler', (t) => {
  let handler = t.mock()

  emitter.on('event', handler)
  emitter.emit('event', 'data')

  t.assert.equal(handler.calls.length, 1)
  t.assert.deepEqual(handler.calls[0].arguments, ['data'])
})

With an implementation:

ts
let fetch = t.mock(async (url: string) => {
  return new Response('mocked')
})

let response = await fetch('/api')
t.assert.equal(await response.text(), 'mocked')
t.assert.equal(fetch.calls.length, 1)

Mock Properties

PropertyTypeDescription
callsCall[]Array of recorded calls. Each has arguments and result.
calls.lengthnumberNumber of times the mock was called.
reset()() => voidClears recorded calls.

t.spyOn(object, method)

Creates a spy on an existing method. The original method still executes, but calls are recorded.

ts
it('logs requests', (t) => {
  let spy = t.spyOn(console, 'log')

  console.log('hello')

  t.assert.equal(spy.calls.length, 1)
  t.assert.deepEqual(spy.calls[0].arguments, ['hello'])
})

Replace the implementation:

ts
let spy = t.spyOn(Date, 'now')
spy.mockImplementation(() => 1704067200000)

t.assert.equal(Date.now(), 1704067200000)

Spies are automatically restored after the test completes.

Configuration

Configure the test runner by creating a remix-test.config.ts file in your project root:

ts
// remix-test.config.ts
import { defineConfig } from 'remix/test'

export default defineConfig({
  // Glob patterns for test files
  include: ['app/**/*.test.ts'],

  // Glob patterns to exclude
  exclude: ['node_modules/**'],

  // Number of test files to run in parallel
  concurrency: 4,

  // Test timeout in milliseconds
  timeout: 5000,

  // Reporter: 'dot', 'spec', or 'json'
  reporter: 'spec',
})

CLI

Run tests from the command line:

bash
# Run all tests
npx remix test

# Run specific files
npx remix test app/routes/user.test.ts

# Watch mode -- re-run on file changes
npx remix test --watch

# Set concurrency
npx remix test --concurrency 8

# Choose a reporter
npx remix test --reporter dot

CLI Options

OptionDescription
--watchRe-run tests when files change.
--concurrency <n>Number of test files to run in parallel.
--reporter <name>Output format: spec, dot, or json.
--timeout <ms>Override the default test timeout.
--bailStop after the first test failure.

Examples

Testing a Route Handler

ts
import { describe, it } from 'remix/test'

describe('GET /users/:id', () => {
  it('returns a user', async (t) => {
    let request = new Request('http://localhost/users/123')
    let response = await handler(request)

    t.assert.equal(response.status, 200)

    let user = await response.json()
    t.assert.equal(user.id, '123')
    t.assert.ok(user.name)
  })

  it('returns 404 for unknown users', async (t) => {
    let request = new Request('http://localhost/users/unknown')
    let response = await handler(request)

    t.assert.equal(response.status, 404)
  })
})

Mocking a Database

ts
import { describe, it, beforeEach } from 'remix/test'

describe('createUser', () => {
  beforeEach(async () => {
    await db.truncate('users')
  })

  it('inserts a user', async (t) => {
    let user = await createUser({ name: 'Alice', email: 'alice@example.com' })

    t.assert.ok(user.id)
    t.assert.equal(user.name, 'Alice')
  })

  it('rejects duplicate emails', async (t) => {
    await createUser({ name: 'Alice', email: 'alice@example.com' })

    await t.assert.rejects(
      () => createUser({ name: 'Bob', email: 'alice@example.com' }),
      /duplicate/,
    )
  })
})

Spying on External Services

ts
import { describe, it } from 'remix/test'

describe('sendWelcomeEmail', () => {
  it('sends an email to the user', async (t) => {
    let spy = t.spyOn(emailService, 'send')
    spy.mockImplementation(async () => ({ success: true }))

    await sendWelcomeEmail('alice@example.com')

    t.assert.equal(spy.calls.length, 1)
    t.assert.equal(spy.calls[0].arguments[0], 'alice@example.com')
  })
})
  • assert --- Cross-runtime assertion utilities.
  • Testing Guide --- End-to-end guide to testing Remix applications.

Released under the MIT License.