Rust unit testing test doubles: fakes
A non-AI non-deep fake

In my previous articles, I have been writing about test doubles. I explained what they are and the different types we can use. I described some scenarios and wrote the test doubles that would help us test the code: dummies, stubs, spies, and mocks. Only one type was left: fakes. Let's look at those now.
Fakes are tests doubles that implement a simplified version of a task or computation. They come in handy when the real thing is slow or complicated. Ciphering messages is usually an expensive task, and we don't want our tests to take longer than they should. So, instead of performing the (real) encryption, we will have a fake that makes a simpler text manipulation.
A real scenario for fakes
Our supervillains are true professionals. They have been engaging in evil deeds for a long time and are well-versed in all the tricks of the trade. One of the most important things for them to keep in mind is that they cannot communicate with their team in plain text. Consequently, they use complex cryptographic tools, which I will refer generically as ciphers, to maintain the privacy of their conversations.
We will model ciphers as a trait in our code, and place its definition in a new file (cipher.rs). It will contain a
single method that takes a key and a message and produces the corresponding ciphered message.
//! Module for sideckicks and all the related functionality
#![allow(dead_code)]
/// Type that represents a sidekick.
pub trait Cipher {
fn transform(&self, secret: &str, key: &str) -> String;
}We include it in the library and re-export the type.
pub mod cipher;
pub use cipher::Cipher;Message privacy is even more important when the supervillains send messages with their secret plans to their sidekicks. In that case, they use a particular (and very secret) implementation of a cipher machine they know as the Riddle: an evolved version of Enigma that uses quantum cryptography.㊙️
pub fn tell_plans<C: Cipher>(&self, secret: &str, cipher: &C) {}Our supervillain will need a secret shared key for using the Riddle machine. We will define a field in the supervillain for the shared key, since that is something that belongs to them.
pub shared_key: String,
In order to simplify the initialization of this shared key, we will use the Default trait in the only instance that
wasn't being used yet (TryFrom).
Ok(Supervillain {
first_name: components[0].to_string(),
last_name: components[1].to_string(),
..Default::default()
})
The implementation of tell_plans() should then take the message and the cipher that it has received as arguments, and
combine them with the secret key to get a ciphered message. The ciphered message is what is told to the sidekick.
if let Some(ref sidekick) = self.sidekick {
let ciphered_msg = cipher.transform(secret, &self.shared_key);
sidekick.tell(ciphered_msg);
}
We need to enable the Sidekick to be told by declaring tell() in its implementation.
pub fn tell(&self, _ciphered_msg: &str) {}And also in the sidekick double.
pub fn tell(&self, ciphered_msg: &str) {}
Remember to compile –cargo b --lib– and to run the tests –cargo t --lib,– before you move on to double-check
that we haven't broken anything.
Test Fakes
Time to set our brains into test mode. And the behavior that we want to test is that the message is always sent
encrypted to the sidekick. The method tell() of the sidekick takes a string, and we could easily forget to encrypt
its contents before passing it onto that method1. The important thing here is that the message is encrypted using a
cipher, not the quality of the cipher, so we don't want to pay extra for having the message properly encrypted.
Having something that has been modified in a predictable way is good enough.
In order to test, we implement a fake cipher that does some simple and computationally cheaper transformation to the message. However, not performing any transformation on the message could be dangerous, because we might incorrectly assume that the encryption of the message has been done in the implementation.
struct CipherDouble;
impl Cipher for CipherDouble {
fn transform(&self, secret: &str, _key: &str) -> String {
String::from("+") + secret + "+"
}
}
Complete the Sidekick double so it can remember what it has been told, adding spy capabilities to it2.
use std::cell::RefCell;
pub struct Sidekick<'a> {
// ...
pub received_msg: RefCell<String>,
}
impl<'a> Sidekick<'a> {
pub fn new() -> Sidekick<'a> {
Sidekick {
// ...
received_msg: RefCell::new(String::from("")),
}
}
// ...
pub fn tell(&self, ciphered_msg: String) {
*self.received_msg.borrow_mut() = ciphered_msg.to_owned();
}
}
And add two constants to our fixture (in test_common.rs) for the original message and the "encrypted" one.
pub const MAIN_SECRET_MESSAGE: &str = "Nobody should know this";
pub const MAIN_CIPHERED_MESSAGE: &str = "+Nobody should know this+";We can add the test now.
#[test_context(Context)]
#[test]
fn tell_plans_sends_ciphered_message(ctx: &mut Context) {
}In the arrangement part, we create an instance of the sidekick double and assign it to our system under test, i.e, the supervillain. We also produce an instance of the cipher double to use it with the method that we are testing.
let mut sk_double = doubles::Sidekick::new();
ctx.sut.sidekick = Some(sk_double);
let fake_cipher = CipherDouble {};The act part is quite straightforward, as always.
ctx.sut
.tell_plans(test_common::MAIN_SECRET_MESSAGE, &fake_cipher);
And in our assert part, we could feel tempted to use the received_msg field to verify that it is the encrypted one.
However, using sk_double is not possible because the SUT has taken ownership of it. So, we will have to implement the
verification in the Drop implementation of the test double, as we have done before for our mock.
pub struct Sidekick<'a> {
// ...
pub assertions: Vec<Box<dyn Fn(&Sidekick) -> () + Send>>,
}
impl<'a> Sidekick<'a> {
pub fn new() -> Sidekick<'a> {
Sidekick {
// ...
assertions: vec![],
}
}
// ...
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);
}
}
}Finally, in the new test, we just have to add the assertion to the instance of the sidekick double to tell it to perform the check.
sk_double.assertions = vec![Box::new(move |s| {
s.verify_received_msg(test_common::MAIN_CIPHERED_MESSAGE)
})];
I don't want to keep the tension. The drum rolls are being played 🥁 while you compile and run the tests with cargo t
--lib. The result is a total success with all the tests passing. You can start parading through the big avenue in the
back of the convertible while the people cheer and clap. Amazing!
Final code
The final version of the supervillain.rs code file is right below this paragraph. Once again, I have included all the
changes 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 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);
assert_eq!(ctx.sut.first_name, test_common::SECONDARY_FIRST_NAME);
assert_eq!(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!(ctx.sut.sidekick.is_some(), "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!(
ctx.sut.sidekick.is_none(),
"Sidekick not fired unexpectedly"
);
}
#[test_context(Context)]
#[test]
fn conspiracy_without_sidekick_doesnt_fail(ctx: &mut Context) {
ctx.sut.conspire();
assert!(ctx.sut.sidekick.is_none(), "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_eq!(
hm_spy.hq_location,
Some(test_common::FIRST_TARGET.to_string())
);
}
#[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, 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);
}
}
}
}
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
This is the last article in the series about what test doubles are and how to craft them from scratch in Rust. But, don't worry, I will be writing more about test doubles in future articles. They are considered one of the cornerstones of testing and rightly so.
If you have been following closely what I have explained in this article, you may have a question in your head, and it
would be a fair one. Why didn't I use a mock of the Cipher to verify that transform() method had been called
instead of using a fake? Well, the difference may be subtle, but the intent is to ensure that the information is
"encrypted" rather than that the method is invoked. In reality, when you use a mocking library, as I will show you
soon, it is just a choice of how you want to implement that behavior using the features of the library.
In any case, my goal is to provide you with the knowledge and the tools to be able to write all these tests. And while it is important that you know the types of test doubles and their purpose, it is far more relevant to me that you can write the tests you need for your codebase. I hope we are getting there.
Stay curious. Hack your code. See you next time!