Testing
Writing unit, integration, acceptance, regression, performance, etc. tests in Rust.
Writing unit-, integration-, etc. tests should be an integral part of the development work, but in my experience in many organizations it is more like an afterthought. Sometimes relegated to a separate team or separate department.
Some people put a lot of emphasize on the separation between unit-, integration-, and acceptance testing. They are all part of the larger group called functional testing. I think it is primarily a question of scope. They all have the same equation:
Fixture + Input = Expected Output + Bugs
In this series of articles we are going to cover how one could write tests in Rust.
-
In the series about the Rocket web development framework each example in each article comes with its tests.
-
There is a nice collection of types of testing.
Show standard output and standard error in tests in Rust
Sometimes we would like to include print statements in the tests in Rust. How can we see them?
Sometimes our code prints to the screen, either to the standard output channel or the standard error channel. There are cases when we might want to verify the content of what was printed in other case we just would like to see.
By default when we run cargo test
, it will capture and hide both channels. Using the --show-output
we can convince
cargo
to print the captured output.
In this example we'll see this working both inline tests that are usually used for unit-testing and external tests that, in this case, run our code as an external program as we can also see in the article on how to test command line application.
Code with inline tests
fn main() { answer(); } fn answer() -> u32 { println!("STDOUT in code"); eprintln!("STDERR in code"); 42 } #[test] fn test_function() { println!("STDOUT In test_function"); eprintln!("STDERR In test_function"); assert_eq!(answer(), 42); }
External tests
Running the program as a command line application we would probably check that the text printed to the STDOUT and STDERR by the program is what we expect. Still we have some text printed by the test that we would like to see.
#![allow(unused)] fn main() { use std::{ os::unix::process::ExitStatusExt, process::{Command, ExitStatus}, }; #[test] fn test_cli() { println!("STDOUT In test_cli"); eprintln!("STDERR In test_cli"); let result = Command::new("cargo") .args(["run", "-q"]) .output() .expect("command failed to start"); assert_eq!( std::str::from_utf8(&result.stdout).unwrap(), "STDOUT in code\n" ); assert_eq!( std::str::from_utf8(&result.stderr).unwrap(), "STDERR in code\n" ); assert_eq!(result.status, ExitStatus::from_raw(0)); } }
Regular test output
$ cargo test -q
running 1 test
.
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
running 1 test
.
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.04s
The upper part shows the inline test
Running the tests and showing the output
$ cargo test -q -- --show-output
running 1 test
.
successes:
---- test_function stdout ----
STDOUT In test_function
STDERR In test_function
STDOUT in code
STDERR in code
successes:
test_function
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
running 1 test
.
successes:
---- test_cli stdout ----
STDOUT In test_cli
STDERR In test_cli
successes:
test_cli
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.04s
Note
The -q
is an option of cargo
, but --show-outputis a parameter of the test runner. So we need the extra
--` in order to pass this argument to the actual test runner.
-
tags:
- testing
- STDOUT
- STDERR
- --show-output
-
todo:
- test capturing and validating the output in a function in an inline test
Test command line applications written in Rust
Testing a CLI - Command Line Interface or command line application by running it as an external program.
For every application, including command line tools, the ideal way would be to move all the code into functions and even separate libraries, but I rarely manage to reach that ideal. Especially when I get started I usually just want to get something done the simplest way I can. On the other hand I like it when my code is tested. It makes me feel safe.
Later, as I fix bugs or as I want to add more features I might start to refactor the code to be structured better and thus also to make it easier to write unit-tests for the individual functions, but even then I'd still want to have some tests that run the tool as a single unit.
That's why I need a way to test the command line tool as a stand-alone executable.
In this article we see a simple example.
The Command Line tool
For this I created a crate called test-cli
with the following code:
fn main() { let args = std::env::args().collect::<Vec<String>>(); if args.len() != 3 { eprintln!("Usage: {} WIDTH HEIGHT", args[0]); std::process::exit(2); } let x = args[1].parse::<u32>().unwrap(); let y = args[2].parse::<u32>().unwrap(); println!("{}", x * y); }
There is nothing fancy in this.
If the user supplies 2 parameters we try to convert them into u32
integers using the parse
method an the Turbofish.
Then we multiply them. I did not even implement proper error handling, just called unwrap
as this code is not the important part of the article.
The only precaution I did was checking if args
has 3 values meaning the user supplied two values. If not, then an error message will be printed to
the Standard Error channel and th program will exit with exit code 2. This number is totally arbitrary. 0 means success, any other number means failure.
We can use this program by running
cargo run -q
to get the error message
or
cargo run -q 6 7
to get the result which is 42.
Testing the Command Line tool
In order to test this command line application we created a folder called tests
and in there a file called tests.rs
with 3 test functions:
#![allow(unused)] fn main() { use std::{ os::unix::process::ExitStatusExt, process::{Command, ExitStatus}, }; #[test] fn test_empty_call() { let result = Command::new("cargo") .args(["run", "-q"]) .output() .expect("command failed to start"); assert_eq!(std::str::from_utf8(&result.stdout).unwrap(), ""); assert_eq!( std::str::from_utf8(&result.stderr).unwrap(), "Usage: target/debug/test-cli WIDTH HEIGHT\n" ); assert_eq!(result.status, ExitStatus::from_raw(256 * 2)); } #[test] fn test_multiply() { let result = Command::new("cargo") .args(["run", "-q", "6", "7"]) .output() .expect("command failed to start"); assert_eq!(std::str::from_utf8(&result.stdout).unwrap(), "42\n"); assert_eq!(std::str::from_utf8(&result.stderr).unwrap(), ""); assert_eq!(result.status, ExitStatus::from_raw(0)); } #[test] fn test_bad_input() { let result = Command::new("cargo") .args(["run", "-q", "3", "4.2"]) .output() .expect("command failed to start"); assert_eq!(std::str::from_utf8(&result.stdout).unwrap(), ""); // "thread 'main' panicked at src/main.rs:8:36:\ncalled `Result::unwrap()` on an `Err` value: // ParseIntError { kind: InvalidDigit }\n // note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace\n" //assert_eq!(std::str::from_utf8(&result.stderr).unwrap(), ""); assert!(std::str::from_utf8(&result.stderr) .unwrap() .contains("InvalidDigit")); assert_eq!(result.status, ExitStatus::from_raw(256 * 101)); } }
At first there is this rather complex use
-statement. I think running cargo fmt
mad it that way.
This would achieve the same:
#![allow(unused)] fn main() { use std::os::unix::process::ExitStatusExt; use std::process::Command; use std::process::ExitStatus; }
In the code we use std::process::Command to run the external program. Here we had a choice:
- Run
cargo run -q
(where-q
make it quiet) - Run
cargo build
once and then./target/debug/test-cli
for every test. - Run
cargo build --release
once and then./target/release/test-cli
for every test.
In most cases there should not be any difference in the results in the 3 cases. If this was an important real-world tool then I'd probably do the 3rd, and for easier debugging I might also do either the 1st or the 2nd.
In this example I used the 1st option.
In every test function we run cargo run -q
with some parameters. The result can be divided into 3 distinct parts.
- Whatever the program prints to STDOUT (Standard Output channel)
- Whatever the program prints to STDERR (Standard Error channel)
- The exit code of the program. (On Linux macOS that would be the content of
$?
. On MS Windows that would beERROR_LEVEL
).
Test empty call (missing parameters)
In the first test function called test_empty_call
we run cargo run -q
.
- We expect STDOUT to be empty.
- We expect STDERR to contain the usage-message we printed with
eprintln!
. - The exit code is expected to be 2, but we actually get 256 times whatever the real exit code is. I wrote the multiplication explicitly as I think it makes the code more readable.
Test with proper parameters
In the second test function called test_multiply
we run cargo run -q 6 7
.
- We expect STDOUT to contain the correct answer.
- We expect STDERR to be empty.
- The exit code to be 0 indicating success.
Test with invalid parameters
In the third test function called test_bad_input
we run cargo run -q 3 4.2
.
This is incorrect as our implementation was only "designed" to handle integers. However we did not do any error checking so this is expected to panic!
.
I think in a real-world application this case should NOT exist as no application should ever panic!
. The code should be fixed to handle this properly
and then we should write a test to verify that the application provides the proper error message and exit code.
However as this is what we have here, let's see how do we test this?
- We expect STDOUT to be the empty string.
- The STDERR would be some long message. We could copy the error message we received the first time and set it as the exact expectation for further runs of the test, but it would be a bit fragile. Especially as it includes the row and column number of the location of the panic!. Moving the code around, even just adding an empty row at the top of the file would break the test. So instead of exact match we check if the string
InvalidDigit
appears in the error message. - After running the code for the first time with this input I checked the exit code and that's how I set the expectation to be 25856.
How do we run the tests?
$ cargo test -q
And the output is:
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
running 3 tests
...
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.04s
Regression tests
Let me emphasize the interesting part of the 3rd test case as similar things happen often.
I did not have a clear idea of what exactly to expect. So I ran the code once and saved the result (or part of it) as the expectation for any further execution of the same test.
I don't know if this is the correct result, but I know that I'll notice when the result of this changes.
I use this technique quite often when for some reason I cannot verify the correctness of every result. At least I can verify that the results don't unexpectedly change as someone changes the code or upgrades the compiler or one of the dependencies.
Conclusion
This was a simple example. In more complex ones we the tool might need some other external input (e.g. a file, a folder, a database) and it might make changes to such external resources. Preparing those and making sure they don't interfere with the real data might be a lot of extra work, but the core of the test-running and result verification can be seen in this example.
- tags:
- CLI
- testing
- Command
- ExitStatus
- STDOUT
- STDERR
- panic!
Test coverage with Tarpaulin
cargo-tarpaulin is a code coverage reporting tool for the Cargo build system, named for a waterproof cloth used to cover cargo on a ship.
Install Tarpaulin
cargo install cargo-tarpaulin
Run Tarpaulin generate HTML report
cargo tarpaulin --out Html
Run Tarpaulin - print minimalistic text report on the screen
cargo tarpaulin --out Stdout
Testing with tempfiles and environment variables
How to pass the path of a temporary file to an application during testing?
There are many applications that need access to files on the filesystem or to a database. In order to have clear separation between the test runs I create a temporary folder (or a temporary database) and then I need to pass this to the test somehow. While programming in Perl or Python I used to do this by setting an environment variable in the test and then reading it in the application itself.
Sometimes this environment variable would contain a path to a configuration file that is then used by the application.
This patters is somewhat broken when trying use it in Rust. The reason is that Rust by default runs the tests in parallel in threads and environment variables are per process. Thus if I set two different temporary paths in two test-files one will override the other. Then depending on the timing one test might look at the temporary file of the other test.
Here is an example. I tried to simplify it, but it still feels a bit convoluted.
The dependencies
[package]
name = "test-tempfile"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
rand = "0.8.5"
tempfile = "3.9.0"
The code with the tests
use std::fs::File; use std::io::Write; fn main() { let args = std::env::args().collect::<Vec<String>>(); let name = &args[1]; println!("Hello {name}"); } fn add(x: i32, y: i32) -> i32 { let time: u64 = rand::random(); let time = time % 3; std::thread::sleep(std::time::Duration::from_secs(time)); match std::env::var("RESULT_PATH") { Ok(file_path) => { let mut file = File::create(&file_path).unwrap(); println!("add({x}, {y}) file {file_path}"); writeln!(&mut file, "{}", x + y).unwrap(); } Err(_) => {} }; x + y } #[test] fn test_add_2_3_5() { let tmp_dir = tempfile::tempdir().unwrap(); println!("tmp_dir: {:?}", tmp_dir); let file_path = tmp_dir.path().join("result.txt"); println!("2+3 - file_path {:?}", file_path); std::env::set_var("RESULT_PATH", &file_path); let result = add(2, 3); assert_eq!(result, 5); let result = std::fs::read_to_string(file_path).unwrap(); assert_eq!(result, "5\n"); } #[test] fn test_add_4_4_8() { let tmp_dir = tempfile::tempdir().unwrap(); println!("tmp_dir: {:?}", tmp_dir); let file_path = tmp_dir.path().join("result.txt"); println!("4+4 - file_path {:?}", file_path); std::env::set_var("RESULT_PATH", &file_path); let result = add(4, 4); assert_eq!(result, 8); let result = std::fs::read_to_string(file_path).unwrap(); assert_eq!(result, "8\n"); }
Running the tests
Running the tests in the usual way will randomly break as the environment variable get mixed between the two test-cases.
cargo test
The solution, for now, is to run the tests in a single thread:
cargo test -- --test-threads=1
Unfortunately people new to the project will likely try to run the default command and the test will fail for them.
We could mention this in the README file, but can we really rely on people reading the documentation?
My question on the users forum got a suggestion to
include the setting in the cargo/config.toml
file:
[alias]
t = "test -- --test-threads=1"
[env]
RUST_TEST_THREADS = "1"
Actually I include two suggestions.
Alias
The first one was to create an alias for the command. This way I can run
cargo t
and it will run the tests with the --test-threads=1
. This is convenient, but people will still run cargo test
and complain about the failures.
Set the RUST_TEST_THREADS
env variable
The second advice was to set the RUST_TEST_THREADS
environment variable in the .cargo/config.toml
file.
That worked. Now if I run
cargo test
It will run in a single thread.
Future
I am not entirely happy with this whole solution as it means I needed some special code in my application and I can't run the tests in a thread. I asked about this and I'll update this post when I have a better solution.
- tags:
- testing
- tempfile
- threads
- RUST_TEST_THREADS
Limit tests to single thread
- Testing with tempfiles and environment variables
- Using change-dir in a test which is also process-wide
On the command line:
cargo test -- --test-threads=1
Create the .cargo/config.toml
file and put the following in it:
[env]
RUST_TEST_THREADS = "1"