Search icon CANCEL
Subscription
0
Cart icon
Your Cart (0 item)
Close icon
You have no products in your basket yet
Save more on your purchases now! discount-offer-chevron-icon
Savings automatically calculated. No voucher code required.
Arrow left icon
Explore Products
Best Sellers
New Releases
Books
Videos
Audiobooks
Learning Hub
Conferences
Free Learning
Arrow right icon
Arrow up icon
GO TO TOP
Practical System Programming for Rust Developers

You're reading from   Practical System Programming for Rust Developers Build fast and secure software for Linux/Unix systems with the help of practical examples

Arrow left icon
Product type Paperback
Published in Dec 2020
Publisher Packt
ISBN-13 9781800560963
Length 388 pages
Edition 1st Edition
Languages
Tools
Arrow right icon
Author (1):
Arrow left icon
Prabhu Eshwarla Prabhu Eshwarla
Author Profile Icon Prabhu Eshwarla
Prabhu Eshwarla
Arrow right icon
View More author details
Toc

Table of Contents (17) Chapters Close

Preface 1. Section 1: Getting Started with System Programming in Rust
2. Chapter 1: Tools of the Trade – Rust Toolchains and Project Structures FREE CHAPTER 3. Chapter 2: A Tour of the Rust Programming Language 4. Chapter 3: Introduction to the Rust Standard Library 5. Chapter 4: Managing Environment, Command Line, and Time 6. Section 2: Managing and Controlling System Resources in Rust
7. Chapter 5: Memory Management in Rust 8. Chapter 6: Working with Files and Directories in Rust 9. Chapter 7: Implementing Terminal I/O in Rust 10. Chapter 8: Working with Processes and Signals 11. Chapter 9: Managing Concurrency 12. Section 3: Advanced Topics
13. Chapter 10: Working with Device I/O 14. Chapter 11: Learning Network Programming 15. Chapter 12: Writing Unsafe Rust and FFI 16. Other Books You May Enjoy

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.

You have been reading a chapter from
Practical System Programming for Rust Developers
Published in: Dec 2020
Publisher: Packt
ISBN-13: 9781800560963
Register for a free Packt account to unlock a world of extra content!
A free Packt account unlocks extra newsletters, articles, discounted offers, and much more. Start advancing your knowledge today.
Unlock this book and the full library FREE for 7 days
Get unlimited access to 7000+ expert-authored eBooks and videos courses covering every tech area you can think of
Renews at $19.99/month. Cancel anytime