The Rust programming language has built-in support for writing automated tests.
Rust tests are basically Rust functions that verify whether the other non-test functions written in the package work as intended. They basically invoke the other functions with the specified data and assert that the return values are as expected.
Rust has two types of tests – unit tests and integration tests.
Writing unit tests in Rust
Create a new Rust package with the following command:
cargo new test-example && cd test-example
Write a new function that returns the process ID of the currently running process. We will look at the details of process handling in a later chapter, so you may just type in the following code, as the focus here is on writing unit tests:
use std::process;
fn main() {
println!("{}", get_process_id());
}
fn get_process_id() -> u32 {
process::id()
}
We have written a simple (silly) function to use the standard library process module and retrieve the process ID of the currently running process.
Run the code using cargo check
to confirm there are no syntax errors.
Let's now write a unit test. Note that we cannot know upfront what the process ID is going to be, so all we can test is whether a number is being returned:
#[test]
fn test_if_process_id_is_returned() {
assert!(get_process_id() > 0);
}
Run cargo test
. You will see that the test has passed successfully, as the function returns a non-zero positive integer.
Note that we have written the unit tests in the same source file as the rest of the code. In order to tell the compiler that this is a test function, we use the #[test]
annotation. The assert!
macro (available in standard Rust library) is used to check whether a condition evaluates to true. There are two other macros available, assert_eq!
and assert_ne!
, which are used to test whether the two arguments passed to these macros are equal or not.
A custom error message can also be specified:
#[test]
fn test_if_process_id_is_returned() {
assert_ne!(get_process_id(), 0, "There is error in code");
}
To compile but not run the tests, use the --no-run
option with the cargo test
command.
The preceding example has only one simple test
function, but as the number of tests increases, the following problems arise:
- How do we write any helper functions needed for test code and differentiate it from the rest of the package code?
- How can we prevent the compiler from compiling tests as part of each build (to save time) and not include test code as part of the normal build (saving disk/memory space)?
In order to provide more modularity and to address the preceding questions, it is idiomatic in Rust to group test functions in a test
module:
#[cfg(test)]
mod tests {
use super::get_process_id;
#[test]
fn test_if_process_id_is_returned() {
assert_ne!(get_process_id(), 0, "There is
error in code");
}
}
Here are the changes made to the code:
- We have moved the
test
function under the tests
module.
- We have added the
cfg
attribute, which tells the compiler to compile test code only if we are trying to run tests (that is, only for cargo test
, not for cargo build
).
- There is a
use
statement, which brings the get_process_id
function into the scope of the tests
module. Note that tests
is an inner module and so we use super:: prefix
to bring the function that is being tested into the scope of the tests
module.
cargo test
will now give the same results. But what we have achieved is greater modularity, and we've also allowed for the conditional compilation of test code.
Writing integration tests in Rust
In the Writing unit tests in Rust section, we saw how to define a tests
module to hold the unit tests. This is used to test fine-grained pieces of code such as an individual function call. Unit tests are small and have a narrow focus.
For testing broader test scenarios involving a larger scope of code such as a workflow, integration tests are needed. It is important to write both types of tests to fully ensure that the library works as expected.
To write integration tests, the convention in Rust is to create a tests
directory in the package root and create one or more files under this folder, each containing one integration test. Each file under the tests
directory is treated as an individual crate.
But there is a catch. Integration tests in Rust are not available for binary crates, only library crates. So, let's create a new library crate:
cargo new --lib integ-test-example && cd integ-test-example
In src/lib.rs
, replace the existing code with the following. This is the same code we wrote earlier, but this time it is in lib.rs
:
use std::process;
pub fn get_process_id() -> u32 {
process::id()
}
Let's create a tests
folder and create a file, tests/integration_test1.rs
. Add the following code in this file:
use integ_test_example;
#[test]
fn test1() {
assert_ne!(integ_test_example::get_process_id(), 0, "Error
in code");
}
Note the following changes to the test code compared to unit tests:
- Integration tests are external to the library, so we have to bring the library into the scope of the integration test. This is simulating how an external user of our library would call a function from the public interface of our library. This is in place of
super:: prefix
used in unit tests to bring the tested function into scope.
- We did not have to specify the
#[cfg(test)]
annotation with integration tests, because these are stored in a separate folder and cargo compiles files in this directory only when we run cargo test
.
- We still have to specify the
#[test]
attribute for each test
function to tell the compiler these are the test functions (and not helper/utility code) to be executed.
Run cargo test
. You will see that this integration test has been run successfully.
Controlling test execution
The cargo test
command compiles the source code in test mode and runs the resultant binary. cargo test
can be run in various modes by specifying command-line options. The following is a summary of the key options.
Running a subset of tests by name
If there are a large number of tests in a package, cargo test
runs all tests by default each time. To run any particular test cases by name, the following option can be used:
cargo test —- testfunction1, testfunction2
To verify this, let's replace the code in the integration_test1.rs
file with the following:
use integ_test_example;
#[test]
fn files_test1() {
assert_ne!(integ_test_example::get_process_id(),0,"Error
in code");
}
#[test]
fn files_test2() {
assert_eq!(1+1, 2);
}
#[test]
fn process_test1() {
assert!(true);
}
This last dummy test
function is for purposes of the demonstration of running selective cases.
Run cargo test
and you can see both tests executed.
Run cargo test files_test1
and you can see files_test1
executed.
Run cargo test files_test2
and you can see files_test2
executed.
Run cargo test files
and you will see both files_test1
and files_test2
tests executed, but process_test1
is not executed. This is because cargo looks for all test cases containing the term 'files'
and executes them.
Ignoring some tests
In some cases, you want to execute most of the tests every time but exclude a few. This can be achieved by annotating the test
function with the #[ignore]
attribute.
In the previous example, let's say we want to exclude process_test1
from regular execution because it is computationally intensive and takes a lot of time to execute. The following snippet shows how it's done:
#[test]
#[ignore]
fn process_test1() {
assert!(true);
}
Run cargo test
, and you will see that process_test1
is marked as ignored, and hence not executed.
To run only the ignored tests in a separate iteration, use the following option:
cargo test —- --ignored
The first --
is a separator between the command-line options for the cargo
command and those for the test
binary. In this case, we are passing the --ignored
flag for the test binary, hence the need for this seemingly confusing syntax.
Running tests sequentially or in parallel
By default, cargo test
runs the various tests in parallel in separate threads. To support this mode of execution, the test
functions must be written in a way that there is no common data sharing across test cases. However if there is indeed such a need (for example, one test case writes some data to a location and another test case reads it), then we can run the tests in sequence as follows:
cargo test -- --test-threads=1
This command tells cargo to use only one thread for executing tests, which indirectly means that tests have to be executed in sequence.
In summary, Rust's strong built-in type system and strict ownership rules enforced by the compiler, coupled with the ability to script and execute unit and integration test cases as an integral part of the language and tooling, makes it very appealing to write robust, reliable systems.