The Itinera bookRust edition

Part 4: Running it for real, Chapter 29

Testing the flow with scripted steps

What you want to do

You want to check the shop's flow: that a declined card is rescued, that a parcel over the limit is given up after its first attempt, and that a charge ending in an error is never rescued. Each test should say how the steps end, without setting up a bank or a parcel that makes them end that way.

The code

The program is chapter 25's shop, with its tests in a module that only a test build compiles, as in chapter 27. Each test makes an instance of the shop's workflow for one of Ann's orders. The workflow is declared as chapter 23 shows it, with the policy outage from chapter 17:

Rust, from the program ch29_testing_the_flow_with_scripted_steps
/// An instance of the shop's workflow for an order of 30, with this card and this parcel.
fn order(order: &str, card: &str, weight: i64) -> Result<Instance<Orders>, Error> {
    Ok(Orders::builder()
        .channel("email")
        .initial_data(NewOrder {
            order: order.to_owned(),
            customer: "Ann".to_owned(),
            price: 30,
            discount: None,
            parcel: Parcel {
                weight,
                size: [20, 15, 5],
            },
            card: Some(card.to_owned()),
            gift_card: false,
        })
        .create()?)
}
Rust, the same in its asynchronous form, from the program ch29_testing_the_flow_with_scripted_steps_async
/// An instance of the shop's workflow for an order of 30, with this card and this parcel.
fn order(
    order: &str,
    card: &str,
    weight: i64,
) -> Result<Instance<Orders, Asynchronous>, Error> {
    Ok(Orders::builder()
        .channel("email")
        .initial_data(NewOrder {
            order: order.to_owned(),
            customer: "Ann".to_owned(),
            price: 30,
            discount: None,
            parcel: Parcel {
                weight,
                size: [20, 15, 5],
            },
            card: Some(card.to_owned()),
            gift_card: false,
        })
        .create()?)
}

NewOrder is as chapter 25 shows it, and Parcel as chapter 21 does.

The first test runs the instance with a script. scripted(), a method of every workflow instance that comes with the testing feature, starts it. step names a step, and what follows says what its attempts do in place of running it: what they contribute, and how they end. on_attempt moves on to another attempt of the same step, and_step to another step, and run runs the journey:

Rust, from the program ch29_testing_the_flow_with_scripted_steps
#[test]
fn a_declined_card_is_renewed_and_the_charge_tried_again_with_it() -> Result<(), Error> {
    let run = order("A-1", "ann-2024", 1)?
        .scripted()
        .step("charge")
        .will_fail("card-expired")
        .on_attempt(2)
        .will_contribute("receipt", "R-A-1".to_owned())
        .will_succeed()
        .and_step("charge_rescue")
        .will_contribute("card", "ann-2029".to_owned())
        .will_succeed()
        .and_step("ship")
        .will_contribute("tracking", "T-A-1".to_owned())
        .will_succeed()
        .run()?;
    assert!(matches!(run.result.status, JourneyStatus::Succeeded { .. }));
    let retried = run.step("charge").attempt(2);
    assert_eq!(
        retried.map(|attempt| attempt.input::<String>("card")),
        Some(Read::Present("ann-2029".to_owned()))
    );
    Ok(())
}
Rust, the same in its asynchronous form, from the program ch29_testing_the_flow_with_scripted_steps_async
#[tokio::test]
async fn a_declined_card_is_renewed_and_the_charge_tried_again_with_it() -> Result<(), Error> {
    let run = order("A-1", "ann-2024", 1)?
        .scripted()
        .step("charge")
        .will_fail("card-expired")
        .on_attempt(2)
        .will_contribute("receipt", "R-A-1".to_owned())
        .will_succeed()
        .and_step("charge_rescue")
        .will_contribute("card", "ann-2029".to_owned())
        .will_succeed()
        .and_step("ship")
        .will_contribute("tracking", "T-A-1".to_owned())
        .will_succeed()
        .run()
        .await?;
    assert!(matches!(run.result.status, JourneyStatus::Succeeded { .. }));
    let retried = run.step("charge").attempt(2);
    assert_eq!(
        retried.map(|attempt| attempt.input::<String>("card")),
        Some(Read::Present("ann-2029".to_owned()))
    );
    Ok(())
}

The next three run one script, in which the shipping's first attempt fails with the reason each test chooses, and the last of them reads what the shipping was built with:

Rust, from the program ch29_testing_the_flow_with_scripted_steps
/// The reason a policy failed the journey with, if one did.
fn failed_by_a_policy(status: &JourneyStatus) -> Option<&str> {
    match status {
        JourneyStatus::Failed {
            failure: Failure::FailWorkflow(reason),
            ..
        } => Some(reason.code()),
        _ => None,
    }
}

/// Runs an order of 60 kg whose shipping fails on attempt 1 with this reason, and would
/// succeed on attempt 2.
fn shipping_that_fails_with(reason: &str) -> Result<ScriptedRun, Error> {
    Ok(order("A-2", "ann-2028", 60)?
        .scripted()
        .step("charge")
        .will_contribute("receipt", "R-A-2".to_owned())
        .will_succeed()
        .and_step("ship")
        .will_fail(reason)
        .on_attempt(2)
        .will_contribute("tracking", "B-A-2".to_owned())
        .will_succeed()
        .run()?)
}

#[test]
fn a_shipping_that_fails_with_too_heavy_is_tried_again() -> Result<(), Error> {
    let run = shipping_that_fails_with("too-heavy")?;
    assert_eq!(run.step("ship").attempts().len(), 2);
    assert!(matches!(run.result.status, JourneyStatus::Succeeded { .. }));
    Ok(())
}

#[test]
fn a_shipping_that_fails_with_over_parcel_limit_is_given_up_after_attempt_1()
-> Result<(), Error> {
    let run = shipping_that_fails_with("over-parcel-limit")?;
    assert_eq!(run.step("ship").attempts().len(), 1);
    assert_eq!(
        failed_by_a_policy(&run.result.status),
        Some("no-carrier-can-take-it")
    );
    Ok(())
}

#[test]
fn a_scripted_shipping_is_still_built_with_the_weight_the_adapter_supplies() -> Result<(), Error>
{
    let run = shipping_that_fails_with("too-heavy")?;
    let first = run.step("ship").attempt(1);
    assert_eq!(
        first.map(|attempt| attempt.input::<i64>("weight")),
        Some(Read::Present(60))
    );
    Ok(())
}
Rust, the same in its asynchronous form, from the program ch29_testing_the_flow_with_scripted_steps_async
/// The reason a policy failed the journey with, if one did.
fn failed_by_a_policy(status: &JourneyStatus) -> Option<&str> {
    match status {
        JourneyStatus::Failed {
            failure: Failure::FailWorkflow(reason),
            ..
        } => Some(reason.code()),
        _ => None,
    }
}

/// Runs an order of 60 kg whose shipping fails on attempt 1 with this reason, and would
/// succeed on attempt 2.
async fn shipping_that_fails_with(reason: &str) -> Result<ScriptedRun, Error> {
    Ok(order("A-2", "ann-2028", 60)?
        .scripted()
        .step("charge")
        .will_contribute("receipt", "R-A-2".to_owned())
        .will_succeed()
        .and_step("ship")
        .will_fail(reason)
        .on_attempt(2)
        .will_contribute("tracking", "B-A-2".to_owned())
        .will_succeed()
        .run()
        .await?)
}

#[tokio::test]
async fn a_shipping_that_fails_with_too_heavy_is_tried_again() -> Result<(), Error> {
    let run = shipping_that_fails_with("too-heavy").await?;
    assert_eq!(run.step("ship").attempts().len(), 2);
    assert!(matches!(run.result.status, JourneyStatus::Succeeded { .. }));
    Ok(())
}

#[tokio::test]
async fn a_shipping_that_fails_with_over_parcel_limit_is_given_up_after_attempt_1()
-> Result<(), Error> {
    let run = shipping_that_fails_with("over-parcel-limit").await?;
    assert_eq!(run.step("ship").attempts().len(), 1);
    assert_eq!(
        failed_by_a_policy(&run.result.status),
        Some("no-carrier-can-take-it")
    );
    Ok(())
}

#[tokio::test]
async fn a_scripted_shipping_is_still_built_with_the_weight_the_adapter_supplies()
-> Result<(), Error> {
    let run = shipping_that_fails_with("too-heavy").await?;
    let first = run.step("ship").attempt(1);
    assert_eq!(
        first.map(|attempt| attempt.input::<i64>("weight")),
        Some(Read::Present(60))
    );
    Ok(())
}

The last test ends the charge's first attempt in an error, as the bank's would:

Rust, from the program ch29_testing_the_flow_with_scripted_steps
#[test]
fn a_charge_that_ends_in_an_error_is_given_up_and_never_rescued() -> Result<(), Error> {
    let run = order("A-3", "ann-2028", 1)?
        .scripted()
        .step("charge")
        .will_terminate_abnormally("the payment network is down")
        .on_attempt(2)
        .will_contribute("receipt", "R-A-3".to_owned())
        .will_succeed()
        .and_step("charge_rescue")
        .will_succeed()
        .run()?;
    assert!(run.step("charge_rescue").attempts().is_empty());
    assert_eq!(
        failed_by_a_policy(&run.result.status),
        Some("payment-network-down")
    );
    Ok(())
}
Rust, the same in its asynchronous form, from the program ch29_testing_the_flow_with_scripted_steps_async
#[tokio::test]
async fn a_charge_that_ends_in_an_error_is_given_up_and_never_rescued() -> Result<(), Error> {
    let run = order("A-3", "ann-2028", 1)?
        .scripted()
        .step("charge")
        .will_terminate_abnormally("the payment network is down")
        .on_attempt(2)
        .will_contribute("receipt", "R-A-3".to_owned())
        .will_succeed()
        .and_step("charge_rescue")
        .will_succeed()
        .run()
        .await?;
    assert!(run.step("charge_rescue").attempts().is_empty());
    assert_eq!(
        failed_by_a_policy(&run.result.status),
        Some("payment-network-down")
    );
    Ok(())
}

In the asynchronous form, which tests chapter 26's shop, the instance is an Instance<Orders, Asynchronous>, and each test is an async fn marked #[tokio::test] that awaits the scripted run, written the same way.

The shop's log, its desk and its messages still print, since the workflow is the shop's own, but cargo test hides what a passing test prints. It prints the same for both forms:

What running the tests of ch29_testing_the_flow_with_scripted_steps prints, recorded in ch29_testing_the_flow_with_scripted_steps.txt
running 5 tests
test tests::a_charge_that_ends_in_an_error_is_given_up_and_never_rescued ... ok
test tests::a_declined_card_is_renewed_and_the_charge_tried_again_with_it ... ok
test tests::a_scripted_shipping_is_still_built_with_the_weight_the_adapter_supplies ... ok
test tests::a_shipping_that_fails_with_over_parcel_limit_is_given_up_after_attempt_1 ... ok
test tests::a_shipping_that_fails_with_too_heavy_is_tried_again ... ok

test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

How it works

  1. The script replaces only a step's run. Every step is still built for real, with its inputs and its services: the shipping's first attempt was built with the weight the adapter supplied, 60. A step the script doesn't name runs for real, as the lookup and the pricing do here. Everything else is the executor's, as in any journey: the hooks, the policies, the decisions, the events and the result.
  2. A script names steps by their names in the workflow. A rescue step is named by its own name, charge_rescue. will_fail, will_terminate_abnormally and will_succeed end an attempt, and will_contribute adds a contribution before it ends, which the executor commits only if the attempt succeeds. An attempt on_attempt doesn't name is attempt 1.
  3. The script must cover what can happen. run checks it before the journey starts, and panics, listing what is missing, if a scripted step leaves out an attempt it can have, after a retry or a rescue, or if a scripted step can fail on attempt 1 and its rescue step has no script. So the shipping's script says what its attempt 2 would do, and the script of the charge that ends in an error says what the rescue step and the charge's attempt 2 would do. The tests then show the workflow never got there.
  4. The run says what each step went through. run returns a ScriptedRun: its result is the result an executor would have given, and step reads a step's attempts. attempt(2) of the charge holds the inputs it was built with, read with input: the card the rescue step contributed. Attempts that never ran aren't there, so the rescue step of the charge that ended in an error has none.
  5. The same script tests one decision of the flow. Two tests run the shipping's script with only the reason its first attempt gives changed. With too-heavy, the shipping is retried and the order succeeds. With over-parcel-limit, failed_shipping gives it up, and the result carries the cause FailWorkflow with the policy's reason, Failure::FailWorkflow in Rust.

So the flow is tested apart from the steps' rules: a change to the charge's rules never breaks these tests, and a change to the workflow is caught by them. But neither kind of test runs one real journey from start to end, with the shop's real steps and its services faked.

Outcomes

We have shown how to test a workflow's flow with scripted steps, checked before the journey runs.

The usual way With Itinera
Testing what happens after a failure often means making a real dependency fail, or mocking every call the code makes. A script says how each attempt of a step ends, and the executor runs the rest of the journey as it would.
A test of the flow often breaks when a business rule changes. Scripted steps never run their rules, so a change to those rules never changes what these tests see.