Skip to Content
Fusee Docs
Guides

Testing your workflows

The test kit gives a test an engine in memory under the dev profile: every run's history is kept, each run can be replayed against your code (strict replay), and the result of a stuck run fails at once instead of waiting.

Rust: fusee::testing

[dev-dependencies]
fusee = { version = "1.0.0", features = ["testing"] }

The feature exists only for tests: a build without it carries none of the kit's hooks, so the step path is the same as in production.

use fusee::testing::TestEngine;
use std::time::Duration;

#[tokio::test]
async fn renews_after_a_day() -> fusee::Result<()> {
    let t = TestEngine::open().await?;
    t.register(RENEW, renew);
    // Replace a step's function by workflow type and step name.
    t.mock_step(RENEW, "charge", |_attempt| Ok(serde_json::json!("ch_1")));
    t.engine().start(RENEW, "r-1", &"pro".to_string()).await?;
    // The workflow sleeps a day: while every run waits on a timer, result()
    // moves the virtual clock to it, so this takes milliseconds.
    let out: String = t.result("r-1").await?;
    assert_eq!(out, "pro:42:ch_1");
    assert_eq!(t.steps("r-1").await?, ["quote", "charge"]);
    t.check().await?; // strict replay of every run
    Ok(())
}
TestEngine::open(), with_shards(n)a memory engine, dev profile, nothing served
register(wf, f)as Engine::register, and again after each crash
mock_step(wf, step, f)f(attempt) returns the step's result, or Err(Failure) to fail that attempt (retried under the step's policy unless final)
advance(d), now_ms()move the virtual clock; timers that fall due fire
result(id)the result; while every run waits on a timer (and no step function waits on outside work), time skips to it
crash()drop the engine's memory and recover every run from its journal: the code replays, recorded steps do not run again
steps(id), journal(id), snapshot(id)what the run recorded; snapshot is stable JSON for snapshot tests
check()replays every run against the registered code; an error names each run and step that differ

Finding steps that are not idempotent

A step's function can run twice: when the process dies after the function ran and before its result was recorded, the step runs again after recovery. crash_everywhere runs a workflow once per step boundary with such a crash at each, and requires the same result and the same outside effects as a run without one:

use fusee::testing::{crash_everywhere, Case};

#[tokio::test]
async fn checkout_survives_a_crash_anywhere() {
    crash_everywhere(|t| async move {
        let payments = Arc::new(FakePayments::default()); // fresh per run
        t.register(CHECKOUT, checkout_with(payments.clone()));
        Ok(Case {
            workflow: "checkout".into(),
            id: "order-1".into(),
            input: serde_json::json!({"cents": 500}),
            effects: Box::new(move || payments.snapshot()),
        })
    })
    .await
    .unwrap();
}

A step that charges a card without passing ctx.key() to the payment service as an idempotency key fails with a message naming the step and both sets of effects.

Python: fusee.testing

from fusee.testing import TestEngine

async def test_renewal():
    t = await TestEngine.open(workflows=[renew])
    t.mock_step(renew, "charge", lambda attempt: {"id": "ch_1"})
    await t.start(renew, "r-1", "pro")
    # The workflow sleeps a day: result() skips time while every run waits.
    assert await t.result("r-1") == "pro:ch_1"
    assert await t.steps("r-1") == ["quote", "charge"]
    await t.crash()          # drop memory, recover from the journals
    await t.check()          # strict replay of every run
    await t.close()

mock_step takes a function of the attempt number; raising fails that attempt. advance("1d") moves the clock; result() moves it to the next timer while no step function is running. crash_everywhere(build) takes a function that returns a Case(workflows, workflow, id, input, effects) with fresh fakes and raises AssertionError naming the step whose rerun changed the result or the effects.

TypeScript: @bitsinfra/fusee/testing

import { TestEngine, crashEverywhere } from "@bitsinfra/fusee/testing";

const t = await TestEngine.open({ workflows: [renew] });
t.mockStep(renew, "charge", (attempt) => ({ id: "ch_1" }));
await t.start(renew, "r-1", "pro");
assert.equal(await t.result("r-1"), "pro:ch_1"); // a day's sleep, skipped
await t.crash();
await t.check();
await t.close();

await crashEverywhere(() => {
  const payments = new FakePayments(); // fresh per run
  return { workflows: [checkoutWith(payments)], workflow: "checkout", id: "o-1", input: order, effects: () => payments.snapshot() };
});

The virtual clock of the Python and TypeScript kits is the process's: one test engine at a time.

On this page