In this article, we are going to learn how to write and run tests for Rust.

Unit tests

Rust made the interesting decision that unit tests should be written in the same files as the code under test. Let’s imagine we have a module with a function named add:

1
2
3
pub fn add(left: i64, right: i64) -> i64 {
    left + right
}

If we want to test that function, we would modify the file to look like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
pub fn add(left: i64, right: i64) -> i64 {
    left + right
}

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

    #[test]
    fn first_test() {
        let result = add(2, 2);
        assert!(result == 4);
    }
}

The #[cfg(test)] parameter tells the compiler to only compile that code when we are running tests. The #[test] parameter is used to specify a function as being a test.

We can run our tests using cargo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
cargo test

    Finished test [unoptimized + debuginfo] target(s) in 0.01s
     Running unittests src/lib.rs (target/debug/deps/testing-d359a3188418919d)

running 1 test
test tests::first_test ... ok

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

   Doc-tests testing

running 0 tests

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

We can see in the example above, the use of assert!. This is a macro that panics if the passed argument is false. Currently, there are three native assertions in Rust: assert, assert_eq, assert_ne. Here are some examples:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
pub fn add(left: i64, right: i64) -> i64 {
    left + right
}

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

    #[test]
    fn first_test() {
        let result = add(2, 2);
        assert!(result == 4);
    }

    #[test]
    fn assert_eq() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }

    #[test]
    fn assert_ne() {
        let result = add(2, 2);
        assert_ne!(result, 3);
    }
}

Assertions can contain custom messages to make it easier to understand what the problem is. For example:

1
2
3
4
5
#[test]
fn assert_with_message() {
    let result = add(2, 2);
    assert!(result == 4, "Expected 4, actual value was {}", result);
}

Another thing worth noting is that, since we are writing tests within the module, it’s possible to access private members. For example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
pub fn add(left: i64, right: i64) -> i64 {
    private_add(left, right)
}

fn private_add(left: i64, right: i64) -> i64 {
    left + right
}

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

    #[test]
    fn test_private_fn() {
        let result = private_add(2, 2);
        assert_eq!(result, 4);
    }
}

Sometimes we want to test that our code panics given certain conditions. We can test that with should_panic. For example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
pub fn divide(left: i64, right: i64) -> i64 {
    left / right
}

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

    #[test]
    #[should_panic]
    fn test_panic() {
        divide(2, 0);
    }

    #[test]
    #[should_panic(expected="divide by zero")]
    fn test_panic_with_message_matching() {
        divide(2, 0);
    }
}

In the second example we specified the divide by zero message. In this case, the test will pass if the panic message contains the given string.

If for some reason, we want to ignore a test (maybe it’s flaky and we don’t want to block all builds while we fix it), we can use the #[ignore] parameter. For example:

1
2
3
4
5
6
    #[test]
    #[ignore]
    fn ignored_failing_test_with_message() {
        let result = add(2, 2);
        assert_eq!(result, 3, "Expected 3, actual value was {}", result);
    }

If we are troubleshooting a specific test, we can filter the tests we want to run by specifying a string to match against the name of the test. For example:

1
cargo test test_private_fn

Doc Tests

Rust has a nice feature, where it automatically runs code that is defined using /// comments. This has the benefit that if our code changes and the examples in our comments are not valid anymore, we will be alerted about it.

An example of how to write doc tests:

1
2
3
4
5
6
7
8
9
10
/// Divides the first number by the second number. Panics if second number is 0
///
/// # Example
///
/// ```
/// assert_eq!(2, testing::divide(8, 4));
/// ```
pub fn divide(left: i64, right: i64) -> i64 {
    left / right
}

The output of running cargo test will look something like this:

1
2
3
4
   Doc-tests testing

running 1 test
test src/lib.rs - divide (line 5) ... ok

Integration tests

When we want to write tests that spawn multiple functions, files, or modules, we can create integration tests. Integration tests live in a tests folder at the same level of the src folder:

1
2
3
4
5
6
7
project_folder
├── Cargo.lock
├── Cargo.toml
├── src
│   └── main.rs
└── tests
    └── some_test.rs

When we run cargo test, cargo will automatically run all tests inside the tests folder.

Since all code under the tests folder is a test, we don’t need to use #[cfg(test)]. An example integration test looks like this:

1
2
3
4
5
6
7
use testing;

#[test]
fn integration_test() {
    let result = testing::add(2, 2);
    assert_eq!(result, 4);
}

Mocking with Mockall

Mocking is a commonly used tool when writing tests. In Rust, the most popular tool for doing this is Mockall.

The simplest way to use it, is applying the #[automock] parameter to a trait. After doing this, a mock named Mock<Name of the trait> will be made available. With the mock we can do things like expect a method to be called or return a specified value when calling a function. An example usage:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
use mockall::{
    automock,
};

#[automock]
trait Calculator {
    fn add(&self, left: i64, right: i64) -> i64;
    fn subtract(&self, left: i64, right: i64) -> i64;
}

#[cfg(test)]
mod tests {
    use super::{
        Calculator,
        MockCalculator,
    };
    use mockall::predicate::eq;

    #[test]
    fn test_mock_expectations_are_met() {
        let mut mock = MockCalculator::new();
        mock.expect_add()
            .with(eq(1), eq(2))
            .return_const(3);

        assert_eq!(mock.add(1, 2), 3);
    }
}

For scenarios where we need a mock that implements multiple traits, we can use the mock! macro. For example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
use mockall::{
    mock
};

trait Calculator {
    fn add(&self, left: i64, right: i64) -> i64;
    fn subtract(&self, left: i64, right: i64) -> i64;
}

trait Beeper {
    fn beep(&self);
}

mock! {
    BeeperCalculator {}

    impl Calculator for BeeperCalculator {
        fn add(&self, left: i64, right: i64) -> i64;
        fn subtract(&self, left: i64, right: i64) -> i64;
    }

    impl Beeper for BeeperCalculator {
        fn beep(&self);
    }
}

#[cfg(test)]
mod tests {
    use super::{
        Beeper,
        Calculator,
        MockBeeperCalculator,
    };

    #[test]
    fn test_mock_multiple_traits() {
        let mut mock = MockBeeperCalculator::new();
        mock.expect_add().return_const(3);
        mock.expect_beep().return_const(());

        mock.add(1, 2);
        mock.beep();
    }
}

Conclusion

I think test code living in the same file as the code it’s testing is not great, because it can make it harder to navigate code files. Most likely Rust chose that standard because it makes tests more visible.

You can find a runnable version of the examples in this article in my examples repo in github.

[ programming  rust  testing  ]
Unit Testing Code for ESP32
Asynchronous Programming with Tokio
Programming Concurrency in Rust
Smart Pointers in Rust
Rust References Lifetimes