How to test functions that use println!()

Hello peers.

I have created a cli that prints to stdout (GitHub - nilsmartel/string: Simple CLI to perform common string operations).
The println! macro is nested into the core functionality.

All of this makes it rather hard to test the code and refactor it in a way that feels like it makes sense.

How would you rewrite the print statement, so that it is easier to test using unit tests?

Example:

I have this function

fn main() {
    // something complicated ...
         println!("{}", result);
    // more complicated stuff
}

And I want to move everything complicated into it's own function in a way, that the funciton is nicely test-able

Ideas I had

  • Make the function accept an output: &mut impl Writer as argument
  • Make the function accept an channel as argument

Thank you for reading through this and have a beautiful day!

Move the computation of the result and the printing in two separate functions. Have the first function return the result. Only test this first function, by directly comparing the return value to the expected result, without converting either of them to a string in any way.

3 Likes

If the printing is a core part of your logic, or so intimately coupled with your code that it would be hard to extract in the way @H2CO3 suggested, you could pass in some object which receives things it should print.

For example, we might define a Logger trait and create an implementation which just forwards to println!():

use std::fmt::Arguments;

trait Logger {
  fn print(&mut self, value: &Arguments<'_>);
}

struct StdoutLogger;

impl Logger for StdoutLogger {
  fn print(&mut self, value: &Arguments<'_>) {
    println!("{}", value);
  }
}

(I use std::fmt::Arguments here so you can avoid extra allocations, but you could just as easily accept &str or String in the print() method)

Your main.rs might then look like this:


fn my_complicated_function(logger: &mut dyn Logger) { 
  logger.print(format_args!("{}", result));
}

fn main() {
  my_complicated_function(&mut StdoutLogger);
}

And during testing you might create a DummyLogger which just turns the messages into a string and saves it in a Vec.

#[cfg(test)]
mod tests {
  use super::*;

  #[derive(Default)]
  struct DummyLogger(Vec<String>);
  impl Logger for DummyLogger {
  fn print(&mut self, value: &Arguments<'_>) {
    self.0.push(value.to_string());
   }
  }

  #[test]
  fn some_test() {
  let mut logger = DummyLogger::default();

  my_complicated_function(&mut logger);

  assert_eq!(logger.0[2], "Hello, World!");
}

Threading the Logger through to each nested function that needs it might get annoying, but normally I'll interpret that as my code telling me "maybe things are too coupled and you should find a simpler way to structure this code".

This general technique is often referred to a "Dependency Injection".

2 Likes

thank you two for the swift and intricate response.
I'll look at my code through this lense, in any way I'll have an easier time testing it.

I really like how your responses build onto each other.
Have a great day!

When I was working on a toy project that used macros to generate println! calls, I came up with this solution using the gag library.

I whipped up this assert_stdout_eq! macro:

#[macro_export]
macro_rules! assert_stdout_eq {
    ($test:expr, $expected:literal) => {{
        use gag::BufferRedirect;
        use std::io::Read;

        let mut buf = BufferRedirect::stdout().unwrap();

        $test;

        let mut output = String::new();
        buf.read_to_string(&mut output).unwrap();
        drop(buf);

        assert_eq!(&output[..], $expected);
    }};
}

So I could put this in my tests:

assert_stdout_eq!(my_macro!("KEY", "VALUE"), "KEY=VALUE\n");
}

Then I put this in my build.rs so that I could just run cargo test without any extra options:

fn main() {
    println!("cargo:rustc-env=RUST_TEST_NOCAPTURE=1");
}
2 Likes

a little late to reply, but this feels like a very neat piece of code

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.