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:
// 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:
npx remix testTest 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 contextt.
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:
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:
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:
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:
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:
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:
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:
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
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
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:
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
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:
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:
// 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
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:
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:
// 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:
// test/setup.ts
import { createTestDatabase } from './helpers/db.ts'
// Set up the test database once
let { db } = await createTestDatabase()
globalThis.testDb = dbRunning Tests
# 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.jsonWatch Mode
Watch mode re-runs tests when source files change. It only re-runs tests affected by the changed files:
npx remix test --watchFocus a single test during development
Temporarily change it to it.only to run a single test:
it.only('this is the only test that runs', (t) => {
// ...
})Remove .only before committing.
Skipping Tests
it.skip('this test is skipped', (t) => {
// Not executed
})
describe.skip('this entire group is skipped', () => {
// None of these tests run
})Related
- Database Guide -- Setting up databases for testing
- Authentication Guide -- Testing auth flows