Chapter 12. Testing

Testing is probably the most important task in which a programmer engages during the software crafting process.

Though it is certainly no silver bullet for guaranteeing software quality, it is a fantastic way to show the presence of bugs for known/expected behavior. In addition, modern tooling can automatically display test coverage ratios in nearly any language.

Chapter 12. Testing

Testing is the process of comparing the invisible to the ambiguous, so that the bugs we don't see don't trip us up. – Robert C. Martin (Uncle Bob)

Unit tests in Rust

Unit testing in Rust is quite straightforward. One thing that may be somewhat foreign from other languages is that test code is typically included alongside the module function code in Rust.

In particular, Rust uses an attribute #[cfg(test)] that specifies to the compiler to conditionally compile the code marked beneath it. This way, test and functionality code are kept close to one another but that test code is not included when the code is compiled.

Note: It's typical to use that test marker with test setup code and actual testing code.

Unit testing lib code

Because Rust language design promotes the use of separation of concerns, you'll write most of your unit tests in src/lib.rs files.

So let's write some trivial code for testing purposes, make it fail and then make it pass. Welcome to the wheel of development.

Let's add the test first.

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

    #[test]
    fn multiply_works() {
        assert_eq!(multiply(&2, &2), 4);
    }
}

This won't work yet because we don't have the multiply function even declared.

// src/lib.rs

fn multiply(lhs: &i32, rhs: &i32) -> i32 {
    1
}

Now run the tests with cargo test. Bask in that failure. And now let's clean it up with a correct implementation for multiply.

// src/lib.rs

fn multiply(lhs: &i32, rhs: &i32) -> i32 {
    lhs * rhs
}

Now run those tests again and bask in your passing glory. Your output should look something like the following.

29% ❯ cargo test
    Finished test [unoptimized + debuginfo] target(s) in 0.00s
     Running unittests src/main.rs (target/debug/deps/test_examples-b2e0b55724fa4029)

running 1 test
test tests::test_multiply ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Unit testing main code

Though it's not recommended, because your functionality should live in another module and be tested there, you can certainly test code living in the main function.

fn multiply(lhs: &i32, rhs: &i32) -> i32 {
    lhs * rhs
}

fn main() {
    let result = multiply(&2, &2);
    println!("result: {}", result);
}

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

    #[test]
    fn test_multiply() {
        assert_eq!(multiply(&2, &2), 4);
    }
}

Run it with cargo test and watch that glorious passage.

Built-in tools for unit testing

Rust has many built-in tools you can and should use for organizing and communicating information from your test runs. It's common to have a CICD step in your build and/or development process that runs these and communicating what went wrong, when and where is critical. Rust makes this simple.

should_panic

Since your tests are just Rust code you can and should make them panic! when you have code that must happen or can't happen, as in, you expect it to panic.

All you have to do is mark it with #[should_panic].

#[cfg(test)]
mod tests {
    #[test]
    #[should_panic]
    fn ima_panic() {
        panic!("Make the test run stop and this test fail!");
    }
}

Running this will produce a huge FAIL and stop the remaining tests from executing.

Other macros

Other macros we can use include assert!, which checks truthiness, assert_ne!, which checks if something isn't equal, and of course, our already used assert_eq! macro which checks if something is equal.

Let's see how we might use each of these.

#[cfg(test)]
mod tests {
    #[test]
    fn this_is_not_equal() { // this will pass
        assert_ne!(multiply(&2, &2), 5);
    }

    #[test]
    fn this_is_true() { // this will pass
        assert!(multiply(&2, &2) == 4);
    }
}

Custom messages

One of the issues large code bases face is testing complexity. When something fails we need to communicate what was supposed to happen and why it failed.

Rust has a convenient ability to do that with its built-in test infrastructure.

    #[test]
    fn this_is_not_equal() { // this will pass
        assert_ne!(multiply(&2, &2), "2 * 2 != 5", 5);
    }

    #[test]
    fn this_is_not_true() { // this will not pass
        assert!(multiply(&2, &2) == 5, "2 * 2 != 5");
    }

Now when we run the above with cargo test we get output that says what went on:

failures:

---- tests::this_is_not_true stdout ----
thread 'tests::this_is_not_true' panicked at src/lib.rs:29:9:
2 * 2 != 5

Leveraging Result<T, E>

Testing Rust code that returns a Result<T, E> is extremely convenient, as an Err value returned will fail calling code. Let's see this in action.


fn a_function_using_result() -> Result<bool, String> {
    Ok(true)
}

#[test]
fn test_using_result() -> Result<(), String> {
    let ran_successfully = a_function_using_result()?; // Err value will fail here

    if ran_successfully {
        Ok(())
    } else {
        Err("Something terrible happened and we got an error. Spam your own phone number, developer, until someone picks up. Yell at them.".into())
    }
}

Writing integration tests in Rust

Integration tests should test how your code interactions and are external to your library code. Normally, we use a tests directory for these at the same level as your src directory. Cargo looks for this when you run the test command.

Ideally you want to mimic how an external client or developer will use this code when writing integration tests.

Let's make that directory and add some integration testing code to it, which will be exactly the same as our unit test code at this point.

mkdir tests
// tests/integration.rs
use test_examples;

#[test]
fn it_multiplies_two_numbers() {
    assert_eq!(test_examples::multiply(&2, &2), 4);
}

Notice how we pull in test_examples, the name of this crate? This is necessary because each file inside the tests example is considered its own crate by the compiler. Secondly, we don't need the cfg configuration flag because this is a known directory for unit tests.

Let's run this and see what happens.

❯ cargo test
    Finished test [unoptimized + debuginfo] target(s) in 0.00s
     Running unittests src/lib.rs (target/debug/deps/test_examples-8cbec1295e96061c)

running 1 test
test tests::test_multiply ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running unittests src/main.rs (target/debug/deps/test_examples-0965484333ee197a)

running 1 test
test tests::test_multiply ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running tests/integration.rs (target/debug/deps/integration-90f1e0a063430ba2)

running 1 test
test it_multiplies_two_numbers ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests test_examples

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Passing like a cop with flatulence.

Running tests

As you've seen, testing Rust code is as simple as writing these tests and running cargo test.

Github CICD pipeline

It's easy to create a simple Github action you can use to run your tests in a CICD pipeline.

  1. Create a .github/workflows directory in your repository if it doesn't already exist.
  2. Inside the .github/workflows directory, create a new file named rust.yml (or any name you prefer).

Add the following content to the rust.yml file:

name: Rust

on:
  push:
    branches:
      - main

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout repository
        uses: actions/checkout@v2

      - name: Set up Rust
        uses: actions-rs/toolchain@v1
        with:
          toolchain: stable
          override: true

      - name: Build and test
        run: |
          cargo build --verbose
          cargo test --verbose

What's happening here?

  • name: Rust: The name of the workflow.
  • on: push: Specifies that this workflow should run when code is pushed to the repository.
  • branches: main: Specifies that the workflow should run only when code is pushed to the main branch.
  • jobs: Defines the jobs to run in the workflow.
    • test: The name of the job.
      • runs-on: ubuntu-latest: Specifies the environment to run the job (latest Ubuntu environment).
      • steps: Defines the sequence of steps to run in the job.
        • actions/checkout@v2: Checks out the repository code.
        • actions-rs/toolchain@v1: Sets up the Rust toolchain.
        • cargo build --verbose and cargo test --verbose: Runs the build and test commands with verbose output.

This setup ensures that every time you push code to the main branch, GitHub Actions will run cargo test to verify that your code passes all tests.

Was this page helpful?