The Itinera bookRust edition

Part 4: Running it for real, Chapter 27

Testing a step alone

What you want to do

The shop's business rules live in its steps: what a charge does when the bank declines, and which parcels the shipping refuses. You want to check each of those rules on its own, with no journey, no real bank and no real carrier.

The code

The tests use recorders that come with the testing feature of itinera. You turn it on for tests only, by naming itinera once more under [dev-dependencies] in Cargo.toml, with features = ["testing"], so the program you ship never has them.

The program is chapter 25's shop, unchanged, with its tests at the end, in a module of their own. #[cfg(test)] makes it a module that only a test build compiles: the shop's own build leaves it out, so only the tests may use the testing feature, and use super::* gives them the shop's steps:

Rust, from the program ch27_testing_a_step_alone
#[cfg(test)]
mod tests {
    use itinera::journey::{Contributions, Read};
    use itinera::step::StepEventReporter;

    use super::*;

The steps are the shop's own, unchanged since they were shown: the charge in chapter 20, its rescue step in chapter 14 and the shipping in chapter 17. This chapter shows only their tests.

A step meets the outside world only through its services, so the tests give it fakes of their own, inside the module, implementing the services Payments and Carrier, with their errors, as chapter 14 and chapter 12 declared them. These two answer as the test chooses:

Rust, from the program ch27_testing_a_step_alone
/// A bank for tests: it answers every charge as the test chose, and has replaced every card
/// with one named after its holder that expires in 2029.
enum TestBank {
    Charges,
    Declines,
    IsDown,
}

impl Payments for TestBank {
    fn charge(&self, order: &str, _: &str, _: i64) -> Result<Option<String>, NetworkDown> {
        match self {
            TestBank::Charges => Ok(Some(format!("R-{order}"))),
            TestBank::Declines => Ok(None),
            TestBank::IsDown => Err(NetworkDown),
        }
    }

    fn replacement(&self, card: &str) -> Option<String> {
        let (holder, _) = card.rsplit_once('-')?;
        Some(format!("{holder}-2029"))
    }
}

/// A carrier for tests: it books and collects every parcel, or its booking system is down.
enum TestCarrier {
    Works,
    IsDown,
}

impl Carrier for TestCarrier {
    fn book(&self, order: &str, _: i64) -> Result<String, BookingFailed> {
        match self {
            TestCarrier::Works => Ok(format!("T-{order}")),
            TestCarrier::IsDown => Err(BookingFailed),
        }
    }

    fn collects(&self, _: i64) -> bool {
        true
    }
}
Rust, the same in its asynchronous form, from the program ch27_testing_a_step_alone_async
/// A bank for tests: it answers every charge as the test chose, and has replaced every card
/// with one named after its holder that expires in 2029.
enum TestBank {
    Charges,
    Declines,
    IsDown,
}

#[async_trait]
impl Payments for TestBank {
    async fn charge(
        &self,
        order: &str,
        _: &str,
        _: i64,
    ) -> Result<Option<String>, NetworkDown> {
        match self {
            TestBank::Charges => Ok(Some(format!("R-{order}"))),
            TestBank::Declines => Ok(None),
            TestBank::IsDown => Err(NetworkDown),
        }
    }

    async fn replacement(&self, card: &str) -> Option<String> {
        let (holder, _) = card.rsplit_once('-')?;
        Some(format!("{holder}-2029"))
    }
}

/// A carrier for tests: it books and collects every parcel, or its booking system is down.
enum TestCarrier {
    Works,
    IsDown,
}

#[async_trait]
impl Carrier for TestCarrier {
    async fn book(&self, order: &str, _: i64) -> Result<String, BookingFailed> {
        match self {
            TestCarrier::Works => Ok(format!("T-{order}")),
            TestCarrier::IsDown => Err(BookingFailed),
        }
    }

    async fn collects(&self, _: i64) -> bool {
        true
    }
}

Each test is a function marked #[test], named after the rule it checks, which fails when an assert! or assert_eq! doesn't hold. The charge is tested by building it with Ann's order, as the executor would, and calling its run. Contributions, from itinera::journey, lends the contributor run asks for, and keeps what the step contributes, read back with read:

Rust, from the program ch27_testing_a_step_alone
/// Ann's charge of 40 to her card.
fn anns_charge() -> Charge {
    Charge {
        order: "A-1".to_owned(),
        amount: 40,
        card: "ann-2028".to_owned(),
        gift_card: false,
    }
}

#[test]
fn a_charge_the_bank_takes_gives_back_the_banks_receipt() -> Result<(), Error> {
    let mut contributions = Contributions::default();
    let outcome = anns_charge().run(&TestBank::Charges, contributions.contributor())?;
    assert!(outcome.is_success());
    assert_eq!(
        contributions.read::<String>("receipt"),
        Read::Present("R-A-1".to_owned())
    );
    Ok(())
}

#[test]
fn a_charge_the_bank_declines_fails_with_card_expired_and_no_receipt() -> Result<(), Error> {
    let mut contributions = Contributions::default();
    let outcome = anns_charge().run(&TestBank::Declines, contributions.contributor())?;
    assert_eq!(outcome.reason().map(Reason::code), Some("card-expired"));
    assert_eq!(contributions.read::<String>("receipt"), Read::Absent);
    Ok(())
}

#[test]
fn a_charge_lets_the_error_of_a_bank_that_is_down_escape() {
    let mut contributions = Contributions::default();
    let ended = anns_charge().run(&TestBank::IsDown, contributions.contributor());
    assert!(ended.is_err());
}
Rust, the same in its asynchronous form, from the program ch27_testing_a_step_alone_async
/// Ann's charge of 40 to her card.
fn anns_charge() -> Charge {
    Charge {
        order: "A-1".to_owned(),
        amount: 40,
        card: "ann-2028".to_owned(),
        gift_card: false,
    }
}

#[tokio::test]
async fn a_charge_the_bank_takes_gives_back_the_banks_receipt() -> Result<(), Error> {
    let mut contributions = Contributions::default();
    let outcome = anns_charge()
        .run(&TestBank::Charges, contributions.contributor())
        .await?;
    assert!(outcome.is_success());
    assert_eq!(
        contributions.read::<String>("receipt"),
        Read::Present("R-A-1".to_owned())
    );
    Ok(())
}

#[tokio::test]
async fn a_charge_the_bank_declines_fails_with_card_expired_and_no_receipt() -> Result<(), Error>
{
    let mut contributions = Contributions::default();
    let outcome = anns_charge()
        .run(&TestBank::Declines, contributions.contributor())
        .await?;
    assert_eq!(outcome.reason().map(Reason::code), Some("card-expired"));
    assert_eq!(contributions.read::<String>("receipt"), Read::Absent);
    Ok(())
}

#[tokio::test]
async fn a_charge_lets_the_error_of_a_bank_that_is_down_escape() {
    let mut contributions = Contributions::default();
    let ended = anns_charge()
        .run(&TestBank::IsDown, contributions.contributor())
        .await;
    assert!(ended.is_err());
}

The rescue step is built the same way, with the reason the test chooses in its #[reason] field:

Rust, from the program ch27_testing_a_step_alone
#[test]
fn renewing_an_expired_card_gives_back_the_card_that_replaced_it() -> Result<(), Error> {
    let mut contributions = Contributions::default();
    let renew = RenewCard {
        card: "jo-2024".to_owned(),
        reason: Reason::from("card-expired"),
    };
    let outcome = renew.run(&TestBank::Charges, contributions.contributor())?;
    assert!(outcome.is_success());
    assert_eq!(
        contributions.read::<String>("card"),
        Read::Present("jo-2029".to_owned())
    );
    Ok(())
}

#[test]
fn a_failure_that_is_not_an_expired_card_is_not_repairable() -> Result<(), Error> {
    let mut contributions = Contributions::default();
    let renew = RenewCard {
        card: "max-2027".to_owned(),
        reason: Reason::from("nothing-to-charge"),
    };
    let outcome = renew.run(&TestBank::Charges, contributions.contributor())?;
    assert_eq!(outcome.reason().map(Reason::code), Some("not-repairable"));
    Ok(())
}
Rust, the same in its asynchronous form, from the program ch27_testing_a_step_alone_async
#[tokio::test]
async fn renewing_an_expired_card_gives_back_the_card_that_replaced_it() -> Result<(), Error> {
    let mut contributions = Contributions::default();
    let renew = RenewCard {
        card: "jo-2024".to_owned(),
        reason: Reason::from("card-expired"),
    };
    let outcome = renew
        .run(&TestBank::Charges, contributions.contributor())
        .await?;
    assert!(outcome.is_success());
    assert_eq!(
        contributions.read::<String>("card"),
        Read::Present("jo-2029".to_owned())
    );
    Ok(())
}

#[tokio::test]
async fn a_failure_that_is_not_an_expired_card_is_not_repairable() -> Result<(), Error> {
    let mut contributions = Contributions::default();
    let renew = RenewCard {
        card: "max-2027".to_owned(),
        reason: Reason::from("nothing-to-charge"),
    };
    let outcome = renew
        .run(&TestBank::Charges, contributions.contributor())
        .await?;
    assert_eq!(outcome.reason().map(Reason::code), Some("not-repairable"));
    Ok(())
}

The shipping also asks for its step reporter. StepEventReporter, from itinera::step, lends one, and keeps what the step emits through it, read back with emitted:

Rust, from the program ch27_testing_a_step_alone
/// An order's shipping, for a parcel of this weight.
fn shipping(weight: i64) -> Ship {
    Ship {
        order: "A-1".to_owned(),
        weight,
        receipt: "R-A-1".to_owned(),
        gift_card: false,
    }
}

#[test]
fn a_parcel_of_2_kg_leaves_with_its_receipt() -> Result<(), Error> {
    let mut contributions = Contributions::default();
    let mut events = StepEventReporter::default();
    let outcome = shipping(2).run(
        &TestCarrier::Works,
        contributions.contributor(),
        events.reporter(),
    )?;
    assert!(outcome.is_success());
    assert_eq!(
        events.emitted().first().map(|event| event.message()),
        Some("the parcel leaves with receipt R-A-1")
    );
    Ok(())
}

#[test]
fn a_parcel_of_90_kg_fails_with_over_parcel_limit_before_the_carrier_is_called()
-> Result<(), Error> {
    let mut contributions = Contributions::default();
    let mut events = StepEventReporter::default();
    let outcome = shipping(90).run(
        &TestCarrier::IsDown,
        contributions.contributor(),
        events.reporter(),
    )?;
    assert_eq!(
        outcome.reason().map(Reason::code),
        Some("over-parcel-limit")
    );
    Ok(())
}
Rust, the same in its asynchronous form, from the program ch27_testing_a_step_alone_async
/// An order's shipping, for a parcel of this weight.
fn shipping(weight: i64) -> Ship {
    Ship {
        order: "A-1".to_owned(),
        weight,
        receipt: "R-A-1".to_owned(),
        gift_card: false,
    }
}

#[tokio::test]
async fn a_parcel_of_2_kg_leaves_with_its_receipt() -> Result<(), Error> {
    let mut contributions = Contributions::default();
    let mut events = StepEventReporter::default();
    let outcome = shipping(2)
        .run(
            &TestCarrier::Works,
            contributions.contributor(),
            events.async_reporter(),
        )
        .await?;
    assert!(outcome.is_success());
    assert_eq!(
        events.emitted().first().map(|event| event.message()),
        Some("the parcel leaves with receipt R-A-1")
    );
    Ok(())
}

#[tokio::test]
async fn a_parcel_of_90_kg_fails_with_over_parcel_limit_before_the_carrier_is_called()
-> Result<(), Error> {
    let mut contributions = Contributions::default();
    let mut events = StepEventReporter::default();
    let outcome = shipping(90)
        .run(
            &TestCarrier::IsDown,
            contributions.contributor(),
            events.async_reporter(),
        )
        .await?;
    assert_eq!(
        outcome.reason().map(Reason::code),
        Some("over-parcel-limit")
    );
    Ok(())
}

Where it differs, each example is followed by its asynchronous form, which tests chapter 26's shop the same way: its fakes implement the services with #[async_trait] and async fn, and each test is an async fn marked #[tokio::test], which runs it on tokio's runtime, awaits run, and has the shipping's step reporter lent by async_reporter.

cargo test runs every test, and prints a line for each, the same for both forms:

What running the tests of ch27_testing_a_step_alone prints, recorded in ch27_testing_a_step_alone.txt
running 7 tests
test tests::a_charge_lets_the_error_of_a_bank_that_is_down_escape ... ok
test tests::a_charge_the_bank_declines_fails_with_card_expired_and_no_receipt ... ok
test tests::a_charge_the_bank_takes_gives_back_the_banks_receipt ... ok
test tests::a_failure_that_is_not_an_expired_card_is_not_repairable ... ok
test tests::a_parcel_of_2_kg_leaves_with_its_receipt ... ok
test tests::a_parcel_of_90_kg_fails_with_over_parcel_limit_before_the_carrier_is_called ... ok
test tests::renewing_an_expired_card_gives_back_the_card_that_replaced_it ... ok

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

How it works

  1. A step is a value with a method. The test builds it by naming its inputs, and calls run with what its parameters ask for: a service, a contributor, a reporter. The attributes on those parameters tell the executor what to give them in a journey; in a test, you give them yourself. The steps are the shop's own, unchanged.
  2. A step is tested through its outcome. run returns the outcome, and is_success and reason read it. A declined card fails the charge with card-expired, and a parcel of 90 kg fails the shipping with over-parcel-limit. That fake carrier's booking system is down, so the shipping would have returned the carrier's error had it booked anything: it refused the parcel before calling the carrier.
  3. An error escapes as it would in a journey. When the bank is down, the charge's run returns an Err, which the executor would record as an abnormal termination, as chapter 12 showed. The test checks that the error escapes; what happens next is the workflow's decision, not the step's.
  4. Recorders lend what the executor would. In a journey, the executor makes a step's contributor and step reporter for each attempt. In a test, Contributions and StepEventReporter lend them and keep what they receive. A recorder only keeps what it is given: committing a successful attempt's contributions is the executor's work.
  5. Fakes come from the test, not from the workflow. In a journey, the workflow's providers make the bank and the carriers. Here, the test passes a fake where run asks for the service, so the step never knows which payment client it got. The fakes are as simple as the tests need: one answer for each case the step has to handle.
  6. Test support stays in the tests. Contributions and StepEventReporter exist only with the testing feature, which only a test build has. So everything that uses them sits inside #[cfg(test)], and the shop you ship compiles without them. The book builds this program both ways: as it would ship, and with its tests.

So each rule of the shop's steps is checked without running an order. But some rules live in the shop's policies and its workflow: failed_shipping, shown in chapter 23, deciding that no retry can help and refunding the charge, or the adapter of chapter 21 taking the weight out of the parcel. These tests never reach them.

Outcomes

We have shown how to test each step alone, with fakes of its services and recorders lending what the executor would.

The usual way With Itinera
Testing a business rule often means setting up the whole request around it, or mocking the code that calls it. A step is built from its inputs and run alone, with fakes passed where it asks for services.
What a function did is often checked through what the mocks it called received. A step's test reads the outcome it returned, and what it contributed and emitted.