Rust unit testing: file reading
Testing file operations for free

So far, I have covered several scenarios. Yet all the interactions in those scenarios occurred in memory: one instance of a type talking to another instance of another type. However, in the real world, our applications do more than just invoke functions or methods, and we would like to test those use cases.
One of the most common things that applications do and that deserves testing is reading from and writing to files. Files are among the most frequently used input and output mechanisms in many applications, and it is key that they behave as expected when using files.
Reading files scenario
Supervillains have long realized that communicating with their subordinates requires more than just spoken words. Detailed, confidential surveillance data should not be communicated aloud, and some orders must be written down so they are available to all when needed. It is our chore to implement this communication and, for the sake of our survival, test it.
In this article, I will focus on a method that reads information from a pre-established text file in CSV (comma-separated values) format. The lines will have two columns: the name of a city and a label for its weakness ("strong" or "weak".) The method will return true only if it can read the file and find at least one city labeled as weak.
pub fn are_there_vulnerable_locations(&self) -> Option<bool> {
let mut listing = String::new();
let Ok(mut file_listing) = File::open(LISTING_PATH) else {
return None;
};
let Ok(n) = file_listing.read_to_string(&mut listing) else {
return None;
};
for line in listing.lines() {
if line.ends_with("weak") {
return Some(true);
}
}
Some(false)
}Testing with file operations
If you have ever considered writing tests that involve code that reads or writes files and didn't know where to start, you may have found resources explaining how to use the tempdir crate that produces a directory in the filesystem, which is automatically deleted when it goes out of scope. And this is a very valid approach for some scenarios, but it has some cons:
- For starters, creating files containing the content to be read or verifying the contents of a file written by the code is slightly more complex than the equivalent operation with memory.
- Reading from or writing to the filesystem, no matter how small the exchanged data is, is a very expensive operation. It really depends on the actual disk, but it is at least four orders of magnitude (10.000 times) slower than the same operation on memory.
- You may think duration is totally irrelevant because we are talking about milliseconds rather than nanoseconds. And you will be right,… if it were only a test. But all these operations usually require multiple tests, and the time spent adds up. This is very important when we do TDD, which we will soon, or for Continuous Integration/Continuous Delivery (CI/CD). But it is always useful to have a fast test harness you can run as often as you wish, without having to go for a coffee while you wait for the results.
In the implementation of are_there_vulnerable_locations(), there are two file-related operations. The first one is the
usage of open() to obtain an instance of File or an error. The other one is when we read the file's contents
into a String.
Test when the file cannot be opened
Our first test cannot be simpler. We want to verify that the method are_there_vulnerable_locations() returns None if
the file cannot be opened. I have used one of the assertions that I explained in a previous article.
#[test_context(Context)]
#[test]
fn vulnerable_locations_with_no_file_returns_none(ctx: &mut Context) {
assert_none!(ctx.sut.are_there_vulnerable_locations());
}
We would like to control those two operations, so we can make open() succeed or fail, independently of the path, and
read_to_string(), return whatever we need to test every possible case. That sounds like using a double to me.
Sadly enough, we cannot use mockall in this case. On the one hand, the original type is defined in the standard
library, and we cannot add the automock attribute in there. On the other hand, if we created the mock with the
mock! macro, the combination of renaming the type –the mock would be called MockFile–, mocking the associated
function open() using a context and returning the mock, and creating an expectation over the returned instance for
reading, isn't trivial. Instead, we will use a technique we learned in a previous article. We will define a type with
the same name that implements the required functionality, then replace the original type with it in the tests using the
namespace. For this to work, the name of the struct must be exactly the same. We cannot call it FileDouble, because
it won't work.
Inside the tests module, we create another module for our double. We have done this in a prior article, but we
removed it when we changed our code to use mockall. Let's bring this module back and create our File test double.
mod doubles {
struct File {
}
}We will import this type instead of the one offered by the standard library, but only for the tests.
#[cfg(not(test))]
use std::fs::File;
#[cfg(test)]
use tests::doubles::File;
In the tests::doubles module, we define the implementation block that contains only the associated function open() and the
method read_to_string(), because they are the only ones used by the code we want to test.
impl File {
fn open<P: AsRef<Path>>(path: P) -> Result<File> {
}
pub fn read_to_string(&mut self, buf: &mut String) -> io::Result<usize> {
}
}
Since our first test is to check that the method are_there_vulnerable_locations() returns a none if the file cannot be
opened, we will simply hard-code the result of the associated function to be an error. We will do the same for the
method, with a different error, so it can compile, even though this method isn't executed in this first test.
impl File {
pub fn open<P: AsRef<Path>>(path: P) -> io::Result<File> {
Err(Error::from(ErrorKind::NotFound))
}
pub fn read_to_string(&mut self, buf: &mut String) -> io::Result<usize> {
Err(Error::from(ErrorKind::Other))
}
}
We run the tests with cargo t --lib and they should all pass. Definitely, a good first step!
Test when the file cannot be read.
Our next test should address a failure to read the file's contents.
#[test_context(Context)]
#[test]
fn vulnerable_locations_with_file_reading_error_returns_none(ctx: &mut Context) {
assert_none!(ctx.sut.are_there_vulnerable_locations());
}
What we are missing here is the ability to control the behavior of the test double. We weren't doing it in the previous
step either, but we hard-coded the behavior that we needed: open() had to fail. The problem is that we now need
open() to succeed and read_to_string() to fail.
In previous examples, we added fields to our test double that controlled the behavior of its methods or their responses.
But open() is an associated function, so there is no File instance before it is called. Putting a field in the
File test double wouldn't help. We need to control that behavior using a static variable in the tests module.
We will need to set the value of that static variable for each test that uses the File test double, which we will name
according to RFC-430. However, a mutable static is unsafe because it can be mutated by multiple threads, and having
multiple threads running the tests is not unusual at all.
We can use the thread_local! macro, which creates thread-local versions of the static variables defined with it. But
that will only allow us to obtain a shared reference, so we need to use either Cell<T> or RefCell<T> to achieve
interior mutability.
thread_local! {
static FILE_OPEN_OK: Cell<bool> = Cell::new(true);
static FILE_READ_RESULT: RefCell<Option<String>> = RefCell::new(None);
}These static variables can be used in the test double to alter its behavior at will.
impl File {
pub fn open<P: AsRef<Path>>(path: P) -> io::Result<File> {
if FILE_OPEN_OK.get() {
Ok(File {})
} else {
Err(Error::from(ErrorKind::NotFound))
}
}
pub fn read_to_string(&mut self, buf: &mut String) -> io::Result<usize> {
if let Some(_) = FILE_READ_RESULT.take() {
Ok(0)
} else {
Err(Error::from(ErrorKind::Other))
}
}
}Then the tests set these variables to control the behavior of the test double as required.
#[test_context(Context)]
#[test]
fn vulnerable_locations_with_no_file_returns_none(ctx: &mut Context) {
FILE_OPEN_OK.set(false);
FILE_READ_RESULT.set(None);
assert_none!(ctx.sut.are_there_vulnerable_locations());
}
#[test_context(Context)]
#[test]
fn vulnerable_locations_with_file_reading_error_returns_none(ctx: &mut Context) {
FILE_OPEN_OK.set(true);
FILE_READ_RESULT.set(None);
assert_none!(ctx.sut.are_there_vulnerable_locations());
}
Let's run the tests again with cargo t --lib and make sure they all pass.
Test file contents
This is all well and good, but we open a file to read its contents, and we would like to check what happens with
different contents. Fortunately, we already have some space in the static variable that we created to control
read_to_string().
We want to add two tests: one for when a line ends with "weak" and another for when it doesn't.
#[test_context(Context)]
#[test]
fn vulnerable_locations_with_weak_returns_true(ctx: &mut Context) {
FILE_OPEN_OK.set(true);
FILE_READ_RESULT.set(Some(String::from(
r#"Madrid,strong
Las Vegas,weak
New York,strong"#,
)));
assert_some_eq_x!(ctx.sut.are_there_vulnerable_locations(), true);
}
#[test_context(Context)]
#[test]
fn vulnerable_locations_without_weak_returns_false(ctx: &mut Context) {
FILE_OPEN_OK.set(true);
FILE_READ_RESULT.set(Some(String::from(
r#"Madrid,strong
Oregon,strong
New York,strong"#,
)));
assert_some_eq_x!(ctx.sut.are_there_vulnerable_locations(), false);
}We modify the test double method to return the string we have stored in the variable.
pub fn read_to_string(&mut self, buf: &mut String) -> io::Result<usize> {
if let Some(mut content) = FILE_READ_RESULT.take() {
*buf = content;
Ok(buf.len())
} else {
Err(Error::from(ErrorKind::Other))
}
}
We run the tests with cargo t --lib and celebrate! We can test interactions with files and read their contents. Not
only to make reading possible, but also to control what is actually read.
A more elegant solution
While it is true that we must use a static variable to control the behavior of the associated function, the method can
be controlled using fields of the test double, as we have done many times in the past. That means, though, that we need
open() to produce a File test double that contains the "expected behavior" for the method, so it can be used later.
Let's first add a field to the File double to hold that information.
pub struct File {
read_result: Option<String>
}
And we create an associated function to produce a File with the expected value in this field.
pub fn new(read_result: Option<String>) -> File {
File { read_result }
}
Now we only need to get open() to return the File test double with the information. There are several ways to do
this, but I would say that the easiest one is to put the data in the static variable that controls open().
static FILE_OPEN_OK: RefCell<Option<doubles::File>> = RefCell::new(None);
We can use that in the open() method.
pub fn open<P: AsRef<Path>>(path: P) -> io::Result<File> {
if let Some(file) = FILE_OPEN_OK.take() {
Ok(file)
} else {
Err(Error::from(ErrorKind::NotFound))
}
}
And then we can use the field from the read_to_string() method.
pub fn read_to_string(&mut self, buf: &mut String) -> io::Result<usize> {
if let Some(ref content) = self.read_result {
*buf = content.to_owned();
Ok(buf.len())
} else {
Err(Error::from(ErrorKind::Other))
}
}The tests have to use the new static variable.
#[test_context(Context)]
#[test]
fn vulnerable_locations_with_no_file_returns_none(ctx: &mut Context) {
FILE_OPEN_OK.replace(None);
assert_none!(ctx.sut.are_there_vulnerable_locations());
}
#[test_context(Context)]
#[test]
fn vulnerable_locations_with_file_reading_error_returns_none(ctx: &mut Context) {
FILE_OPEN_OK.replace(Some(doubles::File::new(None)));
assert_none!(ctx.sut.are_there_vulnerable_locations());
}
#[test_context(Context)]
#[test]
fn vulnerable_locations_with_weak_returns_true(ctx: &mut Context) {
FILE_OPEN_OK.replace(Some(doubles::File::new(Some(String::from(
r#"Madrid,strong
Las Vegas,weak
New York,strong"#,
)))));
assert_some_eq_x!(ctx.sut.are_there_vulnerable_locations(), true);
}
#[test_context(Context)]
#[test]
fn vulnerable_locations_without_weak_returns_false(ctx: &mut Context) {
FILE_OPEN_OK.replace(Some(doubles::File::new(Some(String::from(
r#"Madrid,strong
Oregon,strong
New York,strong"#,
)))));
assert_some_eq_x!(ctx.sut.are_there_vulnerable_locations(), false);
}
And we can remove the FILE_READ_RESULT static variable, since we no longer need it.
Run the tests one more time with cargo t --lib and give kudos to yourself!
Final code
We have added four more tests to our harness and described a couple of versions of the same code. The last version of
the code in the supervillain.rs file is provided below for your reference, but you can check the whole project and the
individual commits, as always, in the repo with all the code in this series.
//! Module for supervillains and their related stuff
#![allow(unused)]
#[cfg(not(test))]
use std::fs::File;
use std::{io::Read, time::Duration};
#[cfg(test)]
use tests::doubles::File;
#[cfg(test)]
use mockall::automock;
#[cfg(test)]
use mockall_double::double;
use rand::Rng;
use thiserror::Error;
#[cfg_attr(test, double)]
use crate::sidekick::Sidekick;
use crate::{Cipher, Gadget, Henchman};
const LISTING_PATH: &str = "tmp/listings.csv";
/// 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,
}
#[cfg_attr(test, automock)]
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);
}
}
pub fn are_there_vulnerable_locations(&self) -> Option<bool> {
let mut listing = String::new();
let Ok(mut file_listing) = File::open(LISTING_PATH) else {
return None;
};
let Ok(n) = file_listing.read_to_string(&mut listing) else {
return None;
};
for line in listing.lines() {
if line.ends_with("weak") {
return Some(true);
}
}
Some(false)
}
}
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, RefCell};
use assertables::{assert_matches, assert_none, assert_some, assert_some_eq_x};
use mockall::{Sequence, predicate::eq};
use test_context::{AsyncTestContext, TestContext, test_context};
use crate::{cipher::MockCipher, gadget::MockGadget, henchman::MockHenchman, test_common};
use super::*;
thread_local! {
static FILE_OPEN_OK: RefCell<Option<doubles::File>> = RefCell::new(None);
// static FILE_OPEN_OK: Cell<bool> = Cell::new(true);
static FILE_READ_RESULT: RefCell<Option<String>> = RefCell::new(None);
}
#[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 mut weapon = MockMegaweapon::new();
weapon.expect_shoot().once().return_const(());
ctx.sut.attack(&weapon, false);
}
#[test_context(Context)]
#[test]
fn intensive_attack_shoots_weapon_twice_or_more(ctx: &mut Context) {
let mut weapon = MockMegaweapon::new();
weapon.expect_shoot().times(2..=3).return_const(());
ctx.sut.attack(&weapon, true);
}
#[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 mock_sidekick = Sidekick::new();
mock_sidekick.expect_agree().once().return_const(true);
ctx.sut.sidekick = Some(mock_sidekick);
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 mock_sidekick = Sidekick::new();
mock_sidekick.expect_agree().once().return_const(false);
ctx.sut.sidekick = Some(mock_sidekick);
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 = MockGadget::new();
let mut mock_henchman = MockHenchman::new();
mock_henchman
.expect_build_secret_hq()
.with(eq(String::from(test_common::FIRST_TARGET)))
.return_const(());
let mut mock_sidekick = Sidekick::new();
mock_sidekick
.expect_get_weak_targets()
.once()
.returning(|_| test_common::TARGETS.map(String::from).to_vec());
ctx.sut.sidekick = Some(mock_sidekick);
ctx.sut
.start_world_domination_stage1(&mut mock_henchman, &gdummy);
}
#[test_context(Context)]
#[test]
fn world_domination_stage2_tells_henchman_to_do_hard_things_and_fight_with_enemies(
ctx: &mut Context,
) {
let mut mock_henchman = MockHenchman::new();
let mut sequence = Sequence::new();
mock_henchman
.expect_fight_enemies()
.once()
.in_sequence(&mut sequence)
.return_const(());
mock_henchman
.expect_do_hard_things()
.once()
.in_sequence(&mut sequence)
.return_const(());
ctx.sut.start_world_domination_stage2(mock_henchman);
}
#[test_context(Context)]
#[test]
fn tell_plans_sends_ciphered_message(ctx: &mut Context) {
let mut mock_sidekick = Sidekick::new();
mock_sidekick
.expect_tell()
.with(eq(String::from(test_common::MAIN_CIPHERED_MESSAGE)))
.once()
.return_const(());
ctx.sut.sidekick = Some(mock_sidekick);
let mut mock_cipher = MockCipher::new();
mock_cipher
.expect_transform()
.returning(|secret, _| String::from("+") + secret + "+");
ctx.sut
.tell_plans(test_common::MAIN_SECRET_MESSAGE, &mock_cipher);
}
#[test_context(Context)]
#[test]
fn vulnerable_locations_with_no_file_returns_none(ctx: &mut Context) {
FILE_OPEN_OK.replace(None);
assert_none!(ctx.sut.are_there_vulnerable_locations());
}
#[test_context(Context)]
#[test]
fn vulnerable_locations_with_file_reading_error_returns_none(ctx: &mut Context) {
FILE_OPEN_OK.replace(Some(doubles::File::new(None)));
assert_none!(ctx.sut.are_there_vulnerable_locations());
}
#[test_context(Context)]
#[test]
fn vulnerable_locations_with_weak_returns_true(ctx: &mut Context) {
FILE_OPEN_OK.replace(Some(doubles::File::new(Some(String::from(
r#"Madrid,strong
Las Vegas,weak
New York,strong"#,
)))));
assert_some_eq_x!(ctx.sut.are_there_vulnerable_locations(), true);
}
#[test_context(Context)]
#[test]
fn vulnerable_locations_without_weak_returns_false(ctx: &mut Context) {
FILE_OPEN_OK.replace(Some(doubles::File::new(Some(String::from(
r#"Madrid,strong
Oregon,strong
New York,strong"#,
)))));
assert_some_eq_x!(ctx.sut.are_there_vulnerable_locations(), false);
}
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) {}
}
pub mod doubles {
use std::{
io::{self, Error, ErrorKind},
path::Path,
};
use super::*;
pub struct File {
read_result: Option<String>,
}
impl File {
pub fn new(read_result: Option<String>) -> File {
File { read_result }
}
pub fn open<P: AsRef<Path>>(path: P) -> io::Result<File> {
if let Some(file) = FILE_OPEN_OK.take() {
Ok(file)
} else {
Err(Error::from(ErrorKind::NotFound))
}
}
pub fn read_to_string(&mut self, buf: &mut String) -> io::Result<usize> {
if let Some(ref content) = self.read_result {
,*buf = content.to_owned();
Ok(buf.len())
} else {
Err(Error::from(ErrorKind::Other))
}
}
}
}
}Summary
In this article, I have explained how to mock types that are defined in the standard library. In particular the File
type that is used to interact with the files in the filesystem. I have also shared how to mock associated functions and
use static variables to change their behavior.
Using static to control the behavior of the test doubles is useful, but it is more convenient to use fields in the test double. We have written both approaches for your reference so you can compare and choose.
But, what is more important, we have tested several interactions with files without ever performing any operation on the filesystem.
Stay curious. Hack your code. See you next time!