Skip to content

Testing

This guide covers how to test Remix V3 applications using the built-in remix/test package. Remix includes its own test runner with a familiar API -- if you have used Vitest or Jest, you will feel at home.

Getting Started

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

Your First Test

Create a test file with the .test.ts extension:

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

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

  it('handles negative numbers', (t) => {
    t.assert.equal(-1 + 1, 0)
  })
})

Run it:

bash
npx remix test

Test Structure

Tests are organized with describe and it:

  • describe(name, fn) -- Groups related tests. Can be nested.
  • it(name, fn) -- Defines a single test case. The callback receives a test context t.
ts
import { describe, it } from 'remix/test'

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

    it('requires a name', (t) => {
      // ...
    })
  })

  describe('creation', () => {
    it('creates a user with valid data', (t) => {
      // ...
    })
  })
})

Test Suites

For grouping test files into larger collections:

ts
import { suite } from 'remix/test'

let userSuite = suite('User tests', {
  files: ['./app/models/user.test.ts', './app/routes/user.test.ts'],
})

Assertions

The test context t provides an assert object with common assertion methods:

ts
it('demonstrates assertions', (t) => {
  // Equality
  t.assert.equal(1 + 1, 2)
  t.assert.notEqual(1 + 1, 3)

  // Deep equality (objects and arrays)
  t.assert.deepEqual({ a: 1, b: 2 }, { a: 1, b: 2 })
  t.assert.notDeepEqual({ a: 1 }, { a: 2 })

  // Truthiness
  t.assert.ok(true)
  t.assert.ok('non-empty string')

  // Type checks
  t.assert.equal(typeof 'hello', 'string')

  // Throws
  t.assert.throws(() => {
    throw new Error('boom')
  })

  // Async throws
  await t.assert.rejects(async () => {
    throw new Error('async boom')
  })
})

Lifecycle Hooks

Run setup and teardown code around your tests:

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

describe('database tests', () => {
  // Runs once before all tests in this describe block
  before(async () => {
    await setupTestDatabase()
  })

  // Runs once after all tests in this describe block
  after(async () => {
    await teardownTestDatabase()
  })

  // Runs before each individual test
  beforeEach(async () => {
    await clearAllTables()
  })

  // Runs after each individual test
  afterEach(async () => {
    await rollbackTransaction()
  })

  it('creates a record', async (t) => {
    // Database is clean for each test
    let user = await db.create(users, { name: 'Jane', email: 'jane@test.com' })
    t.assert.ok(user.id)
  })
})

Use transactions for test isolation

Wrap each test in a database transaction and roll it back after the test. This keeps tests isolated without the overhead of recreating tables:

ts
let tx: Transaction

beforeEach(async () => {
  tx = await db.beginTransaction()
})

afterEach(async () => {
  await tx.rollback()
})

Mock Functions

Create mock functions to track calls and control return values:

ts
it('tracks function calls', (t) => {
  let sendEmail = t.mock.fn()

  sendEmail('jane@test.com', 'Hello!')

  t.assert.equal(sendEmail.mock.calls.length, 1)
  t.assert.deepEqual(sendEmail.mock.calls[0], ['jane@test.com', 'Hello!'])
})

it('controls return values', (t) => {
  let getUser = t.mock.fn(() => ({ id: 1, name: 'Jane' }))

  let user = getUser(1)
  t.assert.equal(user.name, 'Jane')
})

it('returns different values on successive calls', (t) => {
  let random = t.mock.fn()
  random.mockReturnValueOnce(0.5)
  random.mockReturnValueOnce(0.8)

  t.assert.equal(random(), 0.5)
  t.assert.equal(random(), 0.8)
})

Spying on Methods

Use t.spyOn to spy on existing object methods without replacing them:

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

  console.log('hello')

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

  // The spy is automatically restored after the test
})

it('replaces a method temporarily', (t) => {
  let spy = t.spyOn(Date, 'now').mockReturnValue(1000000)

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

  // Automatically restored after this test
})

Testing Routes

Test route handlers by calling router.fetch() directly, without starting an HTTP server:

ts
import { describe, it } from 'remix/test'
import { createRouter } from 'remix/fetch-router'
import { route } from 'remix/fetch-router/routes'

describe('routes', () => {
  let routes = route({
    home: '/',
    greet: '/hello/:name',
  })

  let router = createRouter()

  router.map(routes.home, () => {
    return new Response('Hello, World!')
  })

  router.map(routes.greet, ({ params }) => {
    return new Response(`Hello, ${params.name}!`)
  })

  it('returns hello world for the home page', async (t) => {
    let response = await router.fetch(new Request('http://localhost/'))

    t.assert.equal(response.status, 200)
    t.assert.equal(await response.text(), 'Hello, World!')
  })

  it('greets by name', async (t) => {
    let response = await router.fetch(new Request('http://localhost/hello/Jane'))

    t.assert.equal(response.status, 200)
    t.assert.equal(await response.text(), 'Hello, Jane!')
  })

  it('returns 404 for unknown routes', async (t) => {
    let response = await router.fetch(new Request('http://localhost/unknown'))

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

Testing POST Routes

ts
it('handles form submission', async (t) => {
  let formData = new FormData()
  formData.set('name', 'Jane')
  formData.set('email', 'jane@test.com')

  let response = await router.fetch(
    new Request('http://localhost/contact', {
      method: 'POST',
      body: formData,
    }),
  )

  t.assert.equal(response.status, 302)
  t.assert.equal(response.headers.get('Location'), '/thank-you')
})

Testing JSON APIs

ts
it('returns JSON data', async (t) => {
  let response = await router.fetch(
    new Request('http://localhost/api/users', {
      headers: { Accept: 'application/json' },
    }),
  )

  t.assert.equal(response.status, 200)
  t.assert.equal(response.headers.get('Content-Type'), 'application/json')

  let data = await response.json()
  t.assert.ok(Array.isArray(data))
  t.assert.ok(data.length > 0)
})

it('creates a resource via POST', async (t) => {
  let response = await router.fetch(
    new Request('http://localhost/api/posts', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        title: 'Test Post',
        body: 'Test body',
      }),
    }),
  )

  t.assert.equal(response.status, 201)
  let post = await response.json()
  t.assert.equal(post.title, 'Test Post')
})

Testing Middleware

Test middleware by creating a router that uses it and verifying the behavior:

ts
import { describe, it } from 'remix/test'
import { createRouter } from 'remix/fetch-router'
import { route } from 'remix/fetch-router/routes'

describe('auth middleware', () => {
  it('allows authenticated requests', async (t) => {
    let routes = route({ profile: '/profile' })

    let router = createRouter({
      middleware: [
        session(sessionCookie, testSessionStorage),
        authMiddleware,
        requireLogin,
      ],
    })

    router.map(routes.profile, ({ context }) => {
      let user = context.get(Auth)
      return new Response(`Hello, ${user.name}`)
    })

    // Create a request with a valid session cookie
    let response = await router.fetch(
      new Request('http://localhost/profile', {
        headers: { Cookie: await createTestSessionCookie({ userId: 1 }) },
      }),
    )

    t.assert.equal(response.status, 200)
    t.assert.equal(await response.text(), 'Hello, Jane')
  })

  it('redirects unauthenticated requests', async (t) => {
    let response = await router.fetch(
      new Request('http://localhost/profile'),
    )

    t.assert.equal(response.status, 302)
    t.assert.ok(response.headers.get('Location')?.startsWith('/login'))
  })
})

Testing Custom Middleware

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

describe('rate limiter middleware', () => {
  it('allows requests under the limit', async (t) => {
    let router = createRouter({
      middleware: [rateLimit({ max: 5, windowMs: 60000 })],
    })

    router.map(route({ test: '/test' }).test, () => new Response('OK'))

    for (let i = 0; i < 5; i++) {
      let response = await router.fetch(new Request('http://localhost/test'))
      t.assert.equal(response.status, 200)
    }
  })

  it('blocks requests over the limit', async (t) => {
    let router = createRouter({
      middleware: [rateLimit({ max: 2, windowMs: 60000 })],
    })

    router.map(route({ test: '/test' }).test, () => new Response('OK'))

    await router.fetch(new Request('http://localhost/test'))
    await router.fetch(new Request('http://localhost/test'))

    let response = await router.fetch(new Request('http://localhost/test'))
    t.assert.equal(response.status, 429)
  })
})

Testing Components

Test components by creating a root and rendering them:

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

describe('Button component', () => {
  it('renders with the correct text', async (t) => {
    let root = createRoot()
    root.render(<Button label="Click me" />)
    await flush()

    let button = root.querySelector('button')
    t.assert.ok(button)
    t.assert.equal(button.textContent, 'Click me')
  })

  it('calls onClick when clicked', async (t) => {
    let handleClick = t.mock.fn()
    let root = createRoot()
    root.render(<Button label="Click" onClick={handleClick} />)
    await flush()

    let button = root.querySelector('button')
    button.click()

    t.assert.equal(handleClick.mock.calls.length, 1)
  })
})

Testing Database Operations

Using a Test Database

Create a separate database instance for tests:

ts
// test/helpers/db.ts
import BetterSqlite3 from 'better-sqlite3'
import { createSqliteDatabaseAdapter } from 'remix/data-table-sqlite'
import { createDatabase } from 'remix/data-table'
import { createMigrationRunner } from 'remix/data-table/migrations'
import { loadMigrations } from 'remix/data-table/migrations/node'

export async function createTestDatabase() {
  // Use an in-memory database for speed
  let sqlite = new BetterSqlite3(':memory:')
  sqlite.pragma('foreign_keys = ON')

  let adapter = createSqliteDatabaseAdapter(sqlite)
  let db = createDatabase(adapter)

  // Run migrations
  let migrations = await loadMigrations('./db/migrations')
  let runner = createMigrationRunner(adapter, migrations)
  await runner.up()

  return { db, sqlite }
}

Testing CRUD Operations

ts
import { describe, it } from 'remix/test'
import { createTestDatabase } from '../helpers/db.ts'
import { users } from '../../app/data/schema.ts'
import { eq } from 'remix/data-table/operators'

describe('User model', () => {
  let db: Awaited<ReturnType<typeof createTestDatabase>>['db']

  before(async () => {
    let test = await createTestDatabase()
    db = test.db
  })

  beforeEach(async () => {
    // Clean the users table before each test
    await db.delete(users, {})
  })

  it('creates a user', async (t) => {
    let user = await db.create(users, {
      email: 'jane@test.com',
      password_hash: 'hashed',
      name: 'Jane',
      role: 'user',
      created_at: Date.now(),
    })

    t.assert.ok(user.id)
    t.assert.equal(user.email, 'jane@test.com')
    t.assert.equal(user.name, 'Jane')
  })

  it('finds a user by email', async (t) => {
    await db.create(users, {
      email: 'jane@test.com',
      password_hash: 'hashed',
      name: 'Jane',
      role: 'user',
      created_at: Date.now(),
    })

    let user = await db.findOne(users, {
      where: eq(users.columns.email, 'jane@test.com'),
    })

    t.assert.ok(user)
    t.assert.equal(user!.name, 'Jane')
  })

  it('returns null for nonexistent user', async (t) => {
    let user = await db.findOne(users, {
      where: eq(users.columns.email, 'nobody@test.com'),
    })

    t.assert.equal(user, null)
  })

  it('updates a user', async (t) => {
    let user = await db.create(users, {
      email: 'jane@test.com',
      password_hash: 'hashed',
      name: 'Jane',
      role: 'user',
      created_at: Date.now(),
    })

    await db.update(users, {
      where: eq(users.columns.id, user.id),
      set: { name: 'Jane Doe' },
    })

    let updated = await db.findOne(users, {
      where: eq(users.columns.id, user.id),
    })

    t.assert.equal(updated!.name, 'Jane Doe')
  })

  it('deletes a user', async (t) => {
    let user = await db.create(users, {
      email: 'jane@test.com',
      password_hash: 'hashed',
      name: 'Jane',
      role: 'user',
      created_at: Date.now(),
    })

    await db.delete(users, {
      where: eq(users.columns.id, user.id),
    })

    let exists = await db.exists(users, {
      where: eq(users.columns.id, user.id),
    })

    t.assert.equal(exists, false)
  })
})

Integration Tests

Integration tests exercise multiple layers together -- routing, middleware, database, and business logic:

ts
import { describe, it } from 'remix/test'
import { createTestDatabase } from '../helpers/db.ts'
import { createApp } from '../../app/server.ts'

describe('POST /api/posts', () => {
  let router: ReturnType<typeof createApp>
  let db: Awaited<ReturnType<typeof createTestDatabase>>['db']

  before(async () => {
    let test = await createTestDatabase()
    db = test.db
    router = createApp({ db })
  })

  it('creates a post and returns 201', async (t) => {
    let response = await router.fetch(
      new Request('http://localhost/api/posts', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Authorization: 'Bearer test-token',
        },
        body: JSON.stringify({
          title: 'Integration Test',
          body: 'This tests the full stack.',
        }),
      }),
    )

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

    let post = await response.json()
    t.assert.equal(post.title, 'Integration Test')
    t.assert.ok(post.id)

    // Verify it was actually saved to the database
    let saved = await db.findOne(posts, {
      where: eq(posts.columns.id, post.id),
    })
    t.assert.ok(saved)
    t.assert.equal(saved!.title, 'Integration Test')
  })

  it('returns 422 for invalid data', async (t) => {
    let response = await router.fetch(
      new Request('http://localhost/api/posts', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Authorization: 'Bearer test-token',
        },
        body: JSON.stringify({ title: '' }),
      }),
    )

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

  it('returns 401 without auth', async (t) => {
    let response = await router.fetch(
      new Request('http://localhost/api/posts', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ title: 'Test', body: 'Test' }),
      }),
    )

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

Configuration

Create a remix-test.config.ts file in your project root to configure the test runner:

ts
// remix-test.config.ts
export default {
  // Glob patterns for test files
  include: ['app/**/*.test.ts', 'test/**/*.test.ts'],

  // Files to exclude
  exclude: ['node_modules/**'],

  // Setup file that runs before all tests
  setup: './test/setup.ts',

  // Timeout per test in milliseconds
  timeout: 5000,

  // Number of parallel workers
  workers: 4,

  // Reporter: 'default', 'verbose', 'dot', 'json'
  reporter: 'default',
}

Setup File

The setup file runs once before all tests. Use it for global setup:

ts
// test/setup.ts
import { createTestDatabase } from './helpers/db.ts'

// Set up the test database once
let { db } = await createTestDatabase()
globalThis.testDb = db

Running Tests

bash
# Run all tests
npx remix test

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

# Run tests matching a pattern
npx remix test --filter "User"

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

# Verbose output
npx remix test --reporter verbose

# Generate JSON report
npx remix test --reporter json > results.json

Watch Mode

Watch mode re-runs tests when source files change. It only re-runs tests affected by the changed files:

bash
npx remix test --watch

Focus a single test during development

Temporarily change it to it.only to run a single test:

ts
it.only('this is the only test that runs', (t) => {
  // ...
})

Remove .only before committing.

Skipping Tests

ts
it.skip('this test is skipped', (t) => {
  // Not executed
})

describe.skip('this entire group is skipped', () => {
  // None of these tests run
})

Released under the MIT License.