Home / Docs / Guides / Testing Processes

Testing Processes

Unit-test BPMN processes in Vitest or Jest with @bpmnkit/engine/testing — job and connector mocks, manual job completion, a virtual clock for timers, BPMN matchers and path coverage. No Docker, no cluster.

Camunda’s own process-testing library, Camunda Process Test, is written for Java, and its JavaScript port starts a Zeebe container. @bpmnkit/engine/testing gives TypeScript teams the same style of test without either. It runs your BPMN on the in-process simulator, so a test file is ordinary Vitest or Jest and a whole suite finishes in milliseconds.

It gives you:

  • Job mocks by job type, with fixed results, failures, BPMN errors or a handler.
  • Manual job completion for tasks you want to drive step by step, user tasks included.
  • Connector mocks that map a fake response through the task’s resultVariable and resultExpression, the same way the connector runtime does.
  • A virtual clock. advanceTime("P1D") fires timers at once, with no real waiting.
  • Matchers such as toHaveCompleted(), toHavePassed([...]) and toHaveVariables({...}).
  • Path coverage of the flow nodes and sequence flows your runs reached.
  • AI agent mocks that script or replay which tools an agent calls. See Testing AI Agents.

The simulator is not Zeebe. Before you rely on a test, read what the simulator does not execute.

Install

npm install --save-dev @bpmnkit/engine vitest

vitest is an optional peer dependency. It is needed only for the @bpmnkit/engine/testing/vitest entry point. Jest users do not need it.

A complete Vitest example

// order-process.test.ts
import { afterAll, beforeAll, expect, it } from "vitest"
import "@bpmnkit/engine/testing/vitest"
import { createProcessTest, formatCoverage } from "@bpmnkit/engine/testing"
import type { ProcessTest } from "@bpmnkit/engine/testing"

let t: ProcessTest

beforeAll(async () => {
  t = await createProcessTest({
    bpmn: new URL("./order-process.bpmn", import.meta.url),
    dmn: new URL("./discount.dmn", import.meta.url), // optional
  })
})

afterAll(() => {
  console.log(formatCoverage(t.coverage()))
  t.dispose()
})

it("ships a paid order", async () => {
  t.mockJob("payment", { result: { paid: true } })

  const run = await t.start("order-process", { amount: 10 })
  expect(run).toBeWaitingAt("ship") // "ship" has no mock, so its job waits

  await run.completeJob("ship", { trackingId: "1Z999" })

  expect(run).toHaveCompleted()
  expect(run).toHavePassedInOrder(["payment", "ship"])
  expect(run).toHaveVariables({ paid: true, trackingId: expect.any(String) })
})

it("cancels an unpaid order", async () => {
  t.mockJob("payment", { result: { paid: false } })

  const run = await t.start("order-process", { amount: 10 })

  expect(run).toHaveCompleted()
  expect(run).toHaveNotPassed(["ship"])
})

createProcessTest accepts parsed definitions, XML text, a file path or a file: URL for bpmn, dmn and forms. Each can also be an array. A relative path resolves against the working directory, so new URL("./x.bpmn", import.meta.url) is the reliable choice.

start() and every action on a run (completeJob, publishMessage, advanceTime and the others) return after the process has run as far as it can without more input. You never need to add a wait or a poll.

Vitest setup

Import @bpmnkit/engine/testing/vitest once. This registers the matchers and adds their types to Vitest’s Assertion. To register them for every file, use a setup file:

// vitest.config.ts
import { defineConfig } from "vitest/config"

export default defineConfig({
  test: { setupFiles: ["@bpmnkit/engine/testing/vitest"] },
})

If you use a setup file, add import "@bpmnkit/engine/testing/vitest" to a .d.ts file in your project too, so that TypeScript sees the matcher types.

Jest setup

The matchers are plain expect.extend matchers. Register them in a setup file and add their types to Jest’s Matchers:

// jest.setup.ts  (add to "setupFilesAfterEnv" in jest.config)
import { expect } from "@jest/globals"
import { bpmnMatchers } from "@bpmnkit/engine/testing"
import type { BpmnMatchers } from "@bpmnkit/engine/testing"

expect.extend(bpmnMatchers)

declare module "expect" {
  interface Matchers<R> extends BpmnMatchers<R> {}
}

If you use @types/jest globals, declare the types with declare global { namespace jest { interface Matchers<R> extends BpmnMatchers<R> {} } }. The package ships ES modules, so run Jest in its ESM mode or through a transformer that handles ESM.

Jobs

Each job type in the deployed BPMN gets a worker. What the worker does depends on whether the type has a mock:

  • Mocked: the mock handles the job.
  • Not mocked: the job waits. You complete it with run.completeJob(...), as Zeebe would wait for a worker.
t.mockJob("payment", { result: { paid: true } })                   // complete with variables
t.mockJob("payment", { fail: "card declined" })                    // fail the job
t.mockJob("payment", { throwError: { code: "DECLINED" } })         // throw a BPMN error
t.mockJob("payment", (job) => ({ paid: job.variables.amount < 100 })) // compute; throw to fail

const payment = t.mockJob("payment", { result: { paid: true } })
payment.calls           // every job the mock handled: elementId, variables, headers, ...
payment.restore()       // later jobs of this type wait for completeJob again

To drive a waiting job, name its element id or its job type:

run.jobs                                   // [{ elementId: "ship", type: "ship", ... }]
await run.completeJob("ship", { trackingId: "1Z999" })
await run.failJob("ship", "carrier down")
await run.throwError("ship", "NO_STOCK", "Out of stock")

A Camunda user task (zeebe:userTask, no job type) is a job of type userTask. Complete it by its element id: await run.completeJob("review", { approved: true }). If no job is waiting at the id, the error lists the jobs and elements that are waiting.

Connectors

An outbound connector is a job whose type is the connector’s id. Use mockConnector with the response the connector would return. The element’s resultVariable and resultExpression headers map that response into variables:

t.mockConnector("io.camunda:http-json:1", {
  response: { status: 200, body: { main: { temp: 21.5 } } },
})
// resultVariable "weather"          → weather = the whole response
// resultExpression "={temp: body.main.temp}" → temp = 21.5

The response’s fields are in scope in resultExpression, and response is too. The expression must produce a context. A null result maps nothing. A handler (job) => response and the { fail } and { throwError } forms also work. mapConnectorResponse(response, headers) is exported if you want the mapping on its own. errorExpression is not evaluated.

AI agents

An AI Agent sub-process runs as a job of type io.camunda.agenticai:aiagent-job-worker:1. mockAiAgent scripts the model’s turns: which tools it calls, with which arguments, and its final answer. The tools inside the ad-hoc sub-process then run like any other task:

const agent = t.mockAiAgent("support-agent", [
  { toolCalls: [{ name: "lookup-order", arguments: { orderId: "1042" } }] },
  { responseJson: { answer: "It ships tomorrow.", resolved: true } },
])
const run = await t.start("support", { customerMessage: "Where is order 1042?" })
expect(agent).toHaveCalledTools([{ name: "lookup-order", arguments: { orderId: "1042" } }])

A script can also be a recorded cassette file, and coverage counts the tools that ran. See Testing AI Agents. To mock the agent as a black box, use mockJob with the output you want: { result: { agent: { responseText: "Refund approved" } } }.

Messages

const run = await t.start("order-process")
await run.publishMessage("payment-confirmed") // the bpmn:message name, or its id

A message goes to the run you publish it on. If nothing in that run waits for the message, publishMessage throws. Gaps: a message cannot carry variables, and correlation keys are not evaluated. To set the variables a message would carry, complete an earlier job with them.

The virtual clock

A ProcessTest puts engine timers on a virtual clock. A timer fires only when you advance the clock:

const t = await createProcessTest({ bpmn, startTime: "2026-06-01T00:00:00Z" })
const run = await t.start("reminder-process")
expect(run).toBeWaitingAt("wait_one_day")

await t.advanceTime("PT23H")   // ISO 8601 duration, or milliseconds
expect(run).toBeWaitingAt("wait_one_day")

await t.advanceTime("PT1H")
expect(run).toHaveCompleted()
t.now()                        // 2026-06-02T00:00:00.000Z

advanceTime fires the timers that fall due in order. After each timer, the processes run before the next timer fires. Thus a boundary timer that interrupts a task has an effect before a later timer fires. run.advanceTime(...) is the same call. startTime sets where the clock starts. The default is the real time when the test is created. Set it when a timer uses an absolute timeDate.

The test helpers do not use fake timers, so vi.useFakeTimers() is not necessary. It also does not block them if your own code uses it. Call dispose() in afterAll: it returns engine timers to the real clock and cancels the runs that are still active.

Coverage

t.coverage() counts the flow nodes that the file’s runs entered and the sequence flows they took:

const report = t.coverage()
report.elements        // { total: 14, covered: 12, percent: 85.7, uncovered: ["end_cancel", ...] }
report.flows           // the same, for sequence flows
report.tools           // the same, for the tools of AI agents
report.processes       // one entry per deployed process

console.log(formatCoverage(report))
// BPMN coverage
//   order-process  elements 12/14 (85.7%)  flows 13/15 (86.7%)
//     elements not reached: end_cancel, notify_customer
//     flows not taken: Flow_unpaid, Flow_notify

The simulator’s events name elements but not flows. For this reason, the taken flows are inferred: a parallel join counts all its incoming flows, and any other element counts the incoming flow from the source that completed most recently. For sequential paths the result is exact. Elements inside sub-processes are counted, and data objects are not.

Matchers

MatcherPasses when
toHaveCompleted()The instance ended normally
toHaveFailed(error?)The instance failed. error is a substring or a RegExp
toBeWaitingAt(ids)Each listed element holds a token now
toHavePassed(ids)Each listed element completed at least once, in any order
toHavePassedInOrder(ids)The elements completed in this order. Other elements can come between them
toHaveNotPassed(ids)None of the listed elements completed
toHaveVariables(vars)Each listed variable is equal. Asymmetric matchers such as expect.any(Number) work
toHaveCalledTools(calls)On a mockAiAgent handle: the agent called exactly these tools, in this order. See Testing AI Agents

Every matcher works with .not. When a matcher fails, the message gives the run’s state, its error, the elements that wait and the elements that completed.

What the simulator does not execute

The tests are only as good as the simulator’s coverage of your model. Read Conformance for the element-by-element list. The gaps most likely to affect a test are:

  • Some elements complete without their semantics, or are not modelled. See the conformance table for which elements are affected.
  • A BPMN error thrown from a job ({ throwError } or run.throwError) fails the instance. An error boundary event on the task does not catch it.
  • A variable that is first written inside an embedded sub-process stays local to that sub-process and is lost when it completes. Zeebe propagates it to the process. To keep such a variable, pass it as a start variable.
  • Message variables and correlation keys (see Messages).
  • Inner activities of an ad-hoc sub-process without a task definition. An AI agent’s tools do run (see AI agents).

Engine timers are module-level, so the ProcessTest created last drives them until it is disposed. Use one ProcessTest for each test file. This is the beforeAll pattern above. Vitest runs each file in isolation.

Zeebe semantics (future)

@bpmnkit/engine/wasm-runner runs .bpmn.tests.json scenarios on Reebe, which has more of Zeebe’s semantics. A mode: "wasm" option for createProcessTest is planned but not yet available. The runner drives a scenario from start to end in one call; casen test uses it, and every scenario in the template gallery passes on it.

See also

  • Simulation: the Engine API under these helpers.
  • casen test: runs the .bpmn.tests.json scenario sidecars that casen synth writes, without a test runner.