The Itinera bookRust edition

Part 4: Running it for real, Chapter 28

Testing a hook or an adapter alone

What you want to do

Some of the shop's rules live outside its steps: failed_shipping deciding that no retry can help and refunding the charge, the audit line, the customer's message, and the adapter taking the weight out of the parcel. You want to check each of them on its own, as chapter 27 checked the steps.

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. The notify policy tells customers through the role Messages, shown in chapter 19, and failed_shipping gives money back through the role Refunds, shown in chapter 23, so the tests bring messages and refunds of their own, which keep what they are asked to do:

Rust, from the program ch28_testing_a_hook_or_an_adapter_alone
/// Messages for tests: it keeps each message it is asked to send, with the customer's name.
#[derive(Default)]
struct TestMessages {
    told: RefCell<Vec<String>>,
}

impl Messages for TestMessages {
    fn tell(&self, customer: &str, message: &str) {
        self.told
            .borrow_mut()
            .push(format!("{customer}: {message}"));
    }
}

/// Refunds for tests: it keeps each refund it is asked to give, with its receipt.
#[derive(Default)]
struct TestRefunds {
    given: RefCell<Vec<String>>,
}

impl Refunds for TestRefunds {
    fn refund(&self, receipt: &str, amount: i64) {
        self.given
            .borrow_mut()
            .push(format!("{amount} back on {receipt}"));
    }
}
Rust, the same in its asynchronous form, from the program ch28_testing_a_hook_or_an_adapter_alone_async
/// Messages for tests: it keeps each message it is asked to send, with the customer's name.
#[derive(Default)]
struct TestMessages {
    told: Mutex<Vec<String>>,
}

#[async_trait]
impl Messages for TestMessages {
    async fn tell(&self, customer: &str, message: &str) {
        if let Ok(mut told) = self.told.lock() {
            told.push(format!("{customer}: {message}"));
        }
    }
}

/// Refunds for tests: it keeps each refund it is asked to give, with its receipt.
#[derive(Default)]
struct TestRefunds {
    given: Mutex<Vec<String>>,
}

#[async_trait]
impl Refunds for TestRefunds {
    async fn refund(&self, receipt: &str, amount: i64) {
        if let Ok(mut given) = self.given.lock() {
            given.push(format!("{amount} back on {receipt}"));
        }
    }
}

The policies and the adapter are the shop's own, unchanged since they were shown: the audit in chapter 20, notify in chapter 19, the adapter in chapter 21 and failed_shipping in chapter 23. This chapter shows only their tests. failed_shipping's hook before_retrying is called for Ned's shipping, with the reason each test chooses, and the tests read the decision it returns and the refunds it asked for. Its hook given_up is called with the cause retries exhausted, as for Oli's shipping, and the test also reads the refund it contributed:

Rust, from the program ch28_testing_a_hook_or_an_adapter_alone
/// What `before_retrying` decides for Ned's shipping, which failed with this reason after he
/// paid 50, and the refunds it asked for.
fn before_retrying(
    reason: Option<Reason>,
) -> Result<(Option<FailWorkflow>, Vec<String>), Error> {
    let refunds = TestRefunds::default();
    let mut contributions = Contributions::default();
    let decision = FailedShipping.before_retrying(
        reason,
        "R-A-12".to_owned(),
        50,
        &refunds,
        contributions.contributor(),
    )?;
    Ok((decision, refunds.given.into_inner()))
}

#[test]
fn failed_shipping_refunds_and_gives_up_a_shipping_that_failed_with_over_parcel_limit()
-> Result<(), Error> {
    let (decision, refunds) = before_retrying(Some(Reason::from("over-parcel-limit")))?;
    assert_eq!(
        decision.fails_workflow().map(Reason::code),
        Some("no-carrier-can-take-it")
    );
    assert_eq!(refunds, ["50 back on R-A-12"]);
    Ok(())
}

#[test]
fn failed_shipping_lets_a_shipping_that_failed_with_too_heavy_be_retried() -> Result<(), Error>
{
    let (decision, refunds) = before_retrying(Some(Reason::from("too-heavy")))?;
    assert!(decision.keeps_default());
    assert!(refunds.is_empty());
    Ok(())
}

#[test]
fn failed_shipping_lets_a_shipping_that_ended_in_an_error_be_retried() -> Result<(), Error> {
    let (decision, refunds) = before_retrying(None)?;
    assert!(decision.keeps_default());
    assert!(refunds.is_empty());
    Ok(())
}

#[test]
fn failed_shipping_refunds_a_shipping_the_executor_gave_up() -> Result<(), Error> {
    let refunds = TestRefunds::default();
    let mut contributions = Contributions::default();
    let decision = FailedShipping.given_up(
        StepFailureCause::RetriesExhausted,
        "R-A-14".to_owned(),
        20,
        &refunds,
        contributions.contributor(),
    )?;
    assert!(decision.keeps_default());
    assert_eq!(refunds.given.into_inner(), ["20 back on R-A-14"]);
    assert_eq!(
        contributions.read::<String>("refund"),
        Read::Present("20 back on R-A-14: retries exhausted".to_owned())
    );
    Ok(())
}
Rust, the same in its asynchronous form, from the program ch28_testing_a_hook_or_an_adapter_alone_async
/// What `before_retrying` decides for Ned's shipping, which failed with this reason after he
/// paid 50, and the refunds it asked for.
async fn before_retrying(
    reason: Option<Reason>,
) -> Result<(Option<FailWorkflow>, Vec<String>), Error> {
    let refunds = TestRefunds::default();
    let mut contributions = Contributions::default();
    let decision = FailedShipping
        .before_retrying(
            reason,
            "R-A-12".to_owned(),
            50,
            &refunds,
            contributions.contributor(),
        )
        .await?;
    Ok((decision, refunds.given.into_inner()?))
}

#[tokio::test]
async fn failed_shipping_refunds_and_gives_up_a_shipping_that_failed_with_over_parcel_limit()
-> Result<(), Error> {
    let (decision, refunds) = before_retrying(Some(Reason::from("over-parcel-limit"))).await?;
    assert_eq!(
        decision.fails_workflow().map(Reason::code),
        Some("no-carrier-can-take-it")
    );
    assert_eq!(refunds, ["50 back on R-A-12"]);
    Ok(())
}

#[tokio::test]
async fn failed_shipping_lets_a_shipping_that_failed_with_too_heavy_be_retried()
-> Result<(), Error> {
    let (decision, refunds) = before_retrying(Some(Reason::from("too-heavy"))).await?;
    assert!(decision.keeps_default());
    assert!(refunds.is_empty());
    Ok(())
}

#[tokio::test]
async fn failed_shipping_lets_a_shipping_that_ended_in_an_error_be_retried() -> Result<(), Error>
{
    let (decision, refunds) = before_retrying(None).await?;
    assert!(decision.keeps_default());
    assert!(refunds.is_empty());
    Ok(())
}

#[tokio::test]
async fn failed_shipping_refunds_a_shipping_the_executor_gave_up() -> Result<(), Error> {
    let refunds = TestRefunds::default();
    let mut contributions = Contributions::default();
    let decision = FailedShipping
        .given_up(
            StepFailureCause::RetriesExhausted,
            "R-A-14".to_owned(),
            20,
            &refunds,
            contributions.contributor(),
        )
        .await?;
    assert!(decision.keeps_default());
    assert_eq!(refunds.given.into_inner()?, ["20 back on R-A-14"]);
    assert_eq!(
        contributions.read::<String>("refund"),
        Read::Present("20 back on R-A-14: retries exhausted".to_owned())
    );
    Ok(())
}

The audit's hook is called with the step's name, made with step_name!, the amount, the receipt, and a contributor lent by Contributions:

Rust, from the program ch28_testing_a_hook_or_an_adapter_alone
#[test]
fn audit_writes_the_step_the_amount_and_the_receipt() -> Result<(), Error> {
    let mut contributions = Contributions::default();
    Audit.charged(
        step_name!("charge"),
        30,
        "R-A-1".to_owned(),
        contributions.contributor(),
    )?;
    assert_eq!(
        contributions.read::<String>("audit"),
        Read::Present("charge took 30, receipt R-A-1".to_owned())
    );
    Ok(())
}
Rust, the same in its asynchronous form, from the program ch28_testing_a_hook_or_an_adapter_alone_async
#[tokio::test]
async fn audit_writes_the_step_the_amount_and_the_receipt() -> Result<(), Error> {
    let mut contributions = Contributions::default();
    Audit
        .charged(
            step_name!("charge"),
            30,
            "R-A-1".to_owned(),
            contributions.contributor(),
        )
        .await?;
    assert_eq!(
        contributions.read::<String>("audit"),
        Read::Present("charge took 30, receipt R-A-1".to_owned())
    );
    Ok(())
}

Both hooks of notify are called with the test's messages, for Kim's gift card and Lee's failed order:

Rust, from the program ch28_testing_a_hook_or_an_adapter_alone
#[test]
fn notify_gives_a_gift_cards_code_and_says_sorry_for_a_failed_order() -> Result<(), Error> {
    let messages = TestMessages::default();
    Notify.succeeded(
        "Kim".to_owned(),
        "A-9".to_owned(),
        None,
        Some("G-A-9".to_owned()),
        &messages,
    )?;
    Notify.failed("Lee".to_owned(), "A-10".to_owned(), &messages)?;
    assert_eq!(
        messages.told.into_inner(),
        [
            "Kim: Order A-9 is paid: your gift card's code is G-A-9.",
            "Lee: Sorry, order A-10 could not be completed.",
        ]
    );
    Ok(())
}
Rust, the same in its asynchronous form, from the program ch28_testing_a_hook_or_an_adapter_alone_async
#[tokio::test]
async fn notify_gives_a_gift_cards_code_and_says_sorry_for_a_failed_order() -> Result<(), Error>
{
    let messages = TestMessages::default();
    Notify
        .succeeded(
            "Kim".to_owned(),
            "A-9".to_owned(),
            None,
            Some("G-A-9".to_owned()),
            &messages,
        )
        .await?;
    Notify
        .failed("Lee".to_owned(), "A-10".to_owned(), &messages)
        .await?;
    assert_eq!(
        messages.told.into_inner()?,
        [
            "Kim: Order A-9 is paid: your gift card's code is G-A-9.",
            "Lee: Sorry, order A-10 could not be completed.",
        ]
    );
    Ok(())
}

The adapter is a method of the workflow, so the tests make the workflow's value, with its channel, and ask it for two of the shipping's inputs:

Rust, from the program ch28_testing_a_hook_or_an_adapter_alone
/// Gil's parcel, as the warehouse describes it.
fn gils_parcel() -> Parcel {
    Parcel {
        weight: 60,
        size: [120, 80, 60],
    }
}

#[test]
fn parcels_gives_the_shipping_the_parcels_weight() -> Result<(), Error> {
    let weight = Orders { channel: "email" }.parcels("weight", gils_parcel())?;
    assert_eq!(
        weight
            .as_ref()
            .and_then(|value| value.downcast_ref::<i64>()),
        Some(&60)
    );
    Ok(())
}

#[test]
fn parcels_leaves_every_other_input_to_the_data_bag() -> Result<(), Error> {
    let receipt = Orders { channel: "email" }.parcels("receipt", gils_parcel())?;
    assert!(receipt.is_none());
    Ok(())
}

In the asynchronous form, which tests chapter 26's shop, each test of a hook is an async fn marked #[tokio::test] that awaits the hook, and the fakes implement the roles with #[async_trait] and async fn, keeping what they are asked in a Mutex in place of a RefCell.

cargo test prints the same for both forms:

What running the tests of ch28_testing_a_hook_or_an_adapter_alone prints, recorded in ch28_testing_a_hook_or_an_adapter_alone.txt
running 8 tests
test tests::audit_writes_the_step_the_amount_and_the_receipt ... ok
test tests::failed_shipping_lets_a_shipping_that_ended_in_an_error_be_retried ... ok
test tests::failed_shipping_lets_a_shipping_that_failed_with_too_heavy_be_retried ... ok
test tests::failed_shipping_refunds_a_shipping_the_executor_gave_up ... ok
test tests::failed_shipping_refunds_and_gives_up_a_shipping_that_failed_with_over_parcel_limit ... ok
test tests::notify_gives_a_gift_cards_code_and_says_sorry_for_a_failed_order ... ok
test tests::parcels_gives_the_shipping_the_parcels_weight ... ok
test tests::parcels_leaves_every_other_input_to_the_data_bag ... ok

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

How it works

  1. A hook is a method. The test calls it with what its parameters ask for. Their attributes, such as #[reason], #[from_workflow] or #[role], only tell the executor what to give them in a journey. Nothing else changes: the policies are the shop's own.
  2. A hook is tested through its decision. before_retrying returns an Option<FailWorkflow>, and given_up one too. The trait ReturnedLifecycle, from itinera::policy, which the tests bring in with use, reads it: keeps_default says the hook returned nothing, so the executor does what it would have done without the policy, and fails_workflow gives the reason the hook fails the journey with, if it does. The tests give the hook every case it has to handle, including no reason at all, which is what it gets after an attempt that ended in an error.
  3. A role is faked like a service. notify asks for &dyn Messages, and failed_shipping for &dyn Refunds, so the tests pass their own TestMessages and TestRefunds, and read what the hooks told whom and what they gave back. refund, the policy's own method, is tested through the two hooks that call it. A role's methods take &self, so each fake keeps what it is asked in a RefCell, Rust's way of letting a value change behind a shared reference. In the asynchronous form, the roles are Sync, and a RefCell can't be shared between threads, so the fakes use a Mutex, from std::sync, which lets several threads share a value one at a time. Its lock and into_inner fail only if a thread panicked while holding it, which the fakes never do.
  4. A hook's contributions are read as a step's. The audit and given_up contribute through the contributor Contributions lends, and the tests read it back with read.
  5. An adapter is tested through what it supplies. parcels returns Some of an AnyValue for the weight, which downcast_ref reads as the type the shipping asks for, and None for every other input, which leaves it to the data bag. The test gives it the parcel itself, as a journey would give it from the data bag.

So each rule of the shop's policies and its workflow is checked alone too. But nothing yet checks how the workflow puts them together: that a declined card is rescued, that Ned's shipping is given up after its first attempt, and that every customer is told. A journey checks that, and a journey runs the steps, with their bank and their carriers.

Outcomes

We have shown how to test a policy's hooks and a workflow's input adapter alone, by calling them as methods.

The usual way With Itinera
Logic that runs around a call, such as a retry rule or an audit line, is often tested only by running the whole call. A hook is a method, called by the test with the data it asks for, and its decision is a value the test reads.
Code that converts data for a function often sits inside it, tested only with it. An input adapter is a method of the workflow, tested on its own with the data it reads.