Runlane
Build with RunlaneDevelop and test

Test tasks

Test public task behavior with an isolated runtime and controlled time.

Test through Runlane's public API: trigger the task, execute it, and assert the domain effect or stored result. The Local lane gives each test isolated in-memory state.

Run the complete example

This is the same test file executed by the documentation example package:

src/tasks/send-email.test.ts
import { createRunlane, queue, RunStatus, task } from '@runlane/core'
import { createLocalLane } from '@runlane/lane-local'
import { createControlledClock } from '@runlane/testing'
import { expect, test } from 'vitest'
import * as z from 'zod'

test('sends a welcome email', async () => {
  // Given
  const sentEmails: string[] = []
  const sendWelcomeEmail = task({
    id: 'email.send-welcome',
    schema: z.object({ email: z.email() }),
    run({ email }) {
      sentEmails.push(email)
    },
  })
  const runlane = createRunlane({
    lane: createLocalLane(),
    queues: [queue({ name: 'default', default: true })],
    tasks: { sendWelcomeEmail },
  })

  // When
  const { run } = await runlane.trigger(runlane.tasks.sendWelcomeEmail, {
    email: 'ada@example.com',
  })
  await runlane.drain()
  const storedRun = await runlane.runs.get(run.id)

  // Then
  expect(sentEmails).toEqual(['ada@example.com'])
  expect(storedRun?.status).toBe(RunStatus.Succeeded)

  await runlane.close()
})

test('controls time without sleeping', () => {
  // Given
  const clock = createControlledClock({
    startAt: new Date('2026-01-01T00:00:00.123Z'),
  })

  // When
  clock.advanceBy('5m')

  // Then
  expect(clock.now()).toEqual(new Date('2026-01-01T00:05:00.123Z'))
})

The first test follows Given–When–Then:

  1. Define the task and create a fresh runtime.
  2. Trigger the registered task handle.
  3. Drain currently due work.
  4. Assert the domain effect.
  5. Assert only the run state needed by the contract.

Close the runtime at the end. Use your test framework's cleanup hook if an assertion can stop execution before the explicit close.

Control time instead of sleeping

createControlledClock() changes time only when the test calls advanceBy(). Pass that clock to createRunlane() when retries, releases, schedules, leases, or token timeouts depend on time.

Use a start time with milliseconds. It catches precision bugs that a whole-second clock can hide.

Advancing the clock does not run work by itself. Call the relevant maintenance or worker method after the time change.

Keep tests isolated

  • Create the lane and runtime inside the test or a fresh fixture.
  • Do not share mutable provider state across cases.
  • Use a unique Runlane environment for shared external infrastructure.
  • Assert domain behavior before internal lifecycle detail.
  • Use real Postgres or SQS-compatible tests for provider timing and redelivery boundaries.

Test the failures that matter

Add cases for the behavior your task uses:

  • invalid payload and output;
  • retry exhaustion and non-retryable failure;
  • release and resume;
  • cancellation and worker shutdown;
  • durable-step replay;
  • duplicate idempotency keys;
  • queue concurrency;
  • wait-token completion and timeout.

Avoid tests that call private transition helpers. Public behavior survives refactors and proves the integration users depend on.

On this page