Skip to content

Testing Utilities

The @ereo/testing package provides comprehensive utilities for testing EreoJS applications, including loaders, actions, middleware, and components.

Import

ts
import {
  // Test Context
  createTestContext,
  createContextFactory,
  type TestContextOptions,
  type TestContext,

  // Loader Testing
  testLoader,
  createLoaderTester,
  testLoadersParallel,
  testLoaderMatrix,
  testLoaderError,
  type LoaderTestOptions,
  type LoaderTestResult,

  // Action Testing
  testAction,
  createActionTester,
  testActionMatrix,
  testActionError,
  testActionWithFile,
  type ActionTestOptions,
  type ActionTestResult,

  // Middleware Testing
  testMiddleware,
  createMiddlewareTester,
  testMiddlewareChain,
  testMiddlewareMatrix,
  testMiddlewareError,
  testMiddlewareContext,
  type MiddlewareTestOptions,
  type MiddlewareTestResult,

  // Request/Response Utilities
  createMockRequest,
  createFormRequest,
  createMockFormData,
  createMockHeaders,
  createMockFile,
  parseJsonResponse,
  parseTextResponse,
  extractCookies,
  type MockRequestOptions,

  // Component Testing
  renderRoute,
  createRouteRenderer,
  renderComponent,
  renderRouteMatrix,
  testRouteRenders,
  getRouteMeta,
  type RenderRouteOptions,
  type RenderResult,

  // Assertions
  assertRedirect,
  assertJson,
  assertStatus,
  assertHeaders,
  assertCookies,
  assertThrows,
  assertSchema,
  type AssertionOptions,

  // Test Server
  createTestServer,
  createMockServer,
  type TestServer,
  type TestServerOptions,

  // Snapshot Testing
  snapshotLoader,
  snapshotAction,
  createSnapshotMatrix,
  commonReplacers,
  applyReplacements,
  deterministicSnapshot,
  type SnapshotOptions,
} from '@ereo/testing'

Installation

bash
bun add -D @ereo/testing

Overview

The testing package is organized into several categories:

CategoryFunctionsPurpose
ContextcreateTestContext, createContextFactoryCreate mock contexts for testing
Loader TestingtestLoader, createLoaderTester, testLoadersParallel, testLoaderMatrix, testLoaderErrorTest route loaders in isolation
Action TestingtestAction, createActionTester, testActionMatrix, testActionError, testActionWithFileTest form actions and mutations
Middleware TestingtestMiddleware, createMiddlewareTester, testMiddlewareChain, testMiddlewareMatrix, testMiddlewareError, testMiddlewareContextTest middleware functions
Request/ResponsecreateMockRequest, createFormRequest, createMockFormData, createMockHeaders, createMockFile, parseJsonResponse, parseTextResponse, extractCookiesCreate mock requests and parse responses
Component TestingrenderRoute, createRouteRenderer, renderComponent, renderRouteMatrix, testRouteRenders, getRouteMetaRender routes with loader data
AssertionsassertRedirect, assertJson, assertStatus, assertHeaders, assertCookies, assertThrows, assertSchemaCommon assertions for responses
Test ServercreateTestServer, createMockServerIntegration testing with real HTTP
SnapshotssnapshotLoader, snapshotAction, createSnapshotMatrix, commonReplacers, applyReplacements, deterministicSnapshotSnapshot testing for data

Test Context

createTestContext

Creates a mock context for testing loaders, actions, and middleware. The test context mimics the AppContext your route handlers receive in production, so your loaders and actions can call context.get(), context.set(), and other methods as normal.

Signature

ts
function createTestContext(options?: TestContextOptions): TestContext

Options

ts
interface TestContextOptions {
  // Initial context store values
  store?: Record<string, unknown>

  // Initial environment variables
  env?: Record<string, string>

  // Request URL
  url?: string | URL

  // Initial cache tags
  cacheTags?: string[]

  // Initial response headers
  responseHeaders?: Record<string, string>
}

TestContext Interface

ts
interface TestContext extends AppContext {
  // Get all values set in the context store
  getStore(): Record<string, unknown>

  // Get all cache operations performed
  getCacheOperations(): CacheOperation[]

  // Reset the context to initial state
  reset(): void
}

Example

ts
const ctx = createTestContext({
  store: { user: { id: 1, name: 'Test User' } },
  env: { DATABASE_URL: 'test://db' },
})

// Use in tests
const result = await loader({ request, params, context: ctx })

// Inspect context after loader execution
const operations = ctx.getCacheOperations()
expect(operations).toHaveLength(1)

createContextFactory

Creates a reusable context factory for repeated test setup. This is useful when many tests share the same base configuration (e.g., an authenticated user or specific environment variables) but need small overrides per test.

Signature

ts
function createContextFactory(
  baseOptions?: TestContextOptions
): (overrides?: Partial<TestContextOptions>) => TestContext

Example

ts
const contextFactory = createContextFactory({
  store: { user: testUser },
  env: { API_KEY: 'test-key' },
})

test('loader test 1', async () => {
  const ctx = contextFactory()
  // ...
})

test('loader test 2', async () => {
  const ctx = contextFactory({ store: { user: differentUser } })
  // ...
})

Loader Testing

testLoader

Tests a loader function directly with configurable options. This is the simplest way to test a loader — pass the loader function and any route params, request options, or context overrides you need.

Signature

ts
function testLoader<T = unknown, P = RouteParams>(
  loader: LoaderFunction<T, P>,
  options?: LoaderTestOptions<P>
): Promise<LoaderTestResult<T>>

Options

ts
interface LoaderTestOptions<P = RouteParams> {
  // Route parameters
  params?: P

  // Request options
  request?: MockRequestOptions

  // Context options
  context?: TestContextOptions
}

Result

ts
interface LoaderTestResult<T = unknown> {
  // The loader's return value
  data: T

  // The test context (for inspection)
  context: TestContext

  // The request used
  request: Request

  // Execution time in milliseconds
  duration: number
}

Example

ts
import { testLoader } from '@ereo/testing'
import { loader } from './routes/blog/[slug]'

test('loads blog post', async () => {
  const result = await testLoader(loader, {
    params: { slug: 'my-post' },
  })

  expect(result.data.title).toBe('My Post')
  expect(result.context.getCacheOperations()).toHaveLength(1)
})

createLoaderTester

Creates a reusable loader tester with preset options. Base options are merged with per-test overrides, so you can set up shared configuration once (like an authenticated user context) and then only specify what changes per test.

Signature

ts
function createLoaderTester<T = unknown, P = RouteParams>(
  loader: LoaderFunction<T, P>,
  baseOptions?: LoaderTestOptions<P>
): (overrides?: Partial<LoaderTestOptions<P>>) => Promise<LoaderTestResult<T>>

Example

ts
const testPostLoader = createLoaderTester(loader, {
  context: { store: { user: testUser } },
})

test('loads post with user context', async () => {
  const result = await testPostLoader({ params: { slug: 'test' } })
  expect(result.data).toBeDefined()
})

test('loads different post', async () => {
  const result = await testPostLoader({ params: { slug: 'another' } })
  expect(result.data.slug).toBe('another')
})

testLoadersParallel

Tests multiple loaders in parallel. Useful for testing combined loader scenarios where several loaders run at the same time (e.g., a page that loads user data and post data simultaneously).

Signature

ts
function testLoadersParallel<T extends unknown[] = unknown[]>(
  loaders: Array<{
    loader: LoaderFunction<T[number]>
    params?: RouteParams
    request?: MockRequestOptions
    context?: TestContextOptions
  }>
): Promise<LoaderTestResult<T[number]>[]>

Example

ts
const results = await testLoadersParallel([
  { loader: userLoader, params: { id: '1' } },
  { loader: postsLoader, params: {} },
])

expect(results[0].data.user).toBeDefined()
expect(results[1].data.posts).toHaveLength(3)

testLoaderMatrix

Tests a single loader with multiple parameter combinations. Returns one result for each parameter set, making it easy to verify behavior across a range of inputs in one test.

Signature

ts
function testLoaderMatrix<T = unknown, P = RouteParams>(
  loader: LoaderFunction<T, P>,
  options: {
    params: P[]
    request?: MockRequestOptions
    context?: TestContextOptions
  }
): Promise<LoaderTestResult<T>[]>

Example

ts
const results = await testLoaderMatrix(loader, {
  params: [
    { slug: 'post-1' },
    { slug: 'post-2' },
    { slug: 'non-existent' },
  ],
})

expect(results[0].data).toBeDefined()
expect(results[1].data).toBeDefined()
expect(results[2].data).toBeNull()

testLoaderError

Tests loader error handling by catching any thrown errors instead of letting them propagate. Returns either the caught error or null if the loader completed without throwing.

Signature

ts
function testLoaderError<P = RouteParams>(
  loader: LoaderFunction<unknown, P>,
  options?: LoaderTestOptions<P>
): Promise<{
  error: Error | null
  context: TestContext
  request: Request
}>

Example

ts
test('handles missing post', async () => {
  const result = await testLoaderError(loader, {
    params: { slug: 'non-existent' },
  })

  expect(result.error).toBeInstanceOf(NotFoundError)
  expect(result.error.status).toBe(404)
})

Action Testing

testAction

Tests an action function directly. The request method defaults to POST (since actions handle form submissions). If the action returns a Response object, testAction automatically parses the JSON body and detects redirects.

Signature

ts
function testAction<T = unknown, P = RouteParams>(
  action: ActionFunction<T | Response, P>,
  options?: ActionTestOptions<P>
): Promise<ActionTestResult<T>>

Options

ts
interface ActionTestOptions<P = RouteParams> {
  // Route parameters
  params?: P

  // Request options (method defaults to POST)
  request?: MockRequestOptions

  // Context options
  context?: TestContextOptions

  // Form data to submit
  formData?: Record<string, string | Blob>

  // JSON body to submit
  body?: Record<string, unknown>
}

Result

ts
interface ActionTestResult<T = unknown> {
  // The action's return value (parsed if Response)
  data: T

  // The raw response if action returned a Response
  response: Response | null

  // The test context (for inspection)
  context: TestContext

  // The request used
  request: Request

  // Execution time in milliseconds
  duration: number

  // Whether the action returned a redirect
  isRedirect: boolean

  // Redirect location if applicable
  redirectTo: string | null
}

Example

ts
import { testAction } from '@ereo/testing'
import { action } from './routes/blog/[slug]'

test('creates a comment', async () => {
  const result = await testAction(action, {
    params: { slug: 'my-post' },
    formData: { content: 'Great post!' },
  })

  expect(result.data.success).toBe(true)
  expect(result.isRedirect).toBe(false)
})

test('redirects after creation', async () => {
  const result = await testAction(action, {
    params: { slug: 'my-post' },
    formData: { content: 'Test', redirect: 'true' },
  })

  expect(result.isRedirect).toBe(true)
  expect(result.redirectTo).toBe('/blog/my-post')
})

createActionTester

Creates a reusable action tester with preset options. Works the same way as createLoaderTester — base options are merged with per-test overrides.

Signature

ts
function createActionTester<T = unknown, P = RouteParams>(
  action: ActionFunction<T | Response, P>,
  baseOptions?: ActionTestOptions<P>
): (overrides?: Partial<ActionTestOptions<P>>) => Promise<ActionTestResult<T>>

Example

ts
const testCommentAction = createActionTester(action, {
  context: { store: { user: testUser } },
})

test('creates comment', async () => {
  const result = await testCommentAction({
    params: { slug: 'test' },
    formData: { content: 'Hello!' },
  })
  expect(result.data.success).toBe(true)
})

test('requires content', async () => {
  const result = await testCommentAction({
    params: { slug: 'test' },
    formData: { content: '' },
  })
  expect(result.data.error).toBeDefined()
})

testActionMatrix

Tests an action with multiple form submissions. Each entry in the submissions array is tested independently, and results are returned in the same order. This is useful for verifying that an action handles a variety of valid and invalid inputs correctly.

Signature

ts
function testActionMatrix<T = unknown, P = RouteParams>(
  action: ActionFunction<T | Response, P>,
  options: {
    params?: P
    submissions: Array<{
      formData?: Record<string, string | Blob>
      body?: Record<string, unknown>
    }>
    context?: TestContextOptions
  }
): Promise<ActionTestResult<T>[]>

Example

ts
const results = await testActionMatrix(action, {
  params: { slug: 'post-1' },
  submissions: [
    { formData: { content: 'Comment 1' } },
    { formData: { content: 'Comment 2' } },
    { formData: { content: '' } }, // Invalid
  ],
})

expect(results[0].data.success).toBe(true)
expect(results[1].data.success).toBe(true)
expect(results[2].data.error).toBeDefined()

testActionError

Tests action error handling by catching thrown errors. Works the same way as testLoaderError — returns the caught error or null if the action completed without throwing.

Signature

ts
function testActionError<P = RouteParams>(
  action: ActionFunction<unknown, P>,
  options?: ActionTestOptions<P>
): Promise<{
  error: Error | null
  context: TestContext
  request: Request
}>

Example

ts
test('handles validation error', async () => {
  const result = await testActionError(action, {
    formData: { content: '' },
  })

  expect(result.error).toBeInstanceOf(ValidationError)
})

testActionWithFile

Tests an action with file upload. Creates a File object from the provided content (string or Blob) and submits it as form data. You can also include additional form fields via extraFields.

Signature

ts
function testActionWithFile<T = unknown, P = RouteParams>(
  action: ActionFunction<T | Response, P>,
  options: {
    params?: P
    file: {
      field: string
      name: string
      content: string | Blob
      type?: string
    }
    extraFields?: Record<string, string>
    context?: TestContextOptions
  }
): Promise<ActionTestResult<T>>

Example

ts
test('uploads file', async () => {
  const result = await testActionWithFile(action, {
    params: { id: '1' },
    file: {
      field: 'avatar',
      name: 'avatar.png',
      content: imageBlob,
      type: 'image/png',
    },
    extraFields: {
      description: 'Profile picture',
    },
  })

  expect(result.data.url).toContain('avatar.png')
})

Middleware Testing

testMiddleware

Tests a middleware function directly. Automatically tracks whether and how many times next() was called, so you can verify that your middleware correctly passes through or short-circuits the request.

Signature

ts
function testMiddleware(
  middleware: MiddlewareHandler,
  options?: MiddlewareTestOptions
): Promise<MiddlewareTestResult>

Options

ts
interface MiddlewareTestOptions {
  // Request options
  request?: MockRequestOptions

  // Context options
  context?: TestContextOptions

  // Custom next function (defaults to returning 200 OK)
  next?: NextFunction

  // Expected response from next (for pass-through testing)
  nextResponse?: Response
}

Result

ts
interface MiddlewareTestResult {
  // The response returned by middleware
  response: Response

  // The test context (for inspection)
  context: TestContext

  // The request used
  request: Request

  // Whether next() was called
  nextCalled: boolean

  // How many times next() was called
  nextCallCount: number

  // Execution time in milliseconds
  duration: number
}

Example

ts
import { testMiddleware } from '@ereo/testing'
import { authMiddleware } from './middleware/auth'

test('blocks unauthenticated requests', async () => {
  const result = await testMiddleware(authMiddleware, {
    request: { url: '/admin' },
  })

  expect(result.response.status).toBe(401)
  expect(result.nextCalled).toBe(false)
})

test('allows authenticated requests', async () => {
  const result = await testMiddleware(authMiddleware, {
    request: {
      url: '/admin',
      headers: { Authorization: 'Bearer valid-token' },
    },
  })

  expect(result.nextCalled).toBe(true)
  expect(result.response.status).toBe(200)
})

createMiddlewareTester

Creates a reusable middleware tester with preset options.

Signature

ts
function createMiddlewareTester(
  middleware: MiddlewareHandler,
  baseOptions?: MiddlewareTestOptions
): (overrides?: Partial<MiddlewareTestOptions>) => Promise<MiddlewareTestResult>

Example

ts
const testAuth = createMiddlewareTester(authMiddleware, {
  context: { env: { AUTH_SECRET: 'test-secret' } },
})

test('allows valid tokens', async () => {
  const result = await testAuth({
    request: { headers: { Authorization: 'Bearer valid' } },
  })
  expect(result.nextCalled).toBe(true)
})

test('rejects invalid tokens', async () => {
  const result = await testAuth({
    request: { headers: { Authorization: 'Bearer invalid' } },
  })
  expect(result.response.status).toBe(401)
})

testMiddlewareChain

Tests a chain of middleware functions in order. Each middleware's next() calls the next middleware in the chain. The result includes per-middleware tracking of whether next() was called and how long each middleware took.

Signature

ts
function testMiddlewareChain(
  middlewares: MiddlewareHandler[],
  options?: MiddlewareTestOptions
): Promise<{
  response: Response
  context: TestContext
  request: Request
  middlewareResults: Array<{
    index: number
    nextCalled: boolean
    duration: number
  }>
}>

Example

ts
const result = await testMiddlewareChain([
  loggingMiddleware,
  authMiddleware,
  rateLimitMiddleware,
], {
  request: { url: '/api/data' },
})

expect(result.response.status).toBe(200)
expect(result.middlewareResults[0].nextCalled).toBe(true)
expect(result.middlewareResults[1].nextCalled).toBe(true)
expect(result.middlewareResults[2].nextCalled).toBe(true)

testMiddlewareMatrix

Tests a single middleware with multiple request scenarios. Returns one result per request, making it easy to verify the middleware handles different inputs correctly in a single test.

Signature

ts
function testMiddlewareMatrix(
  middleware: MiddlewareHandler,
  options: {
    requests: MockRequestOptions[]
    context?: TestContextOptions
  }
): Promise<MiddlewareTestResult[]>

Example

ts
const results = await testMiddlewareMatrix(authMiddleware, {
  requests: [
    { url: '/public' },
    { url: '/admin', headers: { Authorization: 'Bearer valid' } },
    { url: '/admin' }, // No auth
  ],
})

expect(results[0].response.status).toBe(200)
expect(results[1].response.status).toBe(200)
expect(results[2].response.status).toBe(401)

testMiddlewareError

Tests middleware error handling. Useful for verifying that error-handling middleware correctly catches errors thrown by downstream handlers. Pass a custom next function that throws an error to simulate downstream failures.

Signature

ts
function testMiddlewareError(
  middleware: MiddlewareHandler,
  options: MiddlewareTestOptions & {
    next: NextFunction
  }
): Promise<{
  response: Response | null
  error: Error | null
  context: TestContext
}>

Example

ts
test('handles errors gracefully', async () => {
  const result = await testMiddlewareError(errorMiddleware, {
    next: async () => {
      throw new Error('Downstream error')
    },
  })

  expect(result.response?.status).toBe(500)
  expect(result.error).toBeNull() // Middleware caught the error
})

testMiddlewareContext

Tests that a middleware correctly modifies the request context. After running the middleware, the test compares the context values against your expected values using deep equality. Returns a contextMatches boolean and a detailed diff of any mismatches.

Signature

ts
function testMiddlewareContext(
  middleware: MiddlewareHandler,
  options: MiddlewareTestOptions & {
    expectContextValues: Record<string, unknown>
  }
): Promise<{
  response: Response
  context: TestContext
  contextMatches: boolean
  contextDiff: Record<string, { expected: unknown; actual: unknown }>
}>

Example

ts
test('sets user in context', async () => {
  const result = await testMiddlewareContext(authMiddleware, {
    request: { headers: { Authorization: 'Bearer valid' } },
    expectContextValues: {
      user: { id: '1', role: 'user' },
    },
  })

  expect(result.contextMatches).toBe(true)
})

Request/Response Utilities

createMockRequest

Creates a mock Request object for testing. Supports two call signatures: createMockRequest(options) or createMockRequest(url, options). Automatically builds the full URL, sets headers, handles cookies, and serializes JSON or FormData bodies.

Signature

ts
function createMockRequest(
  url?: string | MockRequestOptions,
  options?: MockRequestOptions
): Request

Options

ts
interface MockRequestOptions {
  // HTTP method (default: GET)
  method?: string

  // Request URL or path
  url?: string

  // Request headers
  headers?: Record<string, string>

  // Request body (for POST/PUT/PATCH)
  body?: BodyInit | Record<string, unknown>

  // Query parameters
  searchParams?: Record<string, string | string[]>

  // Form data
  formData?: Record<string, string | Blob>

  // Cookies
  cookies?: Record<string, string>
}

Examples

ts
// Simple GET request
const request = createMockRequest({ url: '/api/posts' })

// POST with JSON body
const request = createMockRequest({
  method: 'POST',
  url: '/api/posts',
  body: { title: 'Test Post' },
})

// POST with form data
const request = createMockRequest({
  method: 'POST',
  url: '/api/login',
  formData: { email: 'test@example.com', password: 'secret' },
})

// With cookies and headers
const request = createMockRequest({
  url: '/dashboard',
  cookies: { session: 'abc123' },
  headers: { 'X-Custom-Header': 'value' },
})

// With query parameters
const request = createMockRequest({
  url: '/api/search',
  searchParams: { q: 'test', tags: ['a', 'b'] },
})

createFormRequest

Creates a POST request with URL-encoded form data. This is a convenience function — use it when you need a simple form submission without setting method, headers, or content type manually.

Signature

ts
function createFormRequest(
  url: string,
  data: Record<string, string | Blob>
): Request

Example

ts
const request = createFormRequest('/api/login', {
  email: 'test@example.com',
  password: 'secret',
})

createMockFormData

Creates a FormData object from a plain object. Useful when you need raw FormData for direct use in tests.

Signature

ts
function createMockFormData(
  data: Record<string, string | Blob | File>
): FormData

Example

ts
const formData = createMockFormData({
  email: 'test@example.com',
  avatar: new File(['...'], 'avatar.png', { type: 'image/png' }),
})

createMockHeaders

Creates a Headers object from a plain object.

Signature

ts
function createMockHeaders(data: Record<string, string>): Headers

Example

ts
const headers = createMockHeaders({
  'Authorization': 'Bearer token123',
  'Content-Type': 'application/json',
})

createMockFile

Creates a File object for testing file uploads. Accepts either a string or Blob as the file content.

Signature

ts
function createMockFile(
  name: string,
  content: string | Blob,
  type?: string
): File

Example

ts
const file = createMockFile('test.txt', 'Hello World', 'text/plain')
const imageFile = createMockFile('image.png', imageBlob, 'image/png')

parseJsonResponse

Parses JSON from a Response object. Throws an error with a preview of the body if parsing fails.

Signature

ts
function parseJsonResponse<T = unknown>(response: Response): Promise<T>

Example

ts
const result = await testAction(action, options)
const data = await parseJsonResponse<MyData>(result.response)

parseTextResponse

Parses text from a Response object.

Signature

ts
function parseTextResponse(response: Response): Promise<string>

extractCookies

Extracts cookies from a Response by reading its Set-Cookie headers. Returns a plain object mapping cookie names to their values.

Signature

ts
function extractCookies(response: Response): Record<string, string>

Example

ts
const cookies = extractCookies(response)
expect(cookies.session).toBeDefined()

Component Testing

renderRoute

Renders a route module's component with its loader data. If you provide loaderData in options, the loader is skipped and the provided data is used directly. Otherwise, the loader runs automatically.

Returns a React element that you can pass to your testing library's render() function.

Signature

ts
function renderRoute<T = unknown, P = RouteParams>(
  module: RouteModule,
  options?: RenderRouteOptions<P>
): Promise<RenderResult<T>>

Options

ts
interface RenderRouteOptions<P = RouteParams> {
  // Route parameters
  params?: P

  // Request options
  request?: MockRequestOptions

  // Context options
  context?: TestContextOptions

  // Initial loader data (skip loader execution)
  loaderData?: unknown

  // Children to render
  children?: ReactElement
}

Result

ts
interface RenderResult<T = unknown> {
  // The rendered element
  element: ReactElement

  // The loader data used
  loaderData: T

  // The test context
  context: TestContext

  // The request used
  request: Request

  // Props passed to the component
  props: RouteComponentProps<T>
}

Example

ts
import { renderRoute } from '@ereo/testing'
import { render } from '@testing-library/react'
import * as BlogPost from './routes/blog/[slug]'

test('renders blog post', async () => {
  const result = await renderRoute(BlogPost, {
    params: { slug: 'my-post' },
  })

  // Use with React Testing Library
  const { getByText } = render(result.element)
  expect(getByText('My Post')).toBeInTheDocument()
})

test('renders with mock data', async () => {
  const result = await renderRoute(BlogPost, {
    loaderData: { title: 'Test', content: 'Content' },
  })

  const { getByText } = render(result.element)
  expect(getByText('Test')).toBeInTheDocument()
})

createRouteRenderer

Creates a reusable route renderer with preset options.

Signature

ts
function createRouteRenderer<T = unknown, P = RouteParams>(
  module: RouteModule,
  baseOptions?: RenderRouteOptions<P>
): (overrides?: Partial<RenderRouteOptions<P>>) => Promise<RenderResult<T>>

Example

ts
const renderBlogPost = createRouteRenderer(BlogPost, {
  context: { store: { user: testUser } },
})

test('renders for authenticated user', async () => {
  const result = await renderBlogPost({ params: { slug: 'test' } })
  // ...
})

renderComponent

Renders a standalone React component with props. Returns a React element without running any loader. This is useful for testing island components or shared UI components outside of a route context.

Signature

ts
function renderComponent<P extends object>(
  Component: ComponentType<P>,
  props: P
): ReactElement

Example

ts
const element = renderComponent(Counter, { count: 5 })

renderRouteMatrix

Renders a route with multiple parameter sets. Returns one render result per parameter set, making it useful for visual regression or snapshot testing across different route inputs.

Signature

ts
function renderRouteMatrix<T = unknown, P = RouteParams>(
  module: RouteModule,
  options: {
    params: P[]
    request?: MockRequestOptions
    context?: TestContextOptions
  }
): Promise<RenderResult<T>[]>

Example

ts
const renders = await renderRouteMatrix(BlogPost, {
  params: [
    { slug: 'post-1' },
    { slug: 'post-2' },
  ],
})

renders.forEach((result, index) => {
  expect(result.element).toMatchSnapshot(`render-${index}`)
})

testRouteRenders

Tests that a route renders without throwing an error. Returns { renders: true } on success or { renders: false, error } if rendering fails. Useful as a smoke test to catch runtime errors in route components.

Signature

ts
function testRouteRenders<P = RouteParams>(
  module: RouteModule,
  options?: RenderRouteOptions<P>
): Promise<{
  renders: boolean
  error: Error | null
  result: RenderResult | null
}>

Example

ts
test('renders without errors', async () => {
  const result = await testRouteRenders(BlogPost, {
    params: { slug: 'test' },
  })

  expect(result.renders).toBe(true)
  expect(result.error).toBeNull()
})

getRouteMeta

Gets the meta tags for a route by running its meta() export. If the route has a loader, the loader runs first (unless you provide loaderData in options) so that the meta function receives the correct data.

Signature

ts
function getRouteMeta<P = RouteParams>(
  module: RouteModule,
  options?: RenderRouteOptions<P>
): Promise<MetaDescriptor[]>

Example

ts
const meta = await getRouteMeta(BlogPost, {
  params: { slug: 'my-post' },
})

expect(meta.find(m => m.title)).toEqual({ title: 'My Post' })

Assertions

The assertion functions throw descriptive errors on failure. They work with any test runner (Bun, Jest, Vitest, etc.) and can be used alongside your runner's built-in expect().

assertRedirect

Asserts that a response is a redirect (3xx status). Optionally checks the Location header and specific status code.

Signature

ts
function assertRedirect(
  response: Response | null,
  expectedLocation?: string,
  options?: AssertionOptions & { status?: number }
): void

Example

ts
const result = await testAction(action, { formData: {} })
assertRedirect(result.response, '/login')

// With specific status
assertRedirect(result.response, '/dashboard', { status: 301 })

assertJson

Asserts that a response or data object contains the expected properties. Uses deep partial matching — only the keys you specify are checked, so extra keys in the data are ignored.

Signature

ts
function assertJson<T = unknown>(
  responseOrData: Response | T,
  expected: Partial<T>,
  options?: AssertionOptions
): Promise<void>

Example

ts
const result = await testLoader(loader, { params: { id: '1' } })
await assertJson(result.data, { id: 1, name: 'Test' })

assertStatus

Asserts that a response has the expected status code. You can pass a single status or an array of acceptable statuses.

Signature

ts
function assertStatus(
  response: Response | null,
  expected: number | number[],
  options?: AssertionOptions
): void

Example

ts
const result = await testMiddleware(authMiddleware, {})
assertStatus(result.response, 401)

// Multiple acceptable statuses
assertStatus(result.response, [200, 201])

assertHeaders

Asserts that a response has the expected headers. Values can be exact strings or RegExp patterns.

Signature

ts
function assertHeaders(
  response: Response | null,
  expected: Record<string, string | RegExp>,
  options?: AssertionOptions
): void

Example

ts
const result = await testLoader(loader, {})
assertHeaders(result.response, {
  'Content-Type': 'application/json',
  'Cache-Control': /max-age=\d+/,
})

assertCookies

Asserts that a response sets the expected cookies. You can check for existence, value (string or RegExp), and cookie attributes like httpOnly, secure, sameSite, path, and maxAge.

Signature

ts
function assertCookies(
  response: Response | null,
  expected: Record<string, {
    exists?: boolean
    value?: string | RegExp
    httpOnly?: boolean
    secure?: boolean
    sameSite?: 'Strict' | 'Lax' | 'None'
    path?: string
    maxAge?: number
    expires?: boolean
  }>,
  options?: AssertionOptions
): void

Example

ts
const result = await testAction(loginAction, {
  formData: { email: 'test@example.com', password: 'secret' },
})

assertCookies(result.response, {
  session: { exists: true, httpOnly: true, secure: true },
  _csrf: { exists: false },
})

assertThrows

Asserts that an async function throws an error. Optionally checks the error message (string or RegExp), error name, and HTTP status code.

Signature

ts
function assertThrows(
  fn: () => Promise<unknown>,
  expected?: {
    message?: string | RegExp
    name?: string
    status?: number
  },
  options?: AssertionOptions
): Promise<void>

Example

ts
await assertThrows(
  () => testLoader(loader, { params: { id: 'invalid' } }),
  { message: /not found/i, status: 404 }
)

assertSchema

Asserts that an object's properties match a basic type schema. Each key maps to a type string: 'string', 'number', 'boolean', 'object', 'array', 'null', or 'undefined'.

Signature

ts
function assertSchema(
  data: unknown,
  schema: Record<string, 'string' | 'number' | 'boolean' | 'object' | 'array' | 'null' | 'undefined'>,
  options?: AssertionOptions
): void

Example

ts
assertSchema(result.data, {
  id: 'number',
  name: 'string',
  tags: 'array',
  meta: 'object',
})

Test Server

createTestServer

Creates a full EreoJS application server for integration testing. The server starts on a random available port (or a port you specify), loads all route modules from your routes directory, and provides convenient HTTP method helpers.

Always call server.stop() in your afterAll block to clean up.

Signature

ts
function createTestServer(options?: TestServerOptions): Promise<TestServer>

Options

ts
interface TestServerOptions {
  // Framework configuration
  config?: FrameworkConfig

  // Port to run on (default: random available port)
  port?: number

  // Routes directory
  routesDir?: string
}

TestServer Interface

ts
interface TestServer {
  // Server base URL
  url: string

  // Port the server is running on
  port: number

  // Make a request to the server
  fetch: (path: string, init?: RequestInit) => Promise<Response>

  // HTTP method helpers
  get: (path: string, init?: RequestInit) => Promise<Response>
  post: (path: string, body?: unknown, init?: RequestInit) => Promise<Response>
  put: (path: string, body?: unknown, init?: RequestInit) => Promise<Response>
  delete: (path: string, init?: RequestInit) => Promise<Response>
  patch: (path: string, body?: unknown, init?: RequestInit) => Promise<Response>

  // Submit a form
  submitForm: (path: string, formData: Record<string, string>, init?: RequestInit) => Promise<Response>

  // Stop the server
  stop: () => Promise<void>
}

Example

ts
import { createTestServer, TestServer } from '@ereo/testing'

describe('API routes', () => {
  let server: TestServer

  beforeAll(async () => {
    server = await createTestServer({
      routesDir: './app/routes',
    })
  })

  afterAll(async () => {
    await server.stop()
  })

  test('GET /api/posts', async () => {
    const response = await server.get('/api/posts')
    expect(response.status).toBe(200)

    const posts = await response.json()
    expect(posts).toHaveLength(3)
  })

  test('POST /api/posts', async () => {
    const response = await server.post('/api/posts', {
      title: 'New Post',
      content: 'Content here',
    })
    expect(response.status).toBe(201)
  })

  test('form submission', async () => {
    const response = await server.submitForm('/api/contact', {
      name: 'Test User',
      email: 'test@example.com',
    })
    expect(response.status).toBe(200)
  })
})

createMockServer

Creates a simple mock server for testing external API integrations. Define routes as key-value pairs where the key is "METHOD /path" and the value is either static data or a handler function. Responses are automatically serialized as JSON.

Signature

ts
function createMockServer(options: {
  routes: Record<string, unknown | ((request: { body?: unknown; params?: Record<string, string> }) => unknown)>
  port?: number
}): Promise<{ url: string; port: number; stop: () => Promise<void> }>

Example

ts
const mockApi = await createMockServer({
  routes: {
    'GET /users/1': { id: 1, name: 'Test User' },
    'POST /users': (req) => ({ id: 2, ...req.body }),
    'GET /users': [{ id: 1 }, { id: 2 }],
  },
})

// In your test, use mockApi.url as the API base URL
process.env.API_URL = mockApi.url

// Run tests...

// After test
await mockApi.stop()

Snapshot Testing

snapshotLoader

Creates a snapshot of loader data. Runs the loader with the provided options and applies snapshot transformations (exclude fields, replace dynamic values, etc.) before returning the data for snapshot comparison.

Signature

ts
function snapshotLoader<T = unknown, P = RouteParams>(
  loader: LoaderFunction<T, P>,
  testOptions?: LoaderTestOptions<P>,
  snapshotOptions?: SnapshotOptions
): Promise<unknown>

SnapshotOptions

ts
interface SnapshotOptions {
  // Fields to exclude from snapshot
  exclude?: string[]

  // Fields to include in snapshot (if specified, only these are included)
  include?: string[]

  // Custom serializer
  serialize?: (data: unknown) => string

  // Replace dynamic values
  replacers?: Record<string, unknown>
}

Example

ts
test('loader snapshot', async () => {
  const snapshot = await snapshotLoader(loader, {
    params: { slug: 'test-post' },
  }, {
    exclude: ['createdAt', 'updatedAt'],
    replacers: { id: '[ID]' },
  })

  expect(snapshot).toMatchSnapshot()
})

snapshotAction

Creates a snapshot of an action's result. Works the same as snapshotLoader but for action functions.

Signature

ts
function snapshotAction<T = unknown, P = RouteParams>(
  action: ActionFunction<T | Response, P>,
  testOptions?: ActionTestOptions<P>,
  snapshotOptions?: SnapshotOptions
): Promise<unknown>

Example

ts
test('action snapshot', async () => {
  const snapshot = await snapshotAction(action, {
    formData: { title: 'Test', content: 'Content' },
  }, {
    exclude: ['id', 'createdAt'],
  })

  expect(snapshot).toMatchSnapshot()
})

createSnapshotMatrix

Creates snapshots for multiple test scenarios at once. Each scenario has a name and a set of loader test options. Returns an object keyed by scenario name, which you can snapshot as a whole.

Signature

ts
function createSnapshotMatrix<T = unknown, P = RouteParams>(
  loader: LoaderFunction<T, P>,
  options: {
    scenarios: Record<string, LoaderTestOptions<P>>
    snapshotOptions?: SnapshotOptions
  }
): Promise<Record<string, unknown>>

Example

ts
const snapshots = await createSnapshotMatrix(loader, {
  scenarios: {
    'loads featured posts': { params: { featured: 'true' } },
    'loads recent posts': { params: { sort: 'recent' } },
    'loads by author': { params: { author: 'test-user' } },
  },
})

expect(snapshots).toMatchSnapshot()

commonReplacers

Pre-defined regex patterns for replacing dynamic values in snapshots. Use these with applyReplacements to make snapshots stable across runs.

ts
const commonReplacers = {
  // Replace ISO date strings
  date: /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/g,

  // Replace UUIDs
  uuid: /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi,

  // Replace numeric IDs
  numericId: /\d+/g,
}

applyReplacements

Applies string replacements to data for snapshot stability. Serializes the data to JSON, performs the replacements, and deserializes it back.

Signature

ts
function applyReplacements(
  data: unknown,
  replacements: Record<string, string>
): unknown

Example

ts
const stableData = applyReplacements(data, {
  [commonReplacers.date]: '[DATE]',
  [commonReplacers.uuid]: '[UUID]',
})

deterministicSnapshot

Creates a deterministic JSON snapshot by sorting all object keys alphabetically. This ensures the snapshot is the same regardless of the order properties were assigned.

Signature

ts
function deterministicSnapshot(data: unknown): string

Example

ts
const snapshot = deterministicSnapshot(result.data)
expect(snapshot).toMatchSnapshot()

Best Practices

1. Use Factory Functions

Create reusable testers for commonly tested functions:

ts
// test/helpers.ts
import { createLoaderTester, createActionTester, createContextFactory } from '@ereo/testing'
import { loader, action } from '../routes/posts/[id]'

export const testUser = { id: '1', name: 'Test User', role: 'admin' }

export const contextFactory = createContextFactory({
  store: { user: testUser },
  env: { DATABASE_URL: 'test://db' },
})

export const testPostLoader = createLoaderTester(loader, {
  context: { store: { user: testUser } },
})

export const testPostAction = createActionTester(action, {
  context: { store: { user: testUser } },
})

2. Test Edge Cases with Matrix Testing

ts
test('handles various inputs', async () => {
  const results = await testLoaderMatrix(loader, {
    params: [
      { id: '1' },           // Valid
      { id: '999' },         // Not found
      { id: 'invalid' },     // Invalid format
    ],
  })

  expect(results[0].data).toBeDefined()
  expect(results[1].data).toBeNull()
  // results[2] might throw — use testLoaderError for that case
})

3. Isolate Tests with Fresh Contexts

ts
test('test 1', async () => {
  const ctx = createTestContext({ store: { count: 0 } })
  // ctx is fresh for this test
})

test('test 2', async () => {
  const ctx = createTestContext({ store: { count: 0 } })
  // ctx is independent from test 1
})

4. Use Assertions for Cleaner Tests

ts
test('login action', async () => {
  const result = await testAction(loginAction, {
    formData: { email: 'test@example.com', password: 'secret' },
  })

  assertRedirect(result.response, '/dashboard')
  assertCookies(result.response, {
    session: { exists: true, httpOnly: true },
  })
})

5. Use Test Server for Integration Tests

ts
describe('full request cycle', () => {
  let server: TestServer

  beforeAll(async () => {
    server = await createTestServer()
  })

  afterAll(async () => {
    await server.stop()
  })

  test('complete flow', async () => {
    // Login
    const loginRes = await server.post('/api/login', {
      email: 'test@example.com',
      password: 'secret',
    })
    const cookies = extractCookies(loginRes)

    // Access protected resource
    const dataRes = await server.get('/api/me', {
      headers: { Cookie: `session=${cookies.session}` },
    })
    expect(dataRes.status).toBe(200)
  })
})

6. Snapshot Dynamic Data Carefully

ts
test('loader snapshot', async () => {
  const snapshot = await snapshotLoader(loader, {
    params: { id: '1' },
  }, {
    exclude: ['createdAt', 'updatedAt', 'id'],
    replacers: {
      authorId: '[AUTHOR_ID]',
    },
  })

  expect(snapshot).toMatchSnapshot()
})

Released under the MIT License.