Testing a Rust binary crate

3 minute read Published:

Using std::process to write Rust tests for a binary crate, i.e. a crate that doesn't expose clean public functions like a library crate.

I recently wrote a binary crate in Rust called pq. In a nutshell, pq generically decodes protobuf messages into json given a collection of *.fdset files. This is useful in a scenario where you maintain several protobuf message schemas, and you need a quick way to eyeball a message encoded in any one of those types without knowing the exact type.

Here’s the source code on GitHub.

Since it’s a binary crate without an exposed lib.rs I researched idiomatic ways to run Rust tests on it. I discovered that xsv runs tests by invoking the binary with std::process.

Binary path

First, the Rust test suite needs to know how to find the binary. This piece of code was inspired by xsv: the runner module.

It gets the directory of the current test executable (which will also contain the compiled binary, pq):

let mut root = env::current_exe()
    .unwrap()
    .parent()
    .expect("executable's directory")
    .to_path_buf();

Tests path

In my case I also have some sample encoded protobuf message files in tests which I need to pass to my binary to check the result, so I find the tests path from the executable path:

let mut tests_path = root.parent().unwrap().parent().unwrap().to_path_buf();
tests_path.push("tests");

Command

Finally I create an std::process::Command with:

let mut cmd = Command::new(root.join("pq"));
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());

Passing stdin

A key feature of pq is that it operates on stdin by default, as intuitive Unix tools should. I needed a way to modify the stdin of a running process. Using std::process::Child does the trick:

pub fn with_stdin(&mut self, contents: &[u8]) {
    self.cmd.stdin(Stdio::piped());
    let mut chld = self._spawn();
    chld.stdin
	.as_mut()
	.unwrap()
	.write_all(contents)
	.unwrap();
    self.chld = Some(chld);
}

Getting the output

Finally, getting an std::process::Output to return from a Child is good as it lets you check the exit code, stdout, and stderr in your unit tests:

pub fn output(&mut self) -> Output {
    self.chld.take().unwrap().wait_with_output().unwrap()
}

Invoker utility for different arguments

At this point I’ve finished describing the important functionality of the runner module. Now I move onto the actual unit test file itself:

fn for_person(work: &mut Runner) {
    work.cmd.arg(&work.tests_path.join("samples/person"));
}

fn for_dog_stdin(work: &mut Runner) {
    let mut file = File::open(&work.tests_path.join("samples/dog")).unwrap();
    let mut buf = Vec::new();
    file.read_to_end(&mut buf).unwrap();
    work.with_stdin(&buf);
}

fn run_pqrs<F>(modify_in: F) -> Output
    where F: FnOnce(&mut Runner)
{
    let mut work = Runner::new();

    work.cmd
        .arg("--fdsets")
        .arg(&work.tests_path.join("fdsets"));

    modify_in(&mut work);

    work.spawn();
    work.output()
}

Putting it together

An actual unit test with a success exit code:

#[test]
fn test_person_decode() {
    let out = run_pqrs(for_person);

    //check if success
    assert!(out.status.success());

    //check output
    assert_eq!(String::from_utf8_lossy(&out.stdout),
               "{\"id\":0,\"name\":\"khosrov\"}");
}

Here’s one with an error:

#[test]
fn test_nonexistent_file() {
    let out = run_pqrs(for_nonexistent_file);

    //check if success
    assert_eq!(out.status.code().unwrap(), 255);

    //check output
    assert_eq!(String::from_utf8_lossy(&out.stdout), "");

    //check stderr
    assert_eq!(String::from_utf8_lossy(&out.stderr),
               "Could not open file: file-doesnt-exist\n");
}