How I test Rust command-line apps with assert_cmd
Rust has become my go-to language for my personal toolbox – small, standalone utilities like create_thumbnail, emptydir, and dominant_colours. There’s no place for Rust in my day job, so having some self-contained hobby projects means I can still have fun playing with it.
I’ve been using the assert_cmd
crate to test my command line tools, but I wanted to review my testing approach before I write my next utility. My old code was fine and it worked, but that’s about all you could say about it – it wasn’t clean or idiomatic Rust, and it wasn’t especially readable.
My big mistake was trying to write Rust like Python. I’d written wrapper functions that would call assert_cmd
and return values, then I wrote my own assertions a bit like I’d write a Python test. I missed out on the nice assertion helpers in the crate. I’d skimmed just enough of the assert_cmd
documentation to get something working, but I hadn’t read it properly.
As I was writing this blog post, I went back and read the documentation in more detail, to understand the right way to use the crate. Here are some examples of how I’m using it in my refreshed test suites:
Testing a basic command
This test calls dominant_colours
with a single argument, then checks it succeeds and that a single line is printed to stdout:
use assert_cmd::Command;
/// If every pixel in an image is the same colour, then the image
/// has a single dominant colour.
#[test]
fn it_prints_the_colour() {
Command::cargo_bin("dominant_colours")
.unwrap()
.arg("./src/tests/red.png")
.assert()
.success()
.stdout("#fe0000\n")
.stderr("");
}
If I have more than one argument or flag, I can replace .arg
with .args
to pass a list:
use assert_cmd::Command;
/// It picks the best colour from an image to go with a background --
/// the colour with sufficient contrast and the most saturation.
#[test]
fn it_chooses_the_right_colour_for_a_light_background() {
Command::cargo_bin("dominant_colours")
.unwrap()
.args(&[
"src/tests/stripes.png",
"--max-colours=5",
"--best-against-bg=#fff",
])
.assert()
.success()
.stdout("#693900\n")
.stderr("");
}
Alternatively, I can omit .arg
and .args
if I don’t need to pass any arguments.
Testing error cases
Most of my tests are around error handling – call the tool with bad input, and check it returns a useful error message. I can check that the command failed, the exit code, and the error message printed to stderr:
use assert_cmd::Command;
/// Getting the dominant colour of a file that doesn't exist is an error.
#[test]
fn it_fails_if_you_pass_an_nonexistent_file() {
Command::cargo_bin("dominant_colours")
.unwrap()
.arg("doesnotexist.jpg")
.assert()
.failure()
.code(1)
.stdout("")
.stderr("No such file or directory (os error 2)\n");
}
Comparing output to a regular expression
All the examples so far are doing an exact match for the stdout/stderr, but sometimes I need something more flexible. Maybe I only know what part of the output will look like, or I only care about checking how it starts.
If so, I can use the predicate::str::is_match
predicate from the predicates
crate and define a regular expression I want to match against.
Here’s an example where I’m checking the output contains a version number, but not what the version number is:
use assert_cmd::Command;
use predicates::prelude::*;
/// If I run `dominant_colours --version`, it prints the version number.
#[test]
fn it_prints_the_version() {
// Match strings like `dominant_colours 1.2.3`
let is_version_string =
predicate::str::is_match(r"^dominant_colours [0-9]+\.[0-9]+\.[0-9]+\n$").unwrap();
Command::cargo_bin("dominant_colours")
.unwrap()
.arg("--version")
.assert()
.success()
.stdout(is_version_string)
.stderr("");
}
Creating focused helper functions
I have a couple of helper functions for specific test scenarios.
I try to group these by common purpose – they should be testing similar behaviour. I’m trying to avoid creating helpers for the sake of reducing repetitive code.
For example, I have a helper function that passes a single invalid file to dominant_colours
and checks the error message is what I expect:
use assert_cmd::Command;
use predicates::prelude::*;
/// Getting the dominant colour of a file that doesn't exist is an error.
#[test]
fn it_fails_if_you_pass_an_nonexistent_file() {
assert_file_fails_with_error(
"./doesnotexist.jpg",
"No such file or directory (os error 2)\n",
);
}
/// Try to get the dominant colours for a file, and check it fails
/// with the given error message.
fn assert_file_fails_with_error(
path: &str,
expected_stderr: &str,
) -> assert_cmd::assert::Assert {
Command::cargo_bin("dominant_colours")
.unwrap()
.arg(path)
.assert()
.failure()
.code(1)
.stdout("")
.stderr(predicate::eq(expected_stderr))
}
Initially I wrote this helper just calling .stderr(expected_stderr)
to do an exact match, like in previous tests, but I got an error “expected_stderr
escapes the function body here”. I’m not sure what that means – it’s something to do with borrowing – but wrapping it in a predicate seems to fix the error, so I’m happy.
My test suite is a safety net, not a playground
Writing this blog post has helped me refactor my tests into something that’s actually good. I’m sure there’s still room for improvement, but this is the first iteration that I feel happy with. It’s no coincidence that it looks very similar to other test suites using assert_cmd
.
My earlier approaches were far too clever. I was over-abstracting to hide a few lines of boilerplate, which made the tests harder to follow. I even wrote a macro with a variadic interface because of a minor annoyance, which is stretching the limits of my Rust knowledge. It was fun to write, but it would have been a pain to debug or edit later.
It’s okay to have a bit of repetition in a test suite, if it makes them easier to read. I keep having to remind myself of this – I’m often tempted to create helper functions whose sole purpose is to remove boilerplate, or create some clever parametrisation which only made sense as I’m writing it. I need to resist the urge to compress my test code.
My new tests are more simple and more readable. There’s a time and a place for clever code, but my test suite isn’t it.