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:
#[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:
/// 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
}
}/// 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:
/// 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());
}/// 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:
#[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(())
}#[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:
/// 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(())
}/// 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:
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.00sHow it works
- A step is a value with a method. The test builds it by naming its inputs, and calls
runwith 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. - A step is tested through its outcome.
runreturns the outcome, andis_successandreasonread it. A declined card fails the charge withcard-expired, and a parcel of 90 kg fails the shipping withover-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. - An error escapes as it would in a journey. When the bank is down, the charge's
runreturns anErr, 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. - 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,
ContributionsandStepEventReporterlend 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. - 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
runasks 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. - Test support stays in the tests.
ContributionsandStepEventReporterexist only with thetestingfeature, 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. |