Book Image

Practical System Programming for Rust Developers

By : Prabhu Eshwarla
Book Image

Practical System Programming for Rust Developers

By: Prabhu Eshwarla

Overview of this book

Modern programming languages such as Python, JavaScript, and Java have become increasingly accepted for application-level programming, but for systems programming, C and C++ are predominantly used due to the need for low-level control of system resources. Rust promises the best of both worlds: the type safety of Java, and the speed and expressiveness of C++, while also including memory safety without a garbage collector. This book is a comprehensive introduction if you’re new to Rust and systems programming and are looking to build reliable and efficient systems software without C or C++. The book takes a unique approach by starting each topic with Linux kernel concepts and APIs relevant to that topic. You’ll also explore how system resources can be controlled from Rust. As you progress, you’ll delve into advanced topics. You’ll cover network programming, focusing on aspects such as working with low-level network primitives and protocols in Rust, before going on to learn how to use and compile Rust with WebAssembly. Later chapters will take you through practical code examples and projects to help you build on your knowledge. By the end of this Rust programming book, you will be equipped with practical skills to write systems software tools, libraries, and utilities in Rust.
Table of Contents (17 chapters)
1
Section 1: Getting Started with System Programming in Rust
6
Section 2: Managing and Controlling System Resources in Rust
12
Section 3: Advanced Topics

Writing and running automated tests

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.