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:
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:
| Option | What it does | Default |
|---|---|---|
url | where the dev server answers | http://127.0.0.1:8288 |
start | true runs npx --yes inngest-cli dev; a string is your command | off |
apps | your apps' Inngest endpoints, which the dev server calls | none |
settleTimeoutMs | how long settle() waits for runs to finish | 30 seconds |
retryIntervalSeconds | with start: true, the wait between the attempts of a failed step | 1 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.
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.
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:
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| Fault | What your app meets |
|---|---|
status 500 | the dev server is down |
reset-after | the event was accepted, but the answer was lost; a retrying app sends it twice |
delay 3000ms | a 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:
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.