At the beginning of this series, I explained how to write a test by decomposing it into three parts: arrange, act, and assert. Since then, we have written several unit tests using that structure. Most of the assertions we’ve written in those tests used the assert_eq!() macro. Sometimes, they were a very obvious way to state what was expected and the actual value that was produced. It’s clear and works well for simple checks. However, in other cases, the syntax is awkward or doesn’t clearly convey what we mean, requiring extra steps or missing necessary functionality.
In this article, I will replace some of the assertions made in the tests that we have written so far with others that are easier to read and write or provide enhanced functionality. Get ready to assert yourself!
##…
At the beginning of this series, I explained how to write a test by decomposing it into three parts: arrange, act, and assert. Since then, we have written several unit tests using that structure. Most of the assertions we’ve written in those tests used the assert_eq!() macro. Sometimes, they were a very obvious way to state what was expected and the actual value that was produced. It’s clear and works well for simple checks. However, in other cases, the syntax is awkward or doesn’t clearly convey what we mean, requiring extra steps or missing necessary functionality.
In this article, I will replace some of the assertions made in the tests that we have written so far with others that are easier to read and write or provide enhanced functionality. Get ready to assert yourself!
Easier assertions
Rust’s standard library gives us three assertion macros: assert_eq!(), assert_ne!(), and the more flexible assert!(), each designed for different comparison needs. If you use the nightly version, you also get assert_matches!(), which improves pattern matching in assertions. These macros are useful, but sometimes lack the precision or clarity required for certain checks.
Let me introduce assertables, the crate that comes to the rescue 🦸. It provides several assertion macros that add richer expressiveness, conciseness, and better debug information. It has assertions to compare values beyond equality and inequality (less than, greater than, less than or equal to, greater than or equal to, or within a range) and takes precision into account for floating-point values. It has specific assertions for strings: if they start with a prefix, end with a suffix, contain a substring, or are even valid email addresses. It also offers assertions to verify groups and find out if a value is equal to any or all of the elements. It has assertions for iterators or sets that simplify collection comparison. And many more… With all of the available macros, your code can be very specific about what you are trying to assert, and they will take care of the nitty-gritty of the verification.
Without further ado, let’s add to the project. Since we plan to use assertables only with our tests, not our production code, we specify it as a development dependency with the corresponding option.
cargo add --dev assertables
Simplify matching in assertions
In one of the tests for the implementation of the TryFrom trait –try_from_str_slice_produces_error_with_less_than_two_substrings()– we wanted to verify that the associated function try_from() returned an error when the provided argument didn’t contain two “words” or more. Our supervillain wants to have full control and know when things are going wrong, so the error must be a specific variant of the EvilError enum and contain some specific associated data. So we used the matches!() macro inside the assertion to verify this use case. The expression used was assert!(matches!(...));, that is not as clarifying as it could be.
I have used similar expressions for quite a while, but I was secretly hoping that assert_matches!() would become part of stable Rust. Thankfully, assertables has that assertion available, and we can use it to simplify the expression and make explicit that we expect a specific pattern. It makes the assertion a little bit easier to write and read.
assert_matches!(error, EvilError::ParseError { purpose, reason } if purpose =="full_name" && reason == "Too few arguments");
Simplify assertions with containers
It is also quite common that the values we want to compare are wrapped in an Option<T> or a Result<T,E>. If that is the case, we can either unwrap the value first and compare it to the expected contents or produce an expected value that is wrapped too before comparing them in the assertion. This might not be complex, but it makes the test more wordy and less readable, as you can see in the assertion used in world_domination_stage1_builds_hq_in_first_weak_target(): assert_eq!(hm_spy.hq_location, Some(test_common::FIRST_TARGET.to_string()));
Instead, assertables provides a macro that directly compares container values, making assertions shorter and the intention behind the check clear. For Option<T> and Result<T, E>, this avoids redundant unwrapping or rewrapping and emphasizes what you are actually trying to verify.
assertables::assert_some_eq_x!(&hm_spy.hq_location, test_common::FIRST_TARGET);
However, we can also use it to simplify assertions that check simply for the presence –Some(T)– or absence –None– of a value in an Option<T>.
The assertion for keep_sidekick_if_agrees_with_conspiracy() becomes:
assert_some!(&ctx.sut.sidekick, "Sidekick fired unexpectedly");
And we need to implement debug for doubles::Sidekick.
impl fmt::Debug for Sidekick<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Sidekick")
.field("agree_answer", &self.agree_answer)
.field("targets", &self.targets)
.field("received_msg", &self.received_msg)
.finish()
}
}
The one in fire_sidekick_if_doesnt_agree_with_conspiracy() is replaced by:
assert_none!(&ctx.sut.sidekick, "Sidekick not fired unexpectedly");
And the assertion for conspiracy_without_sidekick_doesnt_fail() can be instead:
assert_none!(&ctx.sut.sidekick, "Unexpected sidekick");
These cases highlight how the right macro can simplify tough assertions. If you struggle to write an assertion, check if there’s a specific macro available for your needs.
Don’t forget to run the tests one more time with cargo t --lib to ensure that they are all passing. And feel free to make some changes to also check that they fail when they should.
More functional assertions
When I explained the basics of writing unit tests, I strongly suggested having a single assertion per test, unless the multiple assertions were related to the same part of the production code. The reason for this practice is that if a test fails, it points to a narrow piece of code, and you know exactly what went wrong.
However, when we tested set_fullname(), we wrote two assertions that verified that both the first name and the last name had been properly set. That was entirely in line with my suggestion, as a single line of code was responsible for extracting the two strings.
That said, our assertions in this test are less than ideal, because if the first name is wrong, the test will fail without telling us anything about the last name. It is possible that we encounter an error in that test, complaining about the first name being incorrect. We apply some fixes to the code to address that part only to realize that the second assertion is also not passing, and our test is still failing.
It would be better to have the entire verdict before proceeding to apply fixes to the code. However, the implementation of tests and assertions makes this more challenging in Rust. The last step of all the assert macros is to panic, which is used by the test runner to indicate that the test has failed. You panic, and there is no way back to continue with the test.
There is another crate that can help us with that: assert2. They used a different approach to empower your assertions. Instead of providing different expressive macros to fulfill your needs when making assertions, it uses just a couple of macros, but they do the heavy lifting, parsing the expressions that you use with them to figure out what you want. Your code gets to be more expressive because the intent is stated in the expression contained in the assert. This works well for inequality comparisons (>, <, >=, <=), not so much for collections. Yet, it has the ability to match patterns, and can even capture parts of it with a specific macro (let_assert!().)
Nevertheless, the reason I decided to include assert2 here is that it provides us with the check!() macro, that works similarly to the typical assertion, but allows the test to continue. As you would expect by now, it does that by creating an instance that panics when it gets out of scope, taking advantage of the Drop trait again.
Let’s add the crate to the project, also as a developer dependency, and see how it works.
cargo add --dev assert2
Test to the end
We can improve our existing test and tell it to continue if the first check fails.
#[test_context(Context)]
#[test]
fn set_full_name_sets_first_and_last_names(ctx: &mut Context) {
ctx.sut.set_full_name(test_common::SECONDARY_FULL_NAME);
assert2::check!(ctx.sut.first_name == test_common::SECONDARY_FIRST_NAME);
assert2::assert!(ctx.sut.last_name == test_common::SECONDARY_LAST_NAME);
}
Notice that I have qualified both assertions, so I don’t mix assert2 and standard library macros in the same test.
If you want to verify that the test runs to completion, you can temporarily replace the assertions and check that it prints, in full color, both failures in the output.
assert2::check!(ctx.sut.first_name == "A");
assert2::assert!(ctx.sut.last_name == "B");
Change the assertions back to the previous ones, run the tests again with cargo t --lib, and relax and enjoy 😎.
Final code
We haven’t made many changes to our code, but it is always useful to have the final version available. You can find the supervillain.rs code file right below this paragraph. And if you want to check the whole project, they are, as always, in the corresponding commit of the repo with all the code in this series.
//! Module for supervillains and their related stuff
#![allow(unused)]
use std::time::Duration;
use rand::Rng;
use thiserror::Error;
#[cfg(not(test))]
use crate::sidekick::Sidekick;
use crate::{Cipher, Gadget, Henchman};
#[cfg(test)]
use tests::doubles::Sidekick;
/// Type that represents supervillains.
#[derive(Default)]
pub struct Supervillain<'a> {
pub first_name: String,
pub last_name: String,
pub sidekick: Option<Sidekick<'a>>,
pub shared_key: String,
}
pub trait Megaweapon {
fn shoot(&self);
}
impl Supervillain<'_> {
/// Return the value of the full name as a single string.
///
/// Full name is produced concatenating first name, a single space, and the last name.
///
/// # Examples
/// ```
///# use evil::supervillain::Supervillain;
/// let lex = Supervillain {
/// first_name: "Lex".to_string(),
/// last_name: "Luthor".to_string(),
/// };
/// assert_eq!(lex.full_name(), "Lex Luthor");
/// ```
pub fn full_name(&self) -> String {
format!("{} {}", self.first_name, self.last_name)
}
pub fn set_full_name(&mut self, name: &str) {
let components = name.split(" ").collect::<Vec<_>>();
println!("Received {} components.", components.len());
if components.len() != 2 {
panic!("Name must have first and last name");
}
self.first_name = components[0].to_string();
self.last_name = components[1].to_string();
}
pub fn attack(&self, weapon: &impl Megaweapon, intense: bool) {
weapon.shoot();
if intense {
let mut rng = rand::rng();
let times = rng.random_range(1..3);
for _ in 0..times {
weapon.shoot();
}
}
}
pub async fn come_up_with_plan(&self) -> String {
tokio::time::sleep(Duration::from_millis(100)).await;
String::from("Take over the world!")
}
pub fn conspire(&mut self) {
if let Some(ref sidekick) = self.sidekick {
if !sidekick.agree() {
self.sidekick = None;
}
}
}
pub fn start_world_domination_stage1<H: Henchman, G: Gadget>(
&self,
henchman: &mut H,
gadget: &G,
) {
if let Some(ref sidekick) = self.sidekick {
let targets = sidekick.get_weak_targets(gadget);
if !targets.is_empty() {
henchman.build_secret_hq(targets[0].clone());
}
}
}
pub fn start_world_domination_stage2<H: Henchman>(&self, henchman: H) {
henchman.fight_enemies();
henchman.do_hard_things();
}
pub fn tell_plans<C: Cipher>(&self, secret: &str, cipher: &C) {
if let Some(ref sidekick) = self.sidekick {
let ciphered_msg = cipher.transform(secret, &self.shared_key);
sidekick.tell(&ciphered_msg);
}
}
}
impl TryFrom<&str> for Supervillain<'_> {
type Error = EvilError;
fn try_from(name: &str) -> Result<Self, Self::Error> {
let components = name.split(" ").collect::<Vec<_>>();
if components.len() < 2 {
Err(EvilError::ParseError {
purpose: "full_name".to_string(),
reason: "Too few arguments".to_string(),
})
} else {
Ok(Supervillain {
first_name: components[0].to_string(),
last_name: components[1].to_string(),
..Default::default()
})
}
}
}
#[derive(Error, Debug)]
pub enum EvilError {
#[error("Parse error: purpose='{}', reason='{}'", .purpose, .reason)]
ParseError { purpose: String, reason: String },
}
#[cfg(test)]
mod tests {
use assertables::{assert_matches, assert_none, assert_some, assert_some_eq_x};
use std::cell::Cell;
use test_context::{AsyncTestContext, TestContext, test_context};
use crate::test_common;
use super::*;
#[test_context(Context)]
#[test]
fn full_name_is_first_name_space_last_name(ctx: &mut Context) {
let full_name = ctx.sut.full_name();
assert_eq!(
full_name,
test_common::PRIMARY_FULL_NAME,
"Unexpected full name"
);
}
#[test_context(Context)]
#[test]
fn set_full_name_sets_first_and_last_names(ctx: &mut Context) {
ctx.sut.set_full_name(test_common::SECONDARY_FULL_NAME);
// assert2::check!(ctx.sut.first_name == "A");
// assert2::assert!(ctx.sut.last_name == "B");
assert2::check!(ctx.sut.first_name == test_common::SECONDARY_FIRST_NAME);
assert2::assert!(ctx.sut.last_name == test_common::SECONDARY_LAST_NAME);
}
#[test_context(Context)]
#[test]
#[should_panic(expected = "Name must have first and last name")]
fn set_full_name_panics_with_empty_name(ctx: &mut Context) {
ctx.sut.set_full_name("");
}
#[test]
fn try_from_str_slice_produces_supervillain_full_with_first_and_last_name()
-> Result<(), EvilError> {
let sut = Supervillain::try_from(test_common::SECONDARY_FULL_NAME)?;
assert_eq!(sut.first_name, test_common::SECONDARY_FIRST_NAME);
assert_eq!(sut.last_name, test_common::SECONDARY_LAST_NAME);
Ok(())
}
#[test]
fn try_from_str_slice_produces_error_with_less_than_two_substrings() {
let result = Supervillain::try_from("");
let Err(error) = result else {
panic!("Unexpected value returned by try_from");
};
assert_matches!(error, EvilError::ParseError { purpose, reason } if purpose =="full_name" && reason == "Too few arguments");
}
#[test_context(Context)]
#[test]
fn non_intensive_attack_shoots_weapon_once(ctx: &mut Context) {
let weapon = WeaponDouble::new();
ctx.sut.attack(&weapon, false);
weapon.verify(once());
}
#[test_context(Context)]
#[test]
fn intensive_attack_shoots_weapon_twice_or_more(ctx: &mut Context) {
let weapon = WeaponDouble::new();
ctx.sut.attack(&weapon, true);
weapon.verify(at_least(2));
}
#[test_context(Context)]
#[tokio::test]
async fn plan_is_sadly_expected(ctx: &mut Context<'_>) {
assert_eq!(ctx.sut.come_up_with_plan().await, "Take over the world!");
}
#[test_context(Context)]
#[test]
fn keep_sidekick_if_agrees_with_conspiracy(ctx: &mut Context) {
let mut sk_double = doubles::Sidekick::new();
sk_double.agree_answer = true;
ctx.sut.sidekick = Some(sk_double);
ctx.sut.conspire();
assert_some!(&ctx.sut.sidekick, "Sidekick fired unexpectedly");
}
#[test_context(Context)]
#[test]
fn fire_sidekick_if_doesnt_agree_with_conspiracy(ctx: &mut Context) {
let mut sk_double = doubles::Sidekick::new();
sk_double.agree_answer = false;
ctx.sut.sidekick = Some(sk_double);
ctx.sut.conspire();
assert_none!(&ctx.sut.sidekick, "Sidekick not fired unexpectedly");
}
#[test_context(Context)]
#[test]
fn conspiracy_without_sidekick_doesnt_fail(ctx: &mut Context) {
ctx.sut.conspire();
assert_none!(&ctx.sut.sidekick, "Unexpected sidekick");
}
#[test_context(Context)]
#[test]
fn world_domination_stage1_builds_hq_in_first_weak_target(ctx: &mut Context) {
let gdummy = GadgetDummy {};
let mut hm_spy = HenchmanDouble::default();
let mut sk_double = doubles::Sidekick::new();
sk_double.targets = test_common::TARGETS.map(String::from).to_vec();
ctx.sut.sidekick = Some(sk_double);
ctx.sut.start_world_domination_stage1(&mut hm_spy, &gdummy);
assert_some_eq_x!(&hm_spy.hq_location, test_common::FIRST_TARGET);
}
#[test_context(Context)]
#[test]
fn world_domination_stage2_tells_henchman_to_do_hard_things_and_fight_with_enemies(
ctx: &mut Context,
) {
let mut henchman = HenchmanDouble::default();
henchman.assertions = vec![Box::new(move |h| h.verify_two_things_done())];
ctx.sut.start_world_domination_stage2(henchman);
}
#[test_context(Context)]
#[test]
fn tell_plans_sends_ciphered_message(ctx: &mut Context) {
let mut sk_double = doubles::Sidekick::new();
sk_double.assertions = vec![Box::new(move |s| {
s.verify_received_msg(test_common::MAIN_CIPHERED_MESSAGE)
})];
ctx.sut.sidekick = Some(sk_double);
let fake_cipher = CipherDouble {};
ctx.sut
.tell_plans(test_common::MAIN_SECRET_MESSAGE, &fake_cipher);
}
pub(crate) mod doubles {
use std::{cell::RefCell, fmt, marker::PhantomData};
use crate::Gadget;
pub struct Sidekick<'a> {
phantom: PhantomData<&'a ()>,
pub agree_answer: bool,
pub targets: Vec<String>,
pub received_msg: RefCell<String>,
pub assertions: Vec<Box<dyn Fn(&Sidekick) -> () + Send>>,
}
impl<'a> Sidekick<'a> {
pub fn new() -> Sidekick<'a> {
Sidekick {
phantom: PhantomData,
agree_answer: false,
targets: vec![],
received_msg: RefCell::new(String::from("")),
assertions: vec![],
}
}
pub fn agree(&self) -> bool {
self.agree_answer
}
pub fn get_weak_targets<G: Gadget>(&self, _gadget: &G) -> Vec<String> {
self.targets.clone()
}
pub fn tell(&self, ciphered_msg: &str) {
,*self.received_msg.borrow_mut() = ciphered_msg.to_owned();
}
pub fn verify_received_msg(&self, expected_msg: &str) {
assert_eq!(*self.received_msg.borrow(), expected_msg);
}
}
impl Drop for Sidekick<'_> {
fn drop(&mut self) {
for a in &self.assertions {
a(self);
}
}
}
impl fmt::Debug for Sidekick<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Sidekick")
.field("agree_answer", &self.agree_answer)
.field("targets", &self.targets)
.field("received_msg", &self.received_msg)
.finish()
}
}
}
struct CipherDouble;
impl Cipher for CipherDouble {
fn transform(&self, secret: &str, _key: &str) -> String {
String::from("+") + secret + "+"
}
}
struct GadgetDummy;
impl Gadget for GadgetDummy {
fn do_stuff(&self) {}
}
#[derive(Default)]
struct HenchmanDouble {
hq_location: Option<String>,
current_invocation: Cell<u32>,
done_hard_things: Cell<u32>,
fought_enemies: Cell<u32>,
assertions: Vec<Box<dyn Fn(&HenchmanDouble) -> () + Send>>,
}
impl HenchmanDouble {
fn verify_two_things_done(&self) {
assert!(self.done_hard_things.get() == 2 && self.fought_enemies.get() == 1);
}
}
impl Henchman for HenchmanDouble {
fn build_secret_hq(&mut self, location: String) {
self.hq_location = Some(location);
}
fn do_hard_things(&self) {
self.current_invocation
.set(self.current_invocation.get() + 1);
self.done_hard_things.set(self.current_invocation.get());
}
fn fight_enemies(&self) {
self.current_invocation
.set(self.current_invocation.get() + 1);
self.fought_enemies.set(self.current_invocation.get());
}
}
impl Drop for HenchmanDouble {
fn drop(&mut self) {
for a in &self.assertions {
a(self);
}
}
}
struct WeaponDouble {
pub times_shot: Cell<u32>,
}
impl WeaponDouble {
fn new() -> WeaponDouble {
WeaponDouble {
times_shot: Cell::default(),
}
}
fn verify<T: Fn(u32) -> bool>(&self, check: T) {
assert!(check(self.times_shot.get()));
}
}
impl Megaweapon for WeaponDouble {
fn shoot(&self) {
self.times_shot.set(self.times_shot.get() + 1);
}
}
struct Context<'a> {
sut: Supervillain<'a>,
}
impl<'a> AsyncTestContext for Context<'a> {
async fn setup() -> Context<'a> {
Context {
sut: Supervillain {
first_name: test_common::PRIMARY_FIRST_NAME.to_string(),
last_name: test_common::PRIMARY_LAST_NAME.to_string(),
..Default::default()
},
}
}
async fn teardown(self) {}
}
fn at_least(min_times: u32) -> impl Fn(u32) -> bool {
return (move |times: u32| (times >= min_times));
}
fn once() -> impl Fn(u32) -> bool {
return (move |times: u32| (times == 1));
}
}
Summary
I have used the assertables crate to modify the assertions that didn’t fit well with the standard macros. In my opinion, that results in a much clearer code with a more obvious intent.
I have also used assert2 to illustrate how to write a test that finishes running even if its first verification fails. This could help reduce the need to go back and forth to fix the kind of tests that require several assertions, making you more productive.
There are other libraries that you may want to consider, such as speculoos, which allows you to use fluent assertions, which are quite popular among some developers. Still, there is some amount of bikeshedding here, so don’t forget that he goal is to have a good test harness for your code, and you should only resort to these libraries when they serve a real purpose for your codebase.
In any case, I strongly advise against using more than one assertion library in the same project. It increases the number of dependencies and makes the testing code more convoluted and less readable, which was the main reason to consider them in the first place.
Stay curious. Hack your code. See you next time!