Skip to content

Testing journeys

A journey with a 2-day wait should be testable in seconds, and checkable against real state without emailing anyone. There are four tools, and they answer different questions:

  • cow test: does the logic do what I meant? Local, instant, nothing real involved.
  • cow dev: does the loop work end to end while I edit it?
  • Dry run: would this one real user get this email, and if not, why not?
  • Development environment: does the whole thing work from my own app, with email captured instead of sent?
Terminal window
cow test abandoned-checkout --scenario scenarios/abandoned-checkout.timeout.json

Local: no API call, nothing sent, no login needed. It builds the project if it is stale, loads the journey’s own bundle, and runs it against a scripted scenario on a virtual clock. Sleeps complete instantly, waitForEvent resolves the moment the scenario’s next matching event is due, and a 30-day journey finishes in milliseconds. Capability calls are recorded and answered with canned results, so nothing is written and no email exists.

A scenario scripts three things: the user, later events at offsets from the start, and the calls the run must make, in order.

scenarios/abandoned-checkout.timeout.json
{
"user": {
"id": "usr_1",
"traits": { "email": "[email protected]", "firstName": "Ada" }
},
"events": [],
"expect": [
{
"activity": "sendEmail",
"input": {
"template": "abandoned-checkout",
"props": { "firstName": "Ada" }
}
}
]
}
scenarios/abandoned-checkout.recovered.json
{
"user": { "id": "usr_1", "traits": { "email": "[email protected]" } },
"events": [{ "at": "1h", "event": "purchase_completed" }],
"expect": [
{
"activity": "setTrait",
"input": { "key": "cart_recovered", "value": true }
}
]
}
  • user is what api.profile.get() returns, and traits.* mutations from the journey update it as the run proceeds.
  • The trigger event is delivered automatically at the start, exactly as production does. events are the later ones, each at an offset ("1h", "30d", or milliseconds).
  • expect names the calls in order, using the activity names sendEmail, sendWebhook, setTrait, unsetTrait, trackEvent, and restart. input matches as a subset of the actual call, so assert the fields you care about and ignore the rest.

The report prints the calls the run made, any mismatches, the events the scenario never delivered, the virtual time the run covered (simulatedMs), and the wall time it took (wallMs). A failure exits non-zero, so cow test belongs in CI beside your unit tests.

Every gallery example ships with its scenarios; cow add example <name> copies them into scenarios/ beside the journey.

Terminal window
cow dev

Watches the project, and on every save builds, pushes, and deploys to development, then tails what the journeys did: new executions with their journey, profile, status and step, log lines from api.log, and each captured email with its rendered subject and a link to the delivery in the dashboard. Build errors print inline and the watcher keeps running.

This is the loop that answers “did my change actually do what I expected against real events”, because the events are real ones from your own development app. See Projects.

Dry run: does it behave against real state?

Section titled “Dry run: does it behave against real state?”

cow test mocks the world; a dry run runs against it. It starts an execution for a real profile with sends disabled:

Terminal window
cow journeys dryRun abandoned-checkout --profile-id usr_…
  • Every send gate is evaluated against real profile, consent, suppression, credit, and destination state.
  • Instead of sending, the delivery log records would_send or a typed would_skip_* entry with the full payload, so “would this user actually get this email, and why not” is answerable from the log. A would_send also carries the rendered message under payload.rendered, the same capture a development send stores.
  • A dry run is terminal by construction: it costs no quota, generates no provider feedback, and is excluded from the journey’s execution counts.

A dry run gets its own workflow id (orgId:environment:journey~dry-run:profileId), deliberately: on the shared id, a dry run in flight would swallow the profile’s real trigger and receive their live events, so developing a journey would silently cancel a customer’s onboarding. The cost is the mirror image: a dry-run execution receives no live events, so its waitForEvent calls run to their timeouts. Script timing with cow test; check state with a dry run.

Development environment: does the whole loop work?

Section titled “Development environment: does the whole loop work?”

Send identify and track from your app through a development app’s source and the journey runs for real, live events included, in the development partition. Email stops at the provider’s door: every gate is evaluated as in production, then the message is rendered and recorded as a would_send with payload.rendered (subject, html, text), which the dashboard shows as a captured message. Webhooks are delivered to the destination URL for real, so a local receiver sees the signed POST. A development run is not metered. See the environments guide for the exact rules.

A journey is a plain module: run is an async function of an event and a capability object, so a unit test can call it with a stub api and assert branching directly, no Cowliss involved. Keep scenarios for the timing behavior a unit test cannot express.