dánwòdánwò

Real stacks

Background jobs

Route your app's Inngest events through danwo, wait for the runs they start, and fault the sends.

Your API sends an event, and a background job does the slow part later: provisioning an account, sending a receipt. inngestService routes those events through danwo, so a test can wait for the jobs to finish and the matrix can break the sends.

Add Inngest to the stack

Give stackEnvironment an inngestService, and give every app that sends or serves Inngest functions its env:

danwo.config.ts
import { defineConfig, inngestService, stackEnvironment } from "@danwo/sdk"

export default defineConfig({
  testDir: "tests",
  readiness: { mode: "page+backend", settle: true },
  async environment() {
    const inngest = await inngestService({
      start: true,
      apps: ["http://localhost:4100/api/inngest"],
    })

    return stackEnvironment({
      services: [inngest],
      apps: [
        {
          name: "api",
          command: "pnpm start",
          url: "http://localhost:4000",
          dependsOn: ["worker"],
          env: { ...inngest.env },
        },
        {
          name: "worker",
          command: "pnpm worker",
          url: "http://localhost:4100",
          ready: { log: "worker ready" },
          env: { ...inngest.env },
        },
      ],
    })
  },
})

inngest.env turns on Inngest's dev mode and points the SDK at inngest.danwo.test. The proxy routes that host to the dev server, so every event a test sends is recorded.

Use a dev server you already run

Leave out start and danwo uses a dev server at url:

OptionWhat it doesDefault
urlwhere the dev server answershttp://127.0.0.1:8288
starttrue runs npx --yes inngest-cli dev; a string is your commandoff
appsyour apps' Inngest endpoints, which the dev server callsnone
settleTimeoutMshow long settle() waits for runs to finish30 seconds
retryIntervalSecondswith start: true, the wait between the attempts of a failed step1 second

A dev server danwo starts gets free ports for its connect gateway as well as for url, so each worker of a run can start its own.

Wait for the jobs

An invariant often checks what a job wrote. Call settle() first: it waits until every run the test's events started has finished.

tests/tasks.danwo.ts
import { test as base, z } from "@danwo/sdk"

const test = base.extend<{
  readonly apps: Readonly<Record<string, string>>
  readonly settle: () => Promise<void>
}>()

const Provisioned = z.array(z.string())

test("adds a task", async ({ app, screen, expect }) => {
  await app.open("/")
  await screen.getByRole("button", { name: "Add task" }).click()
  await expect(screen.getByRole("status")).toHaveText("Task saved")
}).invariant(async ({ apps, settle, expect }) => {
  await settle()

  const provisioned = await fetch(`${apps.worker ?? ""}/provisioned`)

  expect(Provisioned.parse(await provisioned.json())).toEqual(["Write the report"])
})

With readiness: { settle: true }, each step also waits for the runs it started. The flight record lists every run under inngest, with its function, status and duration.

A run that fails for good starts its function's onFailure handler, and settling waits for that too, so an invariant can check what the handler did.

After time travel

time.travel moves your apps' clocks, and Inngest keeps up:

  • Cron functions. Each one that falls due on the way runs once, at the new time, the way a dev server left asleep catches up when it wakes. settle() waits for that run and for the runs its events start.
  • Events. An event your app sends after the jump runs at once. The SDK stamps it with the app's clock, days ahead, and the dev server would otherwise hold it until real time caught up.
tests/digest.danwo.ts · a time travel
test("sends the digest at nine", async ({ app, screen, expect, time }) => {
  await app.open("/digests/new")
  // …schedule it for tomorrow at 09:00…

  await time.travel({ days: 1 })
  await app.open("/digests")
  await expect(screen.getByRole("row", { name: /Weekly notes/ })).toContainText("Sent")
})

danwo runs a due cron function the way the dev server's Invoke button does, so the run's event is inngest/function.invoked, with no data. The v4 SDK checks that event against the schemas of the function's typed event triggers, so a cron function that has one needs an invoke() trigger too:

worker/functions.ts · the scheduler
import { cron, invoke } from "inngest"

inngest.createFunction(
  { id: "scheduler", triggers: [cron("0 * * * *"), DigestScheduled, invoke(z.object({}))] },
  async ({ step }) => {
    /* …find what is due, then fan it out… */
  },
)

A run already asleep in step.sleep or step.sleepUntil still wakes at real time: the dev server keeps its own clock. For work you schedule, store when it is due and let a cron function pick it up, or start the job from a provider's webhook, such as Stripe's trial-ending notice.

Break the sends

danwo matrix faults each event your app sends during the test:

  status 500 on POST inngest.danwo.test/e/dev
  reset-after on POST inngest.danwo.test/e/dev
  delay 3000ms on POST inngest.danwo.test/e/dev
FaultWhat your app meets
status 500the dev server is down
reset-afterthe event was accepted, but the answer was lost; a retrying app sends it twice
delay 3000msa slow send

An app that sends the event once and gives up on a 500 loses the job:

FAIL  status 500 on POST inngest.danwo.test/e/dev
        invariant: expected [] to deeply equal [ 'Write the report' ]

The fix is to retry the send, or to write the job to your own outbox table first and send it from there.

Fail one step from a test

danwo faults what crosses its proxy, so a step fails when the provider call inside it does. To make that happen once in a plain test, arm the fault on the proxy fixture:

tests/digest.danwo.ts · one step failing
test("a send that loses its answer is retried once", async ({ app, proxy, time, settle }) => {
  // The first email is accepted, but the step never hears back, so Inngest retries it.
  proxy.failNext((call) => call.host === "api.resend.com" && call.path === "/emails", {
    kind: "reset-after",
  })
  // …schedule the digest, then travel to its send time…
  await settle()
})

failNext(match, fault, times) faults the next times calls that match (default one), ahead of anything the matrix planned, and is disarmed when the test ends. An invariant then checks the retry did no double work: one email, one delivery row.

Next

On this page

Run it on every pull request

A GitHub check and one comment per pull request, with the flight records attached.

Set up CI