Learn Rust with Tests

⚠️ WIP: This project is an ongoing work in progress. Links may be broken and content may be missing. If you find something wrong, please open an issue and we'll get them fixed asap.

Why

  • Explore the Rust language by writing tests
  • Get a grounding with TDD. Rust is a great language for learning TDD because it is a simple language to learn and testing is built-in
  • Be confident that you'll be able to start writing robust, well-tested systems in Rust
  • Watch a video, or read about why unit testing and TDD is important

Table of contents

Rust fundamentals

  1. Install Rust - Set up environment for productivity.
  2. Hello, world - Declaring variables, constants, if/else statements, loops, functions, write your first Rust program and write your first test. Sub-test syntax and closures.
  3. Primitives - Further Explore function declaration syntax and learn new ways to improve the documentation of your code.
  4. Control Flow - Declaring if/else, loops, match, to control the flow of your program.
  5. Vectors - Learn about one of the most used data structures.
  6. Ownership and Borrow Checker - Learn about ownership, borrowing, and lifetimes.
  7. Structs - Learn about the three struct types: a classic C struct, a tuple struct, and a unit struct..
  8. Enums - Learn about how to define and use enums.
  9. Strings - Learn about the two string types, a string slice (&str) and an owned string (String).
  10. Modules - Learn how to use the module system.
  11. [Mocking] - Learn how to use the Mockall library to mocking.
  12. Hashmaps - Learn how to define and use hashmaps.
  13. Options - Learn how every Option is either Some and contains a value, or None, and does not.
  14. Error Handling - Learn how to properly handle errors.
  15. Generics - Learn how generics generalize types and functionalities to broader cases, reducing code duplication.
  16. Traits - Learn how traits define shared behaviors.
  17. Lifetimes - Learn how lifetimes tell the compiler how to check whether references live long enough to be valid in any given situation
  18. Iterators - Learn about the different ways to iterate.
  19. Smart Pointers - Learn about how smart pointers are variables that contain an address in memory and reference some other data.
  20. Threads - Learn about how programs can have independent parts that run simultaneously.
  21. Macros - Learn how to use and create macros.
  22. Conversions - Learn about the many ways to convert a value of a given type into another type.

Build an application

Now that you have hopefully digested the Rust Fundamentals section you have a solid grounding of a majority of Rust's language features and how to do TDD.

This next section will involve building an application.

Each chapter will iterate on the previous one, expanding the application's functionality as our product owner dictates.

New concepts will be introduced to help facilitate writing great code but most of the new material will be learning what can be accomplished from Rust's standard library.

By the end of this, you should have a strong grasp as to how to iteratively write an application in Rust, backed by tests.

  • HTTP server - We will create an application which listens to HTTP requests and responds to them.
  • JSON, routing and embedding - We will make our endpoints return JSON and explore how to do routing.
  • IO and sorting - We will persist and read our data from disk and we'll cover sorting data.
  • Command line & project structure - Support multiple applications from one code base and read input from command line.
  • Time - using the time package to schedule activities.
  • WebSockets - learn how to write and test a server that uses WebSockets.

Testing fundamentals

Covering other subjects around testing.

Questions and answers

I often run in to questions on the internets like

How do I test my amazing function that does x, y and z

If you have such a question raise it as an issue on github and I'll try and find time to write a short chapter to tackle the issue. I feel like content like this is valuable as it is tackling people's real questions around testing.

  • OS exec - An example of how we can reach out to the OS to execute commands to fetch data and keep our business logic testable/
  • Error types - Example of creating your own error types to improve your tests and make your code easier to work with.
  • Revisiting HTTP Handlers - Testing HTTP handlers seems to be the bane of many a developer's existence. This chapter explores the issues around designing handlers correctly.

Meta / Discussion

Contributing

  • This project is work in progress If you would like to contribute, please do get in touch.
  • Read contributing.md for guidelines
  • Any ideas? Create an issue

Who this is for

  • People who are interested in picking up Rust.
  • People who already know some Rust, but want to explore testing with TDD.

What you'll need

  • A computer!
  • Installed Rust
  • A text editor
  • Some experience with programming. Understanding of concepts like if, variables, functions etc.
  • Comfortable using the terminal

Support me

I am proud to offer this resource for free, but if you wish to give some appreciation:

Feedback

MIT license

Install Rust

The official instructions to install Rust can be found here.

Installation

Rust is installed through the rustup command line tool, which allows us to manage Rust versions and associated tools. It installs the Rust toolchain, rustccompiler, cargo tool, and much more.

Install on Linux

If you're using linux, you can open a terminal and enter the following command:

$ curl --proto '=https' --tlsv1.2 https://sh.rustup.rs -sSf | sh

This command downloads the rustup install script and runs it, which will then install Rust. You might be prompted to enter your password. If the install was successful, you should see the following message:

Rust is installed now. Great!

To verify the script ran successfully, we can type the following command to get the rustup version:

$ rustup --version

Install on Mac

If you're using a mac, you first need to install a C compiler. We'll do that using xcode:

$ xcode-select --install

Similar to linux, we will open a terminal and enter the command for rustup:

$ curl --proto '=https' --tlsv1.2 https://sh.rustup.rs -sSf | sh

We can verify with:

rustup --version

Install on Windows

Installing on windows will be slightly different.

You may need to install the Visual C++ Build Tools 2019 or equivalent (Visual Studio 2019, etc.) if theyre not already installed.

You'll need to download the rustup.exe and run it.

You can verify rustup installed by entering the following command into powershell:

rustup --version

Hello World

Start new project

To start a new project, we use the cargo new command. This will generate everything we need to get our program running.

cargo new hello_world

The structure of the new directory looks like this:

hello_world
├── Cargo.toml
└── src
    └── main.rs

With:

  • hello_world as our project name.
  • Cargo.toml is the file that manages our project.
  • src is the directory all our code goes in.
  • main.rs is the file that holds our code.

If we open main.rs, we will see the traditional "Hello, World!" code.

fn main() {
  println!("Hello, World!");
}

Every program requires a main() function to run, since this is where every program we write begins at.

To run our code, we can type cargo run in our terminal.

This will:

  • Build our code into a binary file.
  • Run our code after the build is finished.

Once cargo run finishes running, we should see "Hello, World!" in our terminal.

Hello, World!

Every cargo new project starts with "Hello, World!" using the println! macro.

fn main() {
    println!("Hello, world!");

    let hello = hello_world();
    println!("{}", hello);

    let name = String::from("Rusty");
    greeting(name);
}

fn hello_world() -> String {
    let greeting = String::from("Hello, World!");
    greeting
}

fn greeting(name: String) -> String {
    let hello = String::from("Hello, ");
    let greeting = format!("{hello}{name}!");
    greeting
}

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

    #[test]
    fn hello_world_test() {
        let want = String::from("Hello, World!");
        let result = hello_world();
        assert_eq!(want, result);
    }

    #[test]
    fn greeting_test() {
        let want = String::from("Hello, Rusty!");
        let name = String::from("Rusty");
        let result = greeting(name);
        assert_eq!(want, result);
    }
}

However, when we write tests, we want to separate our "domain" code from the outside world (side effects). The outside world is dealing with file input/output, printing to the terminal, interfacing with databases, so on and so forth.

To be testable, we need to turn our "Hello, World!" into a function and print the result of the function to the screen separately.

First, we write the function.

fn main() {
    println!("Hello, world!");

    let hello = hello_world();
    println!("{}", hello);

    let name = String::from("Rusty");
    greeting(name);
}

fn hello_world() -> String {
    let greeting = String::from("Hello, World!");
    greeting
}

fn greeting(name: String) -> String {
    let hello = String::from("Hello, ");
    let greeting = format!("{hello}{name}!");
    greeting
}

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

    #[test]
    fn hello_world_test() {
        let want = String::from("Hello, World!");
        let result = hello_world();
        assert_eq!(want, result);
    }

    #[test]
    fn greeting_test() {
        let want = String::from("Hello, Rusty!");
        let name = String::from("Rusty");
        let result = greeting(name);
        assert_eq!(want, result);
    }
}
  • The fn keyword is how we define our function.
  • hello_world is the name of our function.
  • () is where would would define our arguments if we have any.
  • -> String is the return type we expect.
  • let greeting = is an immutable variable assignment.
  • String::from("Hello, World!") creates a "Hello, World!" in the String type.
  • ; semi-colons denote the end of a line.
  • greeting returns the value. We could also write return greeting and it would be the same thing.

In order to run hello_world(), we have to call it from the main().

fn main() {
  let hello = hello_world();
  println!("{}", hello);
}

Running cargo run again builds and runs our code.

This prints "Hello, World!" to the screen the same way println!("Hello, World!") does, but is now ready to be tested.

Writing our first test

Now that we have separated the domain code from any side effects, we write our first test.

fn main() {
    println!("Hello, world!");

    let hello = hello_world();
    println!("{}", hello);

    let name = String::from("Rusty");
    greeting(name);
}

fn hello_world() -> String {
    let greeting = String::from("Hello, World!");
    greeting
}

fn greeting(name: String) -> String {
    let hello = String::from("Hello, ");
    let greeting = format!("{hello}{name}!");
    greeting
}

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

    #[test]
    fn hello_world_test() {
        let want = String::from("Hello, World!");
        let result = hello_world();
        assert_eq!(want, result);
    }

    #[test]
    fn greeting_test() {
        let want = String::from("Hello, Rusty!");
        let name = String::from("Rusty");
        let result = greeting(name);
        assert_eq!(want, result);
    }
}
  • #[cfg(test)] is a macro that says "this block of code will be our tests". This is how cargo knows which code to run when you use cargo test.
  • mod tests is the name of the block.
  • use super::* pulls all the code above the test block into scope.
  • [#test] is a macro we put on each function we want to count as a test.
  • fn hello_world_test() is our first test. Note: no return value is given, since tests aren't supposed to return anything; they're supposed to test code.
  • let want = String::from("Hello, World!") is our desired outcome for the test
  • let result = hello_world() is the result we get back from the function we're testing.
  • assert_eq!(want, result) asserts the want and result are the same value.

We can now run our tests with cargo test.

We should see the following output:

Compiling hello_world v0.1.0 (/Users/glitch/projects/learn_rust_with_tests/examples/01_
hello_world/hello_world)
    Finished test [unoptimized + debuginfo] target(s) in 0.65s
     Running unittests src/main.rs (target/debug/deps/hello_world-9e795269315caeb3)

running 1 tests
test tests::greeting_test ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished i
n 0.00s

Taking in a name

The purpose of tests is to define a desired outcome and write it in code. By first defining what outcome we wish to achieve, we can iterate on our code to match the test.

Our new requirement is to take in a name and print that out with "Hello". For example, the outcome should be "Hello, Rusty!"

Since this is Test-Driven Development, we write our test first.

fn main() {
    println!("Hello, world!");

    let hello = hello_world();
    println!("{}", hello);

    let name = String::from("Rusty");
    greeting(name);
}

fn hello_world() -> String {
    let greeting = String::from("Hello, World!");
    greeting
}

fn greeting(name: String) -> String {
    let hello = String::from("Hello, ");
    let greeting = format!("{hello}{name}!");
    greeting
}

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

    #[test]
    fn hello_world_test() {
        let want = String::from("Hello, World!");
        let result = hello_world();
        assert_eq!(want, result);
    }

    #[test]
    fn greeting_test() {
        let want = String::from("Hello, Rusty!");
        let name = String::from("Rusty");
        let result = greeting(name);
        assert_eq!(want, result);
    }
}
  • fn greeting_test() is defined.
  • let want = String::from("Hello, Rusty!"); is the outcome we want.
  • let name = String::from("Rusty!") is the name we pass an an argument to greeting().
  • let result = greeting(name) calls greeting with our name variable and saves the return value to result.
  • assert_eq!(want, result) asserts want and result are the same.

If we run cargo test now, we will get an error:

$ cargo test
   Compiling hello_world v0.1.0 (/Users/rt/projects/learn_rust_with_tests/examples/01_
hello_world/hello_world)
error[E0425]: cannot find function `greeting` in this scope
  --> src/main.rs:46:22
   |
46 |         let result = greeting(name);
   |                      ^^^^^^^^ not found in this scope

For more information about this error, try `rustc --explain E0425`.
error: could not compile `hello_world` (bin "hello_world" test) due to 1 previous erro
r

That's because greeting() doesnt exist yet.

We can create a placeholder using the unimplemented!() macro. When cargo test is ran, it wont fail:

#![allow(unused)]
fn main() {
fn greeting() {
  unimplemented!()
}
}

Now we need to write the code to fulfill our test.

fn main() {
    println!("Hello, world!");

    let hello = hello_world();
    println!("{}", hello);

    let name = String::from("Rusty");
    greeting(name);
}

fn hello_world() -> String {
    let greeting = String::from("Hello, World!");
    greeting
}

fn greeting(name: String) -> String {
    let hello = String::from("Hello, ");
    let greeting = format!("{hello}{name}!");
    greeting
}

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

    #[test]
    fn hello_world_test() {
        let want = String::from("Hello, World!");
        let result = hello_world();
        assert_eq!(want, result);
    }

    #[test]
    fn greeting_test() {
        let want = String::from("Hello, Rusty!");
        let name = String::from("Rusty");
        let result = greeting(name);
        assert_eq!(want, result);
    }
}
  • fn greeting(name: String) -> String creates function greeting() that takes a String called name as an argument. The return type is String.
  • let hello = String::from("Hello,"); creates the String "Hello,".
  • let greeting = format!("{hello} {name}!") formats the value of hello and our name argument into a single String using the format!() macro.

We've written our function to fulfill the requirements we set in our test, so now we try running cargo test.

Running cargo test will give us "passed" on both tests.

$ cargo test
    Finished test [unoptimized + debuginfo] target(s) in 0.05s
     Running unittests src/main.rs (target/debug/deps/hello_world-9e795269315caeb3)

running 2 tests
test tests::greeting_test ... ok
test tests::hello_world_test ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Starting in the next chapter, we will start writing our tests first, fulfilling them, and refactoring as necessary.