Testing

Test organization

.NET solutions use separate projects to host test code, irrespective of the test framework being used (xUnit, NUnit, MSTest, etc.) and the type of tests (unit or integration) being wirtten. The test code therefore lives in a separate assembly than the application or library code being tested. In Rust, it is a lot more conventional for unit tests to be found in a separate test sub-module (conventionally) named tests, but which is placed in same source file as the application or library module code that is the subject of the tests. This has two benefits:

  • The code/module and its unit tests live side-by-side.

  • There is no need for a workaround like [InternalsVisibleTo] that exists in .NET because the tests have access to internals by virtual of being a sub-module.

The test sub-module is annotated with the #[cfg(test)] attribute, which has the effect that the entire module is (conditionally) compiled and run only when the cargo test command is issued.

Within the test sub-modules, test functions are annotated with the #[test] attribute.

Integration tests are usually in a directory called tests that sits adjacent to the src directory with the unit tests and source. cargo test compiles each file in that directory as a separate crate and run all the methods annotated with #[test] attribute. Since it is understood that integration tests in the tests directory, there is no need to mark the modules in there with the #[cfg(test)] attribute.

See also:

Running tests

As simple as it can be, the equivalent of dotnet test in Rust is cargo test.

The default behavior of cargo test is to run all the tests in parallel, but this can be configured to run consecutively using only a single thread:

cargo test -- --test-threads=1

For more information, see "Running Tests in Parallel or Consecutively".

Output in Tests

For very complex integration or end-to-end test, .NET developers sometimes log what's happening during a test. The actual way they do this varies with each test framework. For example, in NUnit, this is as simple as using Console.WriteLine, but in XUnit, one uses ITestOutputHelper. In Rust, it's similar to NUnit; that is, one simply writes to the standard output using println!. The output captured during the running of the tests is not shown by default unless cargo test is run the with --show-output option:

cargo test --show-output

For more information, see "Showing Function Output".

Assertions

.NET users have multiple ways to assert, depending on the framework being used. For example, an assertion xUnit.net might look like:

[Fact]
public void Something_Is_The_Right_Length()
{
    var value = "something";
    Assert.Equal(9, value.Length);
}

Rust does not require a separate framework or crate. The standard library comes with built-in macros that are good enough for most assertions in tests:

Below is an example of assert_eq in action:

#[test]
fn something_is_the_right_length() {
    let value = "something";
    assert_eq!(9, value.len());
}

The standard library does not offer anything in the direction of data-driven tests, such as [Theory] in xUnit.net.

Mocking

When writing tests for a .NET application or library, there exist several frameworks, like Moq and NSubstitute, to mock out the dependencies of types. There are similar crates for Rust too, like mockall, that can help with mocking. However, it is also possible to use conditional compilation by making use of the cfg attribute as a simple means to mocking without needing to rely on external crates or frameworks. The cfg attribute conditionally includes the code it annotates based on a configuration symbol, such as test for testing. This is not very different to using DEBUG to conditionally compile code specifically for debug builds. One downside of this approach is that you can only have one implementation for all tests of the module.

When specified, the #[cfg(test)] attribute tells Rust to compile and run the code only when executing the cargo test command, which behind-the-scenes executes the compiler with rustc --test. The opposite is true for the #[cfg(not(test))] attribute; it includes the annotated only when testing with cargo test.

The example below shows mocking of a stand-alone function var_os from the standard that reads and returns the value of an environment variable. It conditionally imports a mocked version of the var_os function used by get_env. When built with cargo build or run with cargo run, the compiled binary will make use of std::env::var_os, but cargo test will instead import tests::var_os_mock as var_os, thus causing get_env to use the mocked version during testing:

// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license.

/// Utility function to read an environmentvariable and return its value If
/// defined. It fails/panics if the valus is not valid Unicode.
pub fn get_env(key: &str) -> Option<String> {
    #[cfg(not(test))]                 // for regular builds...
    use std::env::var_os;             // ...import from the standard library
    #[cfg(test)]                      // for test builds...
    use tests::var_os_mock as var_os; // ...import mock from test sub-module

    let val = var_os(key);
    val.map(|s| s.to_str()     // get string slice
                 .unwrap()     // panic if not valid Unicode
                 .to_owned())  // convert to "String"
}

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

    pub(crate) fn var_os_mock(key: &str) -> Option<OsString> {
        match key {
            "FOO" => Some("BAR".into()),
            _ => None
        }
    }

    #[test]
    fn get_env_when_var_undefined_returns_none() {
        assert_eq!(None, get_env("???"));
    }

    #[test]
    fn get_env_when_var_defined_returns_some_value() {
        assert_eq!(Some("BAR".to_owned()), get_env("FOO"));
    }
}

Code coverage

There is sophisticated tooling for .NET when it comes to analyzing test code coverage. In Visual Studio, the tooling is built-in and integrated. In Visual Studio Code, plug-ins exist. .NET developers might be familiar with coverlet as well.

Rust is providing built-in code coverage implementations for collecting test code coverage.

There are also plug-ins available for Rust to help with code coverage analysis. It's not seamlessly integrated, but with some manual steps, developers can analyze their code in a visual way.

The combination of Coverage Gutters plug-in for Visual Studio Code and Tarpaulin allows visual analysis of the code coverage in Visual Studio Code. Coverage Gutters requires an LCOV file. Other tools besides Tarpaulin can be used to generate that file.

Once setup, run the following command:

cargo tarpaulin --ignore-tests --out Lcov

This generates an LCOV Code Coverage file. Once Coverage Gutters: Watch is enabled, it will be picked up by the Coverage Gutters plug-in, which will show in-line visual indicators about the line coverage in the source code editor.

Note: The location of the LCOV file is essential. If a workspace (see Project Structure) with multiple packages is present and a LCOV file is generated in the root using --workspace, that is the file that is being used - even if there is a file present directly in the root of the package. It is quicker to isolate to the particular package under test rather than generating the LCOV file in the root.