Skip to content

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.

ts
// 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)
}
ts
// 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:

bash
npx remix test

Step 2: Test a Route Loader

Test route loaders by constructing Request objects and calling the loader function directly.

ts
// 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)
}
ts
// 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.

ts
// 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.

ts
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.

ts
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.

ts
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.

ts
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.

ts
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.

ts
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

ConceptWhat You Learned
Basic testsdescribe + it with t.assert
Route testingCreate Request objects and call loaders/actions directly
Lifecycle hooksbefore, after, beforeEach, afterEach for setup/teardown
Mockst.mock() creates functions that record calls
Spiest.spyOn(obj, method) observes calls to real methods
Async testsReturn a promise or use async/await
OrganizationNest describe blocks to mirror app structure

Next Steps

  • Use assert for standalone assertion utilities
  • See the API Reference for all configuration options

Released under the MIT License.