Rust unit testing: buffered file reading
Testing other file operations

In the previous article, I explained how to use a test double to replace a File instance and control the behavior
required by a function that reads from it. With the test double in place, I was able not only to return what I needed
for the scenarios where I needed reading, but also to produce errors that allowed me to test the not-so-happy path.
I got some concerned comments pointing out that the test code wasn't reading, which was an essential part of what we
were testing. I agree that the test code isn't actually reading. It isn't, and that is on purpose. On the one hand,
reading isn't an essential part of what we are testing. On the contrary, File is a dependency of the
are_there_vulnerable_locations() method, and, as with any other dependency, it can be replaced by a test double. The
code isn't making it easy to inject the dependency, because the File instance is created inside of the method –and as
you may already know, every time you create an instance inside of your code, a kitten is killed2. But I applied
what I had already explained to replace dependencies using the namespace. On the other hand, we gain additional
benefits by using a test double. I mentioned that performance is much better, but you can only notice the difference
with a large test suite. However, it is also important that the test double can easily reproduce File behaviors that
would require some work when you are actually interacting with the filesystem. For example, opening the file for
reading successfully and then failing to read from it right away.
I hope that by now, you are on board with the idea of using test doubles for file operations, because I am going to do it again. This time, I will extend the idea to a buffering scenario that wasn't covered in the previous article, and, in the next article, I will cover writing to a file. It would be impractical, and, being honest, quite boring too, to try to cover every possible scenario about file I/O. Also, the scenarios might be useful if you are trying to write very similar tests, but don't let them dazzle you; focus on the techniques I will be using, which I consider more useful in the long run.
Buffered reading
Our supervillain is gathering a lot of collaborators who can inspect many locations and assess their vulnerability much faster, so we have been told to write our method more efficiently. We have started by adding buffered reading to the method.
As you may already know, Rust provides a trait for buffered reading operations: BufRead. Types that implement this
trait use an internal buffer to improve reading performance, reducing the number of system calls. But it also builds on
the Read trait, offering more options for reading content, such as the ability to iterate over the lines of a file
without first reading the whole file into a string using the lines() method. The standard library provides several
types that implement that trait. In particular, BufReader takes a File and adds all the buffered operations.
We can write another and more efficient version of our existing method using buffered reading.
pub fn are_there_vulnerable_locations_efficient(&self) -> Option<bool> {
let Ok(mut file_listing) = File::open(LISTING_PATH) else {
return None;
};
let buf_listing = BufReader::new(file_listing);
let mut list_iter = buf_listing.lines();
while let Some(line) = list_iter.next() {
if let Ok(line) = line
&& line.ends_with("weak")
{
return Some(true);
}
}
Some(false)
}
Try and compile the code with cargo b --lib. It should work, but the tests aren't ready yet. In the process of adding
this new method, we broke it because our File double doesn't implement some of the functionality that a BufReader
requires. Let's work on fixing the tests and adding new ones for the new method.
Testing Buffered Reading
The problem with our new code is that we have only implemented open() and read_to_string() for the File test
double. Good enough to be usable by the code existing at the time, yet only a small part of the functionality that the
actual type offers. Specifically, the File test double doesn't implement Read, which is required by the BufReader
we instantiate in the new method, causing the tests to fail compilation.
Refactoring the code1
We could create a test double for BufReader that returns something that we control when we invoke the new() method.
But that is a convoluted solution because this BufReader would have to implement lines(), returning an iterator
over the lines, and we would have to write two or three test doubles for that method. We can do better.
Also, it might be tempting to pass that test double as an argument of the tested method. However, that definitely
wouldn't be refactoring, and we might have other constraints in real-world code, such as other code that invokes our
method, API ergonomics, etc. So we don't want to change the interface of the Supervillain, and the declaration of the
method are_there_vulnerable_locations_efficient() stays untouched.
Another option would be to extract the part of the code that produces the BufReader into a function and implement a
different version of that function for the tests. This would be easier if we didn't have to handle the case where the
file cannot be opened. But, since we don't need to use the specific error, we can return an Option<BufReader>.
fn open_buf_read(path: &str) -> Option<BufReader<File>> {
let Ok(mut file) = File::open(path) else {
return None;
};
Some(BufReader::new(file))
}We can then use this function in the method we plan to test, preserving the same functionally, while replacing the file operations and isolating them.
let Some(buf_listing) = Self::open_buf_read(LISTING_PATH) else {
return None;
};
However, to make this work, we need to swap open_buf_read() for a test version. Also, since the code afterward is
basically using the BufRead implementation, we want to return something that implements BufRead, but makes it easier
to control its behavior.
Let's solve these challenges one by one. First, let's move the function we just wrote into an auxiliary module so we can play with the namespace for the replacement.
mod aux {
use std::{fs::File, io::BufReader};
pub fn open_buf_read(path: &str) -> Option<BufReader<File>> {
let Ok(mut file) = File::open(path) else {
return None;
};
Some(BufReader::new(file))
}
}And we import the function from that module into the main code.
#[cfg(not(test))]
use aux::open_buf_read;With that change, we can use the function without qualification in our method, enabling the replacement later.
pub fn are_there_vulnerable_locations_efficient(&self) -> Option<bool> {
let Some(buf_listing) = open_buf_read(LISTING_PATH) else {
// ...
This is enough to extract the dependency and make it replaceable. The code compiles with cargo b --lib, but the tests
still cannot be compiled. Now, let's work on having the dependency replaced with something we can control for testing
our code.
Make the dependency controllable
File implements the Read trait, but BufReader implements BufRead on top of it, which provides more efficient
read operations by using a buffer. And our method only uses the functionality provided by BufRead, so we can return
something that implements that trait instead of the more specific type BufReader<File>. That could be the more
flexible type Option<impl BufRead>, which uses impl in a return position.
pub fn open_buf_read(path: &str) -> Option<impl BufRead> {
We can make the change and compile with cargo b --lib to verify it.
The advantage of returning an implementation of BufRead is that we can implement an object that uses those methods at
our will, and that it doesn't require a file. However, implementing the BufRead trait means implementing all its
methods and the methods of its supertrait, i.e., Read, or at least, the required ones. Working for a supervillain has
taught me that it is always better to let others do the hard work and take the credit, so I suggest to taking a look at
the BufRead documentation and seeing if there is an implementor that we can use.
It turns out that &[u8] implements BufRead, which is very convenient for us. Let's use it in the test version of the
function that we have extracted to produce the dependency.
First, we define another thread-local variable to hold the buffer contents for each test.
thread_local! {
// ...
static BUF_CONTENTS: RefCell<String> = RefCell::new(String::new());
}
And we add a test double of our function that returns a BufRead implementation using that string. Or, rather, it's
conversion to &[u8].
mod tests {
// ...
mod doubles {
// ...
pub fn open_buf_read(path: &str) -> Option<impl BufRead> {
Some(BUF_CONTENTS.take().as_bytes())
}
}
}And we can import that function from its namespace when we are compiling the code for testing.
#[cfg(test)]
use tests::doubles::open_buf_read;
But… (sad trombone), let me remind you that this isn't just another programming language, and it won't allow us to do
this. We are obtaining the string from thread-local storage and borrowing a reference to its [u8] content, which will
be released by the end of that function test double, i.e., the end of its scope, and render the reference
invalid. Should we surrender and accept this code as non-testable? No way! Let's wrap our heads around the problem
then.
We need another implementation of BufRead, one that takes ownership of its contents, and taking a look at the list of
usual suspects implementors, Cursor catches our eye. Let's try that.
pub fn open_buf_read(path: &str) -> Option<impl BufRead> {
Some(Cursor::new(BUF_CONTENTS.take()))
}
Compile and run the tests with cargo b --lib and show your extreme happiness to the rest of the world. But we haven't
finished yet. We still have to write the tests for our new method.
We will test for the efficient code on three of the scenarios that we did for the original one:
- When the file cannot be opened
- When the file contains a weak target
- When the file doesn't contain a weak target
The one where the file cannot be read isn't relevant here, because lines() filters out read errors and returns an
empty iterator.
Test when the file cannot be opened
The auxiliary function returns an option, and in the original code, we use None to indicate "the file cannot be opened". The function test double does not cover that; nonetheless, we can add it now.
We will need another thread-local static variable, a simple Cell<bool>, to control that behavior3.
thread_local! {
// ...
static FILE_CAN_OPEN: Cell<bool> = Cell::new(false);
}The function test double can check that value to implement the desired behavior.
pub fn open_buf_read(path: &str) -> Option<impl BufRead> {
if FILE_CAN_OPEN.get() {
Some(Cursor::new(BUF_CONTENTS.take()))
} else {
None
}
}We can test what happens when the file cannot be opened with a very similar version to the first iteration in the previous article.
#[test_context(Context)]
#[test]
fn efficient_vulnerable_locations_with_no_file_returns_none(ctx: &mut Context) {
FILE_CAN_OPEN.set(false);
assert_none!(ctx.sut.are_there_vulnerable_locations_efficient());
}
Run the tests with cargo t --lib. So far, so good!
Test when the file contains a weak target
This will be very similar to the original test. We arrange the configuration of our function test double so the file can be opened, and the contents have a weak target, call the method, and expect a result that confirms the existence of vulnerable locations.
#[test_context(Context)]
#[test]
fn efficient_vulnerable_locations_with_weak_returns_true(ctx: &mut Context) {
FILE_CAN_OPEN.set(true);
BUF_CONTENTS.replace(String::from(
r#"Madrid,strong
Las Vegas,weak
New York,strong"#,
));
assert_some_eq_x!(ctx.sut.are_there_vulnerable_locations_efficient(), true);
}
Run the tests with cargo t --lib. Getting there!
Test when file doesn't contain a weak target
To nobody's surprise, this time we set the configuration of the function test double so the file can be opened, but the contents don't have a weak target.
#[test_context(Context)]
#[test]
fn efficient_vulnerable_locations_without_weak_returns_false(ctx: &mut Context) {
FILE_CAN_OPEN.set(true);
BUF_CONTENTS.replace(String::from(
r#"Madrid,strong
Oregon,strong
New York,strong"#,
));
assert_some_eq_x!(ctx.sut.are_there_vulnerable_locations_efficient(), false);
}
Run the tests with cargo t --lib and see them pass with flying colors. Hooray!
Final code
I have kept both versions of the reading method and their seven tests, three of which are new. 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::{BufRead, BufReader, 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};
#[cfg(not(test))]
use aux::open_buf_read;
#[cfg(test)]
use tests::doubles::open_buf_read;
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)
}
pub fn are_there_vulnerable_locations_efficient(&self) -> Option<bool> {
let Some(buf_listing) = open_buf_read(LISTING_PATH) else {
return None;
};
let mut list_iter = buf_listing.lines();
while let Some(line) = list_iter.next() {
if let Ok(line) = line
&& 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 },
}
mod aux {
use std::{
fs::File,
io::{BufRead, BufReader},
};
pub fn open_buf_read(path: &str) -> Option<impl BufRead> {
let Ok(mut file) = File::open(path) else {
return None;
};
Some(BufReader::new(file))
}
}
#[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_IF_CAN_OPEN: RefCell<Option<doubles::File>> = RefCell::new(None);
static FILE_CAN_OPEN: Cell<bool> = Cell::new(false);
static BUF_CONTENTS: RefCell<String> = RefCell::new(String::new());
}
#[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_IF_CAN_OPEN.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_IF_CAN_OPEN.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_IF_CAN_OPEN.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_IF_CAN_OPEN.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);
}
#[test_context(Context)]
#[test]
fn efficient_vulnerable_locations_with_no_file_returns_none(ctx: &mut Context) {
FILE_CAN_OPEN.set(false);
assert_none!(ctx.sut.are_there_vulnerable_locations_efficient());
}
#[test_context(Context)]
#[test]
fn efficient_vulnerable_locations_with_weak_returns_true(ctx: &mut Context) {
FILE_CAN_OPEN.set(true);
BUF_CONTENTS.replace(String::from(
r#"Madrid,strong
Las Vegas,weak
New York,strong"#,
));
assert_some_eq_x!(ctx.sut.are_there_vulnerable_locations_efficient(), true);
}
#[test_context(Context)]
#[test]
fn efficient_vulnerable_locations_without_weak_returns_false(ctx: &mut Context) {
FILE_CAN_OPEN.set(true);
BUF_CONTENTS.replace(String::from(
r#"Madrid,strong
Oregon,strong
New York,strong"#,
));
assert_some_eq_x!(ctx.sut.are_there_vulnerable_locations_efficient(), 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, Cursor, 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_IF_CAN_OPEN.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))
}
}
}
pub fn open_buf_read(path: &str) -> Option<impl BufRead> {
if FILE_CAN_OPEN.get() {
Some(Cursor::new(BUF_CONTENTS.take()))
} else {
None
}
}
}
}Summary
I have explained how to make a more complex dependency replaceable by moving its creation to an auxiliary function. That is particularly useful when the signature of the method that we are testing cannot be changed for one reason or another.
I have also covered how to use Cursor as a BufRead replacement, as well as the thinking process for choosing the
right type to help you with your tests.
Stay curious. Hack your code. See you next time!