Briefly about unit tests in Rust

Hello!

Unit tests allow you to prevent errors and significantly simplify the processes of refactoring and code support. Their implementation exists in all programming languages ​​and Rust is no exception.

Unit tests in Rust are usually located in the same file as the code under test, in a special module called testsannotated #[cfg(test)]. Inside this module are testing functions, each of which is also annotated as #[test].

Example of a simple unit test in Rust:

#[cfg(test)]
mod tests {
    #[test]
    fn it_works() {
        assert_eq!(2 + 2, 4);
    }
}

tests contains a function it_works, which verifies that the addition operation is performed correctly. If the condition assert_eq!(2 + 2, 4) is not executed, the test is considered failed.

Or a slightly more complicated example – a function for dividing two numbers, which should return an error when trying to divide by zero:

fn safe_divide(dividend: i32, divisor: i32) -> Result<i32, String> {
    if divisor == 0 {
        Err("Division by zero".to_string())
    } else {
        Ok(dividend / divisor)
    }
}

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

    #[test]
    fn test_divide_by_zero() {
        let result = safe_divide(10, 0);
        assert_eq!(result, Err("Division by zero".to_string()));
    }

    #[test]
    fn test_normal_division() {
        let result = safe_divide(10, 2);
        assert_eq!(result, Ok(5));
    }
}

In Rust no restrictions on testing private functions, allowing you to choose whether to test them directly or not. Private function test example:

fn internal_adder(a: i32, b: i32) -> i32 {
    a + b
}

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

    #[test]
    fn internal() {
        assert_eq!(internal_adder(2, 2), 4);
    }
}

In this case the private function is tested internal_adderwhat is possible with use super::*allowing tests to see the contents of the parent module.

Assertions

Macro assert! used to check that a Boolean expression is true. If the expression evaluates to falsehappens panic, and the test is considered unsuccessful. This macro can also accept a custom message to output in case of an error:

assert!(1 + 1 == 2);
assert!(some_boolean_function(), "Expected true but got false");

Macro assert_eq! checks the equality of two expressions using the trait PartialEq. If the values ​​are not equal, the test panics and prints the values ​​of both expressions, which helps you quickly understand the reason for the discrepancy. You can add your own message to display additional data:

let expected = 2;
let result = 1 + 1;
assert_eq!(result, expected, "Testing addition: {} + 1 should be {}", 1, expected);

Macro assert_ne! used to check that two expressions are not equal. Like assert_eq!if there is a mismatch, the values ​​are printed to make debugging easier, and a custom message can be added:

let a = 3;
let b = 4;
assert_ne!(a, b, "Values should not be equal: {} and {}", a, b);

Mock objects and dependencies

Using attribute #[automock], mockall can automatically create a mock for any trait. This simplifies testing since manual definition of mock structures is not required:

#[automock]
trait MyTrait {
    fn foo(&self, x: u32) -> u32;
}

In tests, you can customize the behavior of mocks using methods expect_ to define expected calls and return values:

#[test]
fn mytest() {
    let mut mock = MockMyTrait::new();
    mock.expect_foo()
        .with(eq(4))
        .times(1)
        .returning(|x| x + 1);
    assert_eq!(5, mock.foo(4));
}

If automatic creation of mocks is not suitable (for example, if a more complex configuration is required), you can use a macro mock!:

mock! {
    pub MyStruct<T: Clone + 'static> {
        fn bar(&self) -> u8;
    }
    impl<T: Clone + 'static> MyTrait for MyStruct<T> {
        fn foo(&self, x: u32);
    }
}

After creating mocks, you can customize their behavior in tests by setting expected calls, arguments and return values, so you can test different scenarios:

let mut mock = MockMyTrait::new();
mock.expect_foo()
    .return_const(44u32);
mock.expect_bar()
    .with(predicate::ge(1))
    .returning(|x| x + 1);

You can consider this example:

use mockall::{automock, predicate::*};

struct Database {
              ....
}

#[automock]
impl Database {
    fn get_user(&self, user_id: i32) -> Option<String> {
        // определенные операции
        Some("User".to_string())
    }
}

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

    #[test]
    fn test_get_user() {
        let mut mock_db = MockDatabase::new();
        mock_db.expect_get_user()
               .with(eq(42))
               .times(1)
               .returning(|_| Some("User".to_string()));

        let result = mock_db.get_user(42);
        assert_eq!(result, Some("User".to_string()));
    }

More about mockall can be read at the link.

Snapshot testing

When tests are first run using insta, the tests will likely fail because there are no baseline snapshots to compare against. In this case insta creates new pictures with extension .snap.new. You can review these snapshots and, if they match the expected results, confirm them as base snapshots for future tests.

Using the command cargo insta review You can interactively view changes between old and new snapshots and choose whether to accept or reject the new results.

insta Automatically detects when tests are running in CI and modifies snapshot update behavior to prevent snapshots from being accidentally modified without explicit confirmation.

Macros for taking pictures:

assert_snapshot! for basic line snapshots.

assert_debug_snapshot! for pictures that use the output format Debug for objects.

assert_yaml_snapshot!, assert_json_snapshot! and others that support serialization of data into various formats (the corresponding functions must be enabled).

You can use environment variables or configuration files to customize the behavior instafor example, updating snapshots only when explicitly specified or automatically accepting changes if they are not in CI.

Example:

#[test]
fn test_vector() {
    let data = vec![1, 2, 3];
    insta::assert_debug_snapshot!(data);
}

After the first test run insta will create a snapshot file that you can review and confirm if the output is as expected. As you make subsequent changes to the feature, you can observe what changes have occurred and either accept them or correct them.

Parameterized tests

To implement parameterized tests, a crate is often used rstest. This crate provides macros for defining tests with multiple sets of parameters.

Let's say there is a function add, which simply returns the sum of two numbers. Let's write a parameterized test for it that will test the function on different sets of input data:

use rstest::rstest;

fn add(a: i32, b: i32) -> i32 {
    a + b
}

#[rstest]
#[case(1, 2, 3)]
#[case(5, -2, 3)]
#[case(0, 0, 0)]
fn test_add(#[case] a: i32, #[case] b: i32, #[case] expected: i32) {
    assert_eq!(add(a, b), expected);
}

Macro #[rstest] used to create a parameterized test. annotation #[case(...)] defines separate sets of parameters, each of which will result in the generation of a separate test.

Now let's create a parameterized test for a function that performs division while checking for division by zero:

use rstest::rstest;
use std::num::NonZeroU32;

fn safe_divide(numerator: u32, denominator: NonZeroU32) -> u32 {
    numerator / denominator.get()
}

#[rstest]
#[case(10, NonZeroU32::new(2).unwrap(), 5)]
#[case(20, NonZeroU32::new(5).unwrap(), 4)]
#[case(12, NonZeroU32::new(3).unwrap(), 4)]
fn test_safe_divide(#[case] numerator: u32, #[case] denominator: NonZeroU32, #[case] expected: u32) {
    assert_eq!(safe_divide(numerator, denominator), expected);
}

Type used NonZeroU32, which ensures that the divisor cannot be zero, thereby preventing the possibility of a division by zero error at runtime. Every #[case] defines the various inputs on which the function must be tested.


In conclusion, I would like to recommend you free webinar about Borrow checker – this is one of the main features of the language. Teachers will talk about it at the webinar and live code it with you.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *