Testing a whole journey
What you want to do
You want to run one order through the shop's real steps and policies, from start to end, and check the way its journey went: which steps succeeded, which failed and why, and how the journey ended. The bank and the carriers should be fakes, so the test never reaches the outside world.
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 tests bring their own bank, which charges or is down, and their own carrier, which collects every parcel or none, implementing Payments and Carrier, with their errors, as chapter 14 and chapter 12 declared them:
/// A bank for tests: it charges every card, or its network is down.
#[derive(Clone, Copy)]
enum TestBank {
Charges,
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::IsDown => Err(NetworkDown),
}
}
fn replacement(&self, _: &str) -> Option<String> {
None
}
}
/// A carrier for tests: it books every parcel, and collects every parcel or none.
#[derive(Clone, Copy)]
enum TestCarrier {
CollectsAll,
CollectsNone,
}
impl Carrier for TestCarrier {
fn book(&self, order: &str, _: i64) -> Result<String, BookingFailed> {
Ok(format!("T-{order}"))
}
fn collects(&self, _: i64) -> bool {
matches!(self, TestCarrier::CollectsAll)
}
}/// A bank for tests: it charges every card, or its network is down.
#[derive(Clone, Copy)]
enum TestBank {
Charges,
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::IsDown => Err(NetworkDown),
}
}
async fn replacement(&self, _: &str) -> Option<String> {
None
}
}
/// A carrier for tests: it books every parcel, and collects every parcel or none.
#[derive(Clone, Copy)]
enum TestCarrier {
CollectsAll,
CollectsNone,
}
#[async_trait]
impl Carrier for TestCarrier {
async fn book(&self, order: &str, _: i64) -> Result<String, BookingFailed> {
Ok(format!("T-{order}"))
}
async fn collects(&self, _: i64) -> bool {
matches!(self, TestCarrier::CollectsAll)
}
}Each test runs an order through path_of, which makes an instance of the shop's workflow, as main does, with two more calls on its builder: fake_provider, which comes with the testing feature, and reporter, which any journey's builder has. fake_provider names one of the workflow's providers, bank, from chapter 7 as chapter 12 shows it, or carriers, from chapter 13, and gives a function the executor calls in its place; the type in ::<...> is the service it provides. reporter adds a RecordingReporter, from itinera::report, which also comes with the testing feature, to the journey's reporters. It keeps every event, and its clones share what they keep, so the test reads the events from the clone it kept. Its path gives the journey's path:
/// Runs an order of 30 with a parcel of this weight, through the shop's real steps and
/// policies, with the test's bank and carriers in place of the shop's, and gives back its
/// journey's path.
fn path_of(
order: &str,
weight: i64,
bank: TestBank,
carrier: TestCarrier,
) -> Result<Vec<String>, Error> {
let recorder = RecordingReporter::default();
let instance = 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("ann-2028".to_owned()),
gift_card: false,
})
.reporter(recorder.clone())
.fake_provider::<dyn Payments>("bank", move || Ok(provide!(bank)))
.fake_provider::<dyn Carrier>("carriers", move || Ok(provide!(carrier)))
.create()?;
local_executor_for!(Orders).run(instance)?;
Ok(recorder.path())
}/// Runs an order of 30 with a parcel of this weight, through the shop's real steps and
/// policies, with the test's bank and carriers in place of the shop's, and gives back its
/// journey's path.
async fn path_of(
order: &str,
weight: i64,
bank: TestBank,
carrier: TestCarrier,
) -> Result<Vec<String>, Error> {
let recorder = RecordingReporter::default();
let instance = 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("ann-2028".to_owned()),
gift_card: false,
})
.reporter(recorder.clone())
.fake_provider::<dyn Payments>("bank", move || Ok(provide!(bank)))
.fake_provider::<dyn Carrier>("carriers", move || Ok(provide!(carrier)))
.create()?;
local_executor_for!(Orders).run(instance).await?;
Ok(recorder.path())
}NewOrder is as chapter 25 shows it, and Parcel as chapter 21 does.
The shop approves the path of each kind of order once, and writes it down. The paths follow the workflow as chapter 23 declared it, with outage from chapter 17. Each test compares a journey's path with the approved one:
#[test]
fn an_order_the_bank_charges_and_the_carrier_takes_is_shipped() -> Result<(), Error> {
assert_eq!(
path_of("A-1", 2, TestBank::Charges, TestCarrier::CollectsAll)?,
[
"lookup attempt 1 succeeded",
"price attempt 1 succeeded",
"charge attempt 1 succeeded",
"ship attempt 1 succeeded",
"journey succeeded",
]
);
Ok(())
}
#[test]
fn a_parcel_over_the_limit_is_given_up_after_its_first_attempt() -> Result<(), Error> {
assert_eq!(
path_of("A-2", 90, TestBank::Charges, TestCarrier::CollectsAll)?,
[
"lookup attempt 1 succeeded",
"price attempt 1 succeeded",
"charge attempt 1 succeeded",
"ship attempt 1 failed: over-parcel-limit",
"ship given up: FailWorkflow from failed_shipping",
"journey failed at ship: no-carrier-can-take-it",
]
);
Ok(())
}
#[test]
fn a_charge_whose_bank_is_down_is_given_up_and_never_rescued() -> Result<(), Error> {
assert_eq!(
path_of("A-3", 2, TestBank::IsDown, TestCarrier::CollectsAll)?,
[
"lookup attempt 1 succeeded",
"price attempt 1 succeeded",
"charge attempt 1 terminated abnormally: the payment network is down",
"charge given up: FailWorkflow from outage",
"journey failed at charge: payment-network-down",
]
);
Ok(())
}
#[test]
fn a_parcel_no_carrier_collects_is_tried_once_more_then_given_up() -> Result<(), Error> {
assert_eq!(
path_of("A-4", 60, TestBank::Charges, TestCarrier::CollectsNone)?,
[
"lookup attempt 1 succeeded",
"price attempt 1 succeeded",
"charge attempt 1 succeeded",
"ship attempt 1 failed: too-heavy",
"ship retrying",
"ship attempt 2 failed: too-heavy",
"ship given up: retries exhausted",
"journey failed at ship: too-heavy",
]
);
Ok(())
}#[tokio::test]
async fn an_order_the_bank_charges_and_the_carrier_takes_is_shipped() -> Result<(), Error> {
assert_eq!(
path_of("A-1", 2, TestBank::Charges, TestCarrier::CollectsAll).await?,
[
"lookup attempt 1 succeeded",
"price attempt 1 succeeded",
"charge attempt 1 succeeded",
"ship attempt 1 succeeded",
"journey succeeded",
]
);
Ok(())
}
#[tokio::test]
async fn a_parcel_over_the_limit_is_given_up_after_its_first_attempt() -> Result<(), Error> {
assert_eq!(
path_of("A-2", 90, TestBank::Charges, TestCarrier::CollectsAll).await?,
[
"lookup attempt 1 succeeded",
"price attempt 1 succeeded",
"charge attempt 1 succeeded",
"ship attempt 1 failed: over-parcel-limit",
"ship given up: FailWorkflow from failed_shipping",
"journey failed at ship: no-carrier-can-take-it",
]
);
Ok(())
}
#[tokio::test]
async fn a_charge_whose_bank_is_down_is_given_up_and_never_rescued() -> Result<(), Error> {
assert_eq!(
path_of("A-3", 2, TestBank::IsDown, TestCarrier::CollectsAll).await?,
[
"lookup attempt 1 succeeded",
"price attempt 1 succeeded",
"charge attempt 1 terminated abnormally: the payment network is down",
"charge given up: FailWorkflow from outage",
"journey failed at charge: payment-network-down",
]
);
Ok(())
}
#[tokio::test]
async fn a_parcel_no_carrier_collects_is_tried_once_more_then_given_up() -> Result<(), Error> {
assert_eq!(
path_of("A-4", 60, TestBank::Charges, TestCarrier::CollectsNone).await?,
[
"lookup attempt 1 succeeded",
"price attempt 1 succeeded",
"charge attempt 1 succeeded",
"ship attempt 1 failed: too-heavy",
"ship retrying",
"ship attempt 2 failed: too-heavy",
"ship given up: retries exhausted",
"journey failed at ship: too-heavy",
]
);
Ok(())
}In the asynchronous form, which tests chapter 26's shop, the fakes implement the services with #[async_trait] and async fn, path_of runs the instance on the executor local_executor_for! makes, an AsyncLocalExecutor, and awaits it, and each test is an async fn marked #[tokio::test].
As in chapter 29, cargo test hides what the shop's log, its desk and its messages print. It prints the same for both forms:
running 4 tests
test tests::a_charge_whose_bank_is_down_is_given_up_and_never_rescued ... ok
test tests::a_parcel_no_carrier_collects_is_tried_once_more_then_given_up ... ok
test tests::a_parcel_over_the_limit_is_given_up_after_its_first_attempt ... ok
test tests::an_order_the_bank_charges_and_the_carrier_takes_is_shipped ... ok
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sHow it works
- A fake provider replaces only the provider. The workflow keeps its providers' keys and scopes. The steps still ask for
paymentsandcarrier, andcarriersstays a provider made for every attempt. Only what the provider returns changes. - Everything else is real. The steps run their own rules: the shipping refuses the parcel of 90 kg itself, with
over-parcel-limit, as chapter 27's test showed. The policies decide, the adapter supplies the weight, and the result and the events are the ones a real order would give. - A path is the journey's flow, one line for each event of it.
pathreads only the events that say how the flow went: each attempt's outcome, each decision about a step, and how the journey ended. Everything else, such as which provider served a step or which data was committed, is left out, so a path changes only when the flow does. - Each line is a plain sentence.
charge attempt 1 terminated abnormallynames the step, its attempt and how it ended, with the reason or the error after a colon.ship given up: FailWorkflow from failed_shippingnames the cause, and the policy that decided it.journey failed at ship: no-carrier-can-take-itnames the step where the journey ended, and the reason it ended with: after retries are exhausted, the last attempt's reason,too-heavyfor the parcel no carrier collects. - An approved path is a decision of the flow, written down. A person reads it once and agrees that this is what should happen to such an order. If a change to the workflow sends an order another way, the test fails. A change to a step's rules that keeps the flow keeps the path.
So the shop tests each piece alone, its flow with scripts, and whole journeys by their paths. But the shop's log will soon write to a logging service, over the network too, where a write can fail.
Outcomes
We have shown how to test a whole journey, with fake providers in place of the workflow's, by comparing its path with an approved one.
| The usual way | With Itinera |
|---|---|
| Testing a whole request often needs a test double for every dependency, wired by hand into the code under test. | fake_provider replaces a workflow's provider by name, and the steps get the fake as they would the real service. |
| An end-to-end test usually checks the final state, and says little about the way a request went. | A journey's path lists each outcome and decision in order, and a test compares it with the path the shop approved. |