Test Tutorial
This tutorial walks through writing tests for a Remix V3 application. You will learn how to test utility functions, route handlers, mock dependencies, spy on functions, use lifecycle hooks, and configure the test runner.
Prerequisites
- A Remix V3 project
Step 1: Write Your First Test
Create a test file next to the code it tests. Test files are discovered by convention -- files ending in .test.ts or .test.js.
// app/utils/math.ts
export function add(a: number, b: number) {
return a + b
}
export function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max)
}// app/utils/math.test.ts
import { describe, it } from 'remix/test'
import { add, clamp } from './math'
describe('add', () => {
it('adds two positive numbers', (t) => {
t.assert.equal(add(1, 2), 3)
})
it('handles zero', (t) => {
t.assert.equal(add(0, 5), 5)
})
it('handles negative numbers', (t) => {
t.assert.equal(add(-3, 3), 0)
})
})
describe('clamp', () => {
it('clamps below minimum', (t) => {
t.assert.equal(clamp(-5, 0, 100), 0)
})
it('clamps above maximum', (t) => {
t.assert.equal(clamp(150, 0, 100), 100)
})
it('returns value when in range', (t) => {
t.assert.equal(clamp(50, 0, 100), 50)
})
})Run the tests:
npx remix testStep 2: Test a Route Loader
Test route loaders by constructing Request objects and calling the loader function directly.
// app/routes/users.ts
export async function loader({ request }: { request: Request }) {
let url = new URL(request.url)
let search = url.searchParams.get('q') ?? ''
let users = await db.users.findMany({
where: { name: { contains: search } },
})
return Response.json(users)
}// app/routes/users.test.ts
import { describe, it } from 'remix/test'
import { loader } from './users'
describe('GET /users', () => {
it('returns users as JSON', async (t) => {
let request = new Request('http://localhost/users')
let response = await loader({ request })
t.assert.equal(response.status, 200)
let data = await response.json()
t.assert.ok(Array.isArray(data))
})
it('filters by search query', async (t) => {
let request = new Request('http://localhost/users?q=alice')
let response = await loader({ request })
let data = await response.json()
for (let user of data) {
t.assert.match(user.name.toLowerCase(), /alice/)
}
})
})Step 3: Test a Route Action
Test actions by constructing Request objects with the appropriate method and body.
// app/routes/users.test.ts (continued)
import { action } from './users'
describe('POST /users', () => {
it('creates a user', async (t) => {
let body = new FormData()
body.set('name', 'Alice')
body.set('email', 'alice@example.com')
let request = new Request('http://localhost/users', {
method: 'POST',
body,
})
let response = await action({ request })
t.assert.equal(response.status, 200)
let user = await response.json()
t.assert.equal(user.name, 'Alice')
t.assert.equal(user.email, 'alice@example.com')
t.assert.ok(user.id)
})
it('rejects missing email', async (t) => {
let body = new FormData()
body.set('name', 'Alice')
let request = new Request('http://localhost/users', {
method: 'POST',
body,
})
let response = await action({ request })
t.assert.equal(response.status, 400)
})
})Step 4: Use Lifecycle Hooks
Lifecycle hooks help you set up and tear down shared state. before/after run once per describe block. beforeEach/afterEach run around every test.
import { describe, it, before, after, beforeEach } from 'remix/test'
describe('database tests', () => {
before(async () => {
// Run migrations once before all tests
await db.migrate()
})
after(async () => {
// Close connection after all tests
await db.close()
})
beforeEach(async () => {
// Reset data before each test for isolation
await db.truncate('users')
})
it('inserts a user', async (t) => {
await db.users.insert({ name: 'Alice' })
let count = await db.users.count()
t.assert.equal(count, 1)
})
it('starts with empty table', async (t) => {
// Thanks to beforeEach, the table is empty
let count = await db.users.count()
t.assert.equal(count, 0)
})
})Step 5: Mock Functions
Use t.mock() to create mock functions that record their calls. This is useful for testing callbacks and event handlers.
import { describe, it } from 'remix/test'
describe('EventEmitter', () => {
it('calls listeners with the event data', (t) => {
let emitter = new EventEmitter()
let listener = t.mock()
emitter.on('message', listener)
emitter.emit('message', 'hello')
emitter.emit('message', 'world')
// Check call count
t.assert.equal(listener.calls.length, 2)
// Check arguments
t.assert.deepEqual(listener.calls[0].arguments, ['hello'])
t.assert.deepEqual(listener.calls[1].arguments, ['world'])
})
it('wraps an existing function', (t) => {
let original = (x: number) => x * 2
let mocked = t.mock(original)
let result = mocked(5)
t.assert.equal(result, 10) // Original logic still runs
t.assert.equal(mocked.calls.length, 1)
t.assert.deepEqual(mocked.calls[0].arguments, [5])
})
})Step 6: Spy on Object Methods
Use t.spyOn to observe calls to methods on existing objects without replacing their behavior.
import { describe, it } from 'remix/test'
describe('Logger', () => {
it('logs request details', (t) => {
let logger = {
info(message: string) {
// real implementation
},
}
let spy = t.spyOn(logger, 'info')
// Code under test
handleRequest(logger)
// Verify the logger was called
t.assert.equal(spy.calls.length, 1)
t.assert.match(spy.calls[0].arguments[0], /request/)
})
})Step 7: Test Async Code
Async tests work by returning a promise. The test runner waits for the promise to resolve.
import { describe, it } from 'remix/test'
describe('async operations', () => {
it('fetches data', async (t) => {
let data = await fetchData()
t.assert.ok(data)
t.assert.equal(typeof data.name, 'string')
})
it('rejects on invalid input', async (t) => {
await t.assert.rejects(async () => {
await fetchData({ id: 'invalid' })
})
})
it('resolves within timeout', async (t) => {
let start = Date.now()
await processJob()
let elapsed = Date.now() - start
t.assert.ok(elapsed < 1000, 'Should complete within 1 second')
})
})Step 8: Organize Tests with Nested Describe Blocks
Nest describe blocks to create a hierarchy that mirrors your application structure.
import { describe, it } from 'remix/test'
describe('UserService', () => {
describe('create', () => {
it('sets default role to "user"', async (t) => {
let user = await UserService.create({ name: 'Alice' })
t.assert.equal(user.role, 'user')
})
it('accepts a custom role', async (t) => {
let user = await UserService.create({ name: 'Alice', role: 'admin' })
t.assert.equal(user.role, 'admin')
})
})
describe('delete', () => {
it('removes the user', async (t) => {
let user = await UserService.create({ name: 'Alice' })
await UserService.delete(user.id)
let found = await UserService.findById(user.id)
t.assert.equal(found, null)
})
})
})Step 9: Test with Mock HTTP Requests
Combine everything to test a complete route handler with mocked dependencies.
import { describe, it, beforeEach } from 'remix/test'
describe('POST /api/subscribe', () => {
let mockMailer: { send: ReturnType<typeof t.mock> }
beforeEach((t) => {
mockMailer = { send: t.mock() }
})
it('sends a welcome email', async (t) => {
let body = new FormData()
body.set('email', 'alice@example.com')
let request = new Request('http://localhost/api/subscribe', {
method: 'POST',
body,
})
let response = await action({ request, mailer: mockMailer })
t.assert.equal(response.status, 200)
t.assert.equal(mockMailer.send.calls.length, 1)
t.assert.equal(mockMailer.send.calls[0].arguments[0].to, 'alice@example.com')
})
it('rejects invalid email', async (t) => {
let body = new FormData()
body.set('email', 'not-an-email')
let request = new Request('http://localhost/api/subscribe', {
method: 'POST',
body,
})
let response = await action({ request, mailer: mockMailer })
t.assert.equal(response.status, 400)
t.assert.equal(mockMailer.send.calls.length, 0)
})
})Summary
| Concept | What You Learned |
|---|---|
| Basic tests | describe + it with t.assert |
| Route testing | Create Request objects and call loaders/actions directly |
| Lifecycle hooks | before, after, beforeEach, afterEach for setup/teardown |
| Mocks | t.mock() creates functions that record calls |
| Spies | t.spyOn(obj, method) observes calls to real methods |
| Async tests | Return a promise or use async/await |
| Organization | Nest describe blocks to mirror app structure |
Next Steps
- Use assert for standalone assertion utilities
- See the API Reference for all configuration options