Welcome to Software Development on Codidact!
Will you help us build our independent community of developers helping developers? We're small and trying to grow. We welcome questions about all aspects of software development, from design to code to QA and more. Got questions? Got answers? Got code you'd like someone to review? Please join us.
Writing a testable console program
I have a class Foo
that prints something to stdout and I want to be able to write tests for it.
So I created a trait to abstract println!
, and gave it a prod implementation and a test implementation.
The test implementation simply writes the strings to a vector, so that tests can make assertions on the contents of the vector.
The problem is that since the MockIO
modifies itself, I'm forced to write the method's signature as fn println(&mut self, s: &str)
instead of fn println(&self, s: &str)
. Which in turns forces me to sprinkle mut
s all over the code.
// main.rs
mod foo;
mod io;
fn main() {
let mut io_interface = io::StdIO{};
foo::Foo::print_hello(&mut io_interface);
}
// foo.rs
use crate::io::{self, IO};
pub struct Foo {
}
impl Foo {
pub fn print_hello<I>(io_interface: &mut I) where I: IO {
io_interface.println("Hello, World!");
}
}
#[cfg(test)]
#[test]
fn print_hello_world() {
let mut mock_io = io::MockIO::new();
Foo::print_hello(&mut mock_io);
assert_eq!(mock_io.outputs[0], "Hello, World!");
}
// io.rs
pub struct StdIO;
pub trait IO {
fn println(&mut self, s: &str);
}
impl IO for StdIO {
fn println(&mut self, s: &str) {
println!("{}", s);
}
}
#[cfg(test)]
pub struct MockIO {
pub outputs: Vec<String>,
}
#[cfg(test)]
impl IO for MockIO {
fn println(&mut self, s: &str) {
self.outputs.push(s.to_string());
}
}
#[cfg(test)]
impl MockIO {
pub fn new() -> Self {
MockIO { outputs:vec![] }
}
}
So my questions are:
- is there a way to write
MockIO
so that I don't have to change the signature ofIO::println()
for the sake of the test class? - is there a better way to solve this problem altogether?
Feel free to point out any non-idiomatic code as well. I've done a fair bit of software development but am new to Rust.
2 answers
is there a way to write MockIO so that I don't have to change the signature of IO::println() for the sake of the test class?
Yes, implementing mock objects is one of the recommended uses for RefCell<T>
, according to the Rust book.
By placing the vector in a RefCell
you gain the ability to modify it (carefully!) via a non-mut
reference.
struct MockIO {
outputs: RefCell<Vec<String>>,
}
impl IO for MockIO {
fn println(&self, s: &str) {
self.outputs.borrow_mut().push(s.to_string());
}
}
I've also removed the pub
s from the test code in this example: generally test code should live in the same module (or a sub-module) as the code it is testing, and does not need to be exposed publicly. If the struct does need to be exposed for some reason, the RefCell
certainly shouldn't be pub
, because it would be impossible to enforce the requirement that there is never more than one borrow_mut()
active at once (otherwise the code will panic at runtime).
is there a better way to solve this problem altogether?
Personally I think this approach is rather too complicated. I presume that it is not the act of printing that you want to test, but just that your class generates the correct string contents.
I would test this by simply splitting the printing code from the string-generating code, then only test the generating code while assuming that the basic Rust functionality of printing to stdout works fine.
E.g.
impl Foo {
fn make_hello() -> String {
String::from("Hello, World!")
}
pub fn print_hello() {
let line = make_hello();
println!("{line}");
}
}
Now you can write all the tests for make_hello()
that you require by examining the returned string, without needing any abstracted IO traits.
Note the different visibility of the two methods: print_hello()
is pub
because it is intended to be called by client code, whereas make_hello()
is not pub
because it is only intended to be called in unit tests within the same module. You could of course choose to make make_hello()
public if there was a potential need for client code to generate an output string without immediately printing it.
If you really do want to test printing rather than just string generation, or you find the allocation of String
objects unacceptable for any reason, then you should use the std::io::Write
trait exposed in the standard library (as recommended by Moshi in the comments) rather than defining your own.
Organising tests
Instead of adding a #[cfg(test)]
to each individual chunk of test code, it is more idiomatic to put all of the test code in a tests
submodule at the end of the module being tested. This makes the tests easy to find, and keeps them private while allowing them to access all of the code in the parent module due to Rust's module visibility rules.
#[cfg(test)]
mod tests {
use super::*; // import everything from parent module
// Define mock objects, other test-specific code here
struct MockIO {
outputs: RefCell<Vec<String>>,
}
impl IO for MockIO {
// etc
}
// Actual test functions here
#[test]
fn make_hello_world() {
// etc
}
}
0 comment threads
Not using Rust myself I'll add to @InfiniteDissent's answer on:
is there a better way to solve this problem altogether?
Yes, there is, it's called a golden master test.
Legacy code retreat taught me a golden master testing technique for cases just like that.
- You create a set of inputs for your application.
- You capture a set of outputs for these inputs, creating working pairs: i1 -> o1, i2 -> o2, ... in -> on. That is your golden master.
- For each change you should run all inputs and COMPARE the outputs with the golden master ones.
For console-heavy apps I'd usually do the following:
- make your CLI app accept an array of inputs (or a file with inputs).
- have the app source inputs from said array/file.
- capture the output by redirecting it to a file.
- Assuming that we have 40 sets of inputs:
for i in $(seq 40); do ./app input$i > output$i; done; diffOutputsWithGoldenMaster.sh
- For diffing the outputs, sometimes just
diff
is enough (I always tried to make it so). Sometimes, you want something special, usually to highlight clearer the parts broken.
In short: I'd move the testing outside of the Rust app. I had success with this technique also with SQL, where I wanted to make sure all reports worked before and after we completely overhauled the engine for their making, running and exporting. We had two Jenkins jobs, one for query generation (in your case, message creation) and one for actual results of the reports being the same (the console log of your app run) as the golden master.
AFAIR, the name is from music recording, where the original, often golden CD, was "the master record", a collectors item. And the source of mass-production of CD albums.
1 comment thread