Building Readable Tests with Fluent Testing APIs
Motivation
One of the biggest challenges in software testing is defining the input for code under test in a way that is expressive and powerful enough to test complex situations but doesn’t distract from the intent of the test or clutter the test code to a degree that makes it difficult to read.
Many dynamic languages have testing APIs which take advantage of their looser and later type checking to provide easy mocking and stubbing, but strict, statically typed languages can make it difficult to build up suitable instances of the types needed in the test. This is especially true with nested data.
At CancerIQ, we have a large body of tests to ensure we correctly implement our risk models. These tests are thorough and give us a high confidence that new features or refactors aren’t breaking old functionality.
We do have one problem, however: fragility and verbosity. Our tests are very complete but, until the changes described here, they didn’t express what they were testing well, and they were very difficult to change.
In this post, you’ll see how we got from fragile and hard-to-understand tests to short, expressive, and functionally equivalent tests with fluent-style testing APIs.
The Literal Approach
Originally, these tests were written using literals.
For instance, consider a Person
that looks something like this:
1struct Person {
2 pub id: String,
3 pub age: Option<u8>,
4 pub mother_id: Option<String>,
5 pub father_id: Option<String>,
6 pub children: Option<Vec<String>>,
7 pub medical_history: Option<MedicalHistory>,
8 pub reproductive_history: Option<ReproductiveHistory>,
9 // ... and many more fields
10}
Of course, the MedicalHistory
and ReproductiveHistory
structs are
themselves nontrivial, and because we are dealing with data from
inconsistent sources (patient reporting), almost every field is
Option
al. Our tests ended up looking something like this:
1#[test]
2fn patient_with_field_that_matters_value_is_flagged() {
3 let patient = Patient {
4 id: "1".into(),
5 age: None,
6 mother_id: None,
7 father_id: None,
8 children: None,
9 medical_history: Some(MedicalHistory{
10 diagnoses: vec![Diagnosis {
11 type: DiagnosisType::SomeIllnessOrOther,
12 field_a: None,
13 field_b: None,
14 field_that_matters: Some(31337),
15 field_d: None,
16 }]
17 }),
18 reproductive_history: None,
19 // Followed by many more Nones
20 };
21 assert!(function_under_test(patient),
22 "Function did not return True for patient who should be at risk!");
23}
Is it clear what we’re doing here? Not really. It turns out, we only
care about one field here: field_that_matters
, on the Patient
’s
MedicalHistory
. In addition, this test is 21 lines long - far too long
for a test which is only evaluating a single function call.
More importantly, though, this test is totally inflexible. Whenever we
had to add a field to the Diagnosis
struct, our tests stopped
compiling, even though the field was Option
al and not important here.
With hundreds of such fragile tests in the codebase, making even basic
changes became very slow.
First Steps
The most obvious way to solve both fragility and verbosity was to encapsulate this initialization into a function, which we did. These functions took some basic info and then tests with more complex requirements would add the properties they needed. For instance:
1fn patient_with_field_that_matters_is_flagged() {
2 let mut patient = make_basic_patient("1", None, None, None, None);
3 let diagnosis = make_diagnosis(
4 Diagnosis_Type::SomeIllnessOrOther,
5 None,
6 None,
7 Some(31337),
8 None);
9 patient.medical_history = MedicalHistory{
10 diagnoses: Some(MedicalHistory{
11 diagnoses: vec![diagnosis]
12 })
13 }
14 assert!(function_under_test(patient),
15 "Function did not return True for patient who should be at risk!");
16}
This approach does make the test more concise, but it actually reduces
readability due to the large number of unnamed function arguments.
Without looking up the signature of make_diagnosis
, a reader has no
way to know that the value given is going into field_that_matters
.
This can also be done with Rust’s struct update syntax, either by
implementing Default
or with an explicit ::base()
associated
function:
1fn patient_with_field_that_matters_is_flagged() {
2 let patient = Patient {
3 id: "1".into(),
4 medical_history: Some(MedicalHistory{
5 diagnoses: Some(vec![ Diagnosis {
6 field_that_matters: Some(31337),
7 .. Diagnosis::base(DiagnosisType::SomeIllnessOrOther)
8 }])
9 }),
10 .. Default::default()
11 }
12 assert!(function_under_test(patient),
13 "Function did not return True for patient who should be at risk!");
14}
Now, only the ::base()
method or the implementation of Default
has
to be modified when a struct field is added or modified. This is a good
first step, but it doesn’t fully solve the problem of clarity.
Taking Inspiration
I don’t have a lot of occasion to work with dynamic languages, but I was pairing with another engineer on some Ruby code and noticed how nice the testing API was with RSpec. This lead me down a rabbit hole which eventually brought me to fluent programming.
In short, the idea of a fluent API is that it can be read almost like a sentence in a natural language. We built an API with a similar idea, which is what is currently used in the risk model tests. They look something like this:
1fn patient_with_field_that_matters_is_flagged() {
2 let patient = Patient::base("1").with_medical_history(
3 MedicalHistory::base().and_diagnosis(
4 Diagnosis::base(DiagnosisType::SomeIllnessOrOther)
5 .with_field_that_matters(31337)
6 )
7 );
8 assert(function_under_test(patient),
9 "Function did not return True for patient who should be at risk!");
10}
The advantages of this approach become even clearer when working with tests that require many associated structs. For example, consider this code:
1let patient = Person::base("1", Gender::Male)
2 .with_father("2")
3 .with_mother("3")
4 .with_children(&["4", "5"])
5 .with_age(53);
6let father = Person::base("2", Gender::Male).deceased_at(84);
7let mother = Person::base("3", Gender::Female).with_medical_history(
8 MedicalHistory::base()
9 .and_diagnosis(Diagnosis::base(DiagnosisType::CommonCold).at_age(32))
10 .and_diagnosis(Diagnosis::base(DiagnosisType::AvianFlu)
11 .with_treatment(Treatment::base(Treatment::SomeFluTreatment))));
12let child1 = Person::base("4", Gender::Female);
13let child2 = Person::base("5", Gender::Female)
14 .with_mutation(GeneticMutation::BlueHairAllele_pg5s77);
This is a lot of information! We’ve effectively defined a DAG (a family tree) in fewer lines than our original test, and it’s trivial to read through this.
“Who’s the father?” “Oh, he’s a male who died at 84.”
“Who’s the mother?” “She’s a woman who got the cold at 32 and was treated for avian flu.”
“Who’s the patient?” “He’s a 53 year old man with two children.”
The critical idea here is that the API doesn’t require specifying that you don’t have some information; rather, you use methods to specify which pieces of information you do have.
Implementation
Implementation of these methods is efficient and straightforward in Rust, using the aforementioned update syntax. Consider the following struct:
1struct ShippingItem {
2 pub volume: f64,
3 pub condition: Option<Condition>,
4 pub sender: Option<String>,
5 pub recipient: Option<String>,
6 pub certified: Option<bool>,
7 pub contents: Option<Vec<InventoryObject>>,
8 // et cetera
9}
This struct has 1 required field and several optional fields. Because of
the required field, just implementing Default
isn’t an option, so the
::base()
method is the way to go:
1impl ShippingItem {
2 pub fn base(volume: f64) -> ShippingItem {
3 ShippingItem {
4 volume,
5 condition: None,
6 sender: None,
7 recipient: None,
8 certified: None,
9 contents: None,
10 }
11 }
12}
Then, for each basic Option
al field, we’ll add a method. This includes
condition
, sender
, and recipient
.
1impl ShippingItem {
2 pub fn with_condition(self, cond: Condition) -> Self {
3 ShippingItem {
4 condition: cond,
5 ..self
6 }
7 }
8
9 pub fn with_sender(self, sender: &str) -> Self {
10 ShippingItem {
11 sender: sender.into(),
12 ..self
13 }
14 }
15
16 pub fn with_recipient(self, recipient: &str) -> Self {
17 ShippingItem {
18 recipient: recipient.into(),
19 ..self
20 }
21 }
22}
Each of these takes ownership of self
and returns a new
ShippingItem
, allowing the creation of long sentence-like
descriptions.
For boolean values, two methods are preferrable.
1impl ShippingItem {
2 pub fn certified(self) -> Self {
3 ShippingItem {
4 certified: Some(true),
5 ..self
6 }
7 }
8
9 pub fn not_certified(self) -> Self {
10 ShippingItem {
11 certified: Some(false),
12 ..self
13 }
14 }
15}
Option
al lists provide something more of a challenge. We settled on a
two-method API, providing one method for replacing the contents with a
slice and one for adding a single item.
1impl ShippingItem {
2 pub fn with_contents(self, contents: &[InventoryObject]) -> Self {
3 ShippingItem {
4 contents: Some(contents.into()),
5 ..self
6 }
7 }
8
9 pub fn and_contents(self, object: InventoryObject) -> Self {
10 let contents = self.contents.unwrap_or(Some(Vec::new()));
11 contents.push(object);
12 ShippingItem {
13 contents,
14 ..self
15 }
16 }
17}
Now we can write our tests with this API! For example, let’s say we wanted to test a shipping validation function. We can specify only the required information, rather than all the possible fields.
1#[test]
2fn same_sender_and_receiver_not_shippable() {
3 let item = ShippingItem::base(1.0)
4 .with_sender("Somebody T. Something")
5 .with_receiver("Somebody T. Something");
6 assert!(!item.validate());
7}
Conclusion and Use Cases
This technique isn’t for every project. The main drawback is that it
requires a fair amount of boilerplate for each struct, and that the
::base()
method needs an argument for every required item in the
struct (as opposed to a builder-style pattern).
However, for any system in which you need to test a lot of computations on a struct with a lot of optional fields, you should consider this fluent-style testing pattern. It’s made our tests more readable, easier to write, and easier to modify, and it can probably do the same for you.