rust
Published: 2025-12-23

Rust: A Test Driven Guide

A tour of Rust for the experienced programmer driven by tests. We’re all writing tests, right?! I wrote this to help me learn the language as efficiently as possible. It’s not exhaustive (but it is long!). It should allow me to start writing some code. It’ll also serve as a reference.

One thing to note about the style. I’ve gone for 2 spaces for indentation due to many years as a Ruby programmer. This combined with an 80 character line length allows more splits for files in Neovim.

Contents

Linting with clippy

clippy is a linting tool that checks for common mistakes and provides suggestions. Some Cargo (introduced next) settings I use for building this blog.

// Cargo.toml
[lints.clippy]
pedantic = { level = "warn", priority = -1 }
missing_panics_doc = "allow"
must_use_candidate = "allow"

You can also apply the above clippy workspace-wide

// Cargo.toml (workspace):
[workspace.lints.clippy]
all = "warn"
pedantic = "warn"

// member/Cargo.toml:
[lints]
workspace = true

It’s too strict for this guide so we’ll leave it off. However, pedantic mode is generally recommended as it allows you to catch potential issues earlier.

Clippy docs

Cargo

For projects, you’ll want to use cargo which is a build tool and package manager.

src/main.rs is the entry point of the default binary. target/debug/ is where build artifacts end up in debug mode. target/release/ is where build artifacts end up in release mode.

cargo new proj_name     # Create a new Rust project in proj_name/
cargo build             # Builds a project in debug mode
cargo build --release   # Builds a project in release mode
cargo run               # Runs the default binary
cargo check             # Checks for errors without building. Fast.

More info: Cargo docs

rust-script

For basic stuff that doesn’t need any crates (libraries) you can run rustc script.rs and it’ll compile a ./script binary. But if you want to add add a few crates..

rust-script compiles and runs one-off scripts from any folder. You can also add a crate description to the script to allow additional dependencies to be included.

#!/usr/bin/env rust-script
//! ```cargo
//! [dependencies]
//! time = "0.1.25"
//! ```
fn main() {
  println!("The time is {}", time::now().rfc822z());
}


rust-script’s wrap the script in a main() function unless you specify one. Our tests need to run at the module level so we’ll need to add a main().

fn main() {}

Macros

Macros are a way to define reusable code. They’re similar to functions but they’re expanded by the compiler. This one creates a nice refute! macro that’s the opposite of assert!. I’m a Ruby developer and brought this over from the Minitest syntax. I think it’s easier to read than assert!(!cond). I won’t go into macro syntax here (perhaps a future post).

macro_rules! refute {
  ($cond:expr $(,)?) => { assert!(!$cond) };
  ($cond:expr, $($arg:tt)+) => { assert!(!$cond, $($arg)+) };
}

Attributes

The #[test] attribute marks a function as a test. It’ll get picked up by the test runner and run when you run cargo test (or cargo t).

#[test]
fn hello_world() {           // This is a function
  println!("Hello, world!"); // Run with cargo t -- --nocapture to see output
  assert_eq!(1, 1);          // Asserts that the left and right values are equal

  // Ordering of assertion values is not important
  let left = "value";  // When values don't match it'll output the "left" and
  let right = "value"; // "right" values so it's easy to identify which is which.
  assert_eq!(left, right);
}

The should_panic attribute tells the test runner to expect a panic. If it doesn’t panic then the test will fail. Watch when you run cargo t -- --nocapture. You’ll see it panics but all the tests pass. A panic is like an exception in other languages. However Rust uses the Result type to indicate success or failure (See Error Handling).

#[test]
#[should_panic(expected = "assertion `left == right` failed\n  left: 1\n right: 2")]
fn failing_test() {
  assert_eq!(1, 2);
}

Variables

Variables are immutable by default. Use mut to make them mutable.

#[test]
fn variables() {
  let x = 2;                    // An immutable variable
  let mut y = 5;                // A mutable variable
  y += x;
  assert_eq!(y, 7);

  const A_CONST: f32 = 1.2;     // Constants cannot be marked as `mut`able, must
                                // have a type, can be declared in any scope and
                                // may only be set to a constant expression.
  let z = 1.2;
  assert_eq!(A_CONST, z);

}

Variables may be shadowed, as in, the same name can be used in the same or nested scope and it’ll override the one previously defined, allowing variable name reuse.

#[test]
fn variable_shadowing() {
  let a = 1;
  assert_eq!(a, 1);
  let a = 2;                    // Second declaration shadows the first
  assert_eq!(a, 2);             // Useful for reusing names.
}

Basic Types

Docs

#[test]
fn basic_types() {
  // isize and usize are architecture-dependent and used for collection indexing.
  let int8: i8 = -1;       // Signed integers are i8, i16, i32, i64, i128, isize
  let unsigned8: u8 = 255; // Unsigned integers are u8, u16, u32, u64, u128, usize
  let float32 = 1.0;       // Floats f32 by default
  let float64: f64 = 1.0;
  let default32 = 1;       // Default type is i32
  let ch = 'a';            // char type, supports ASCII and Unicode
  let imoji = '😻';        // A unicode char type
  let t = true;            // Boolean type
  let f: bool = false;     // Boolean type with explicit type
  let unit = ();           // The unit type another one pulled from functional languages

  assert_eq!(int8, -1);
  assert_eq!(unsigned8, 255);
  assert!((float64 - float32).abs() < f64::EPSILON); // Roughly equal
  assert_eq!(default32, 1);
  assert_eq!(ch, 'a');
  assert_eq!(imoji, '😻');
  assert!(t);
  refute!(f);              // The refute! macro from above
  assert_eq!(unit, ());

  let tuple = (1, "hello", 3);      // Tuples can contain any type
  let (one, hello, three) = tuple;  // They can be destructured like so

  assert_eq!(one, 1);
  assert_eq!(hello, "hello");
  assert_eq!(three, 3);
  assert_eq!("hello", tuple.1);   // Or accessed by index

  assert_eq!(0xff, 255);          // Hexadecimal literals are prefixed with 0x
  assert_eq!(0o77, 63);           // Octal literals are prefixed with 0o
  assert_eq!(0b1111_1111, 255);   // Binary literals are prefixed with 0b
  assert_eq!(b'A', 65);           // Byte literals are prefixed with b
  assert_eq!(1_000_000, 1000000); // Underscores can be used for clarity

  // Note: Overflow checks are not done in release builds.
}

Operators

See Rust operators for the full list.

#[test]
fn operators() {
  assert_eq!(1 + 1, 2);           // Addition
  assert_eq!(3 - 1, 2);           // Subtraction
  assert_eq!(2 * 3, 6);           // Multiplication
  assert_eq!(6 / 2, 3);           // Division
  assert_eq!(6 % 2, 0);           // Remainder/Modulo
}

Strings

#[test]
fn strings() {
  let str: &str = "Hello this is a str type"; // fixed size, immutable, borrowed reference
  let string: String = str.to_string().replace("str", "String"); // Heap allocated, mutable, growable, owned

  assert_eq!(str, "Hello this is a str type");
  assert_eq!(string, "Hello this is a String type");

  assert_eq!(&str[..5], "Hello");   // Slices are a way to get a reference to a part of a string
  assert_eq!(&string[..5], "Hello");
}

Arrays

#[test]
fn arrays() {
  let a = [1, 2, 3];              // Arrays are fixed-size, same-type values
  assert_eq!(a[1], 2);

  let b: [i32; 3] = [1, 2, 3];    // Array type can be specified
  assert_eq!(b[1], 2);

  let c = [3; 5];                 // Array with a fixed size can be initialized
  assert_eq!(c, [3, 3, 3, 3, 3]);

  // Array slices can be created with from_index..to_index same as string slices
  assert_eq!(c[2..4], [3, 3]);
}

Functions

This is a function, just like our main() function at the start. Functions must specify their parameter and return types (if any).

Notice the lack of semicolons at the end of the string literals. Rust has implicit returns. This is indicating it’ll return a &str type. If we had a semicolon it would return the unit type, ().

fn conditional_msg(value: u32) -> &'static str {
  if value < 5 {
    "less than 5"
  } else {
    "greater than 5"
  }
}

Structs

Structs are a way to group data together. They can be used to create custom types. You can also define methods on them. Methods are functions that are associated with a struct.

struct Rect {
  width: u32,
  height: u32,
}

Associated Functions

impl allows us to add associated functions to a struct. new and square are constructors. They return an instance of Rect. area is a method because it’s first parameter is self. Self is an alias for the struct type, in this case Rect.

impl Rect {
  fn new(width: u32, height: u32) -> Self {
    Self { width, height } // Shorthand for Self { width: width, height: height }
  }

  fn square(size: u32) -> Self {
    Self { width: size, height: size }
  }

  fn area(&self) -> u32 {
    self.width * self.height
  }
}

#[test]
fn structs() {
  let rect = Rect::new(10, 20);

  assert_eq!(rect.width, 10);
  assert_eq!(rect.height, 20);
  assert_eq!(rect.area(), 200);

  let square = Rect::square(10);
  assert_eq!(square.width, 10);
  assert_eq!(square.height, 10);
  assert_eq!(square.area(), 100);
}

Enums

Enums are like Unions types in functional languages. Great for pattern matching. And you can add data to them. You can’t compare enums directly, instead, use matches!.

enum Message {
  Quit,
  Move { x: i32, y: i32 },
  Write(String),
  ChangeColor(i32, i32, i32),
}

fn which_enum(msg: Message) -> String {
  match msg {
    Message::Quit => "Quit".to_string(),
    Message::Move { x, y } => format!("Move {} {}", x, y),
    Message::Write(text) => format!("Write {}", text),
    Message::ChangeColor(r, g, b) => format!("ChangeColor {} {} {}", r, g, b),
    // _ => "Some other value", // Use this if you don't want to handle all values
  }
}

#[test]
fn enums() {
  assert_eq!(which_enum(Message::Write(String::from("Hello"))), "Write Hello");
  assert_eq!(which_enum(Message::Move { x: 1, y: 2 }), "Move 1 2");
  assert_eq!(which_enum(Message::ChangeColor(1, 2, 3)), "ChangeColor 1 2 3");
  assert_eq!(which_enum(Message::Quit), "Quit");

  let msg = Message::Write(String::from("Hello"));
  assert!(matches!(msg, Message::Write(_)));
}

Type Aliases

Type aliases are a way to give a type a new name. Here, the first one gives a name to a tuple. The second, assigns a more convenient name to an enum.

type MyPoint = (i32, i32);
enum VeryLongEnumNameForDoingStuffWithNumbers { Add, Subtract }
type Operations = VeryLongEnumNameForDoingStuffWithNumbers;

#[test]
fn type_aliases() {
  let p: MyPoint = (1, 2);
  assert_eq!(p, (1, 2));

  assert!(matches!(Operations::Add, VeryLongEnumNameForDoingStuffWithNumbers::Add));
}

Control Flow

#[test]
fn control_flow() {
  // if expressions
  assert_eq!("less than 5", conditional_msg(4));    // function from above
  assert_eq!("greater than 5", conditional_msg(7));

  let mut x = if true { 1 } else { 2 }; // If expressions
  assert_eq!(x, 1);

  // loop
  let result = loop {               // Loop forever!
    if x < 2 { x += 1; continue; }  // Skip to the next iteration
    break x;                        // Let's break out of the loop
  };
  assert_eq!(result, 2);

  // while
  let mut number = 5;
  while number != 0 {
    number -= 1;
  }
  assert_eq!(number, 0);

  // match
  let value = 4;
  let result =
    match value {
      1 => "One!",
      2 | 3 => "Two or three!",
      a if a < 10 => "Single digit",
      _ => "Some other value",
    };
  assert_eq!(result, "Single digit");
}

Error Handling

For simple programs you can use panic! to stop execution and print a message. Setting RUST_BACKTRACE=1 env var will print a backtrace.

#[test]
#[should_panic(expected = "Crash and burn")]
fn panics() {
  panic!("Crash and burn");
}

As opposed to exceptions or return codes in some languages, errors are handled with the Result type. This is a type that can either be Ok(value) or Err(error).

fn divide(a: f64, b: f64) -> Result<f64, String> {
  if b == 0.0 {
    Err("Div by zero".to_string())
  } else {
    Ok(a / b)
  }
}

Result types can be used in match expressions to handle errors.

fn handle_divide(a: f64, b: f64) -> String {
  match divide(a, b) {
    Err(msg) => msg,
    Ok(answer) => format!("The answer is {}.", answer),
  }
}

#[test]
fn error_handling() {
  assert_eq!(handle_divide(10.0, 0.0), "Div by zero");
  assert_eq!(handle_divide(10.0, 2.0), "The answer is 5.");
}

Traits

Traits are a way to define shared behavior for types. They’re similar to interfaces in other languages.

trait Describe {
  fn describe(&self) -> String;
}

trait Area {
  fn area(&self) -> f64;
}

struct Circle {
  radius: f64,
}

impl Describe for Circle {
  fn describe(&self) -> String {
    format!("A circle with radius {}", self.radius)
  }
}

impl Area for Circle {
  fn area(&self) -> f64 {
    std::f64::consts::PI * self.radius * self.radius
  }
}

struct Square {
  side: f64,
}

impl Describe for Square {
  fn describe(&self) -> String {
    format!("A square with side {}", self.side)
  }
}

impl Area for Square {
  fn area(&self) -> f64 {
    self.side * self.side
  }
}

#[test]
fn traits() {
  let circle = Circle { radius: 2.0 };
  assert_eq!(circle.describe(), "A circle with radius 2");
  assert!((circle.area() - 12.566370614359172).abs() < f64::EPSILON);

  let square = Square { side: 3.0 };
  assert_eq!(square.describe(), "A square with side 3");
  assert!((square.area() - 9.0).abs() < f64::EPSILON);
}

Traits can also have default implementations. Types can override them or use the default.

trait Greet {
  fn greet(&self) -> String {
    "Hello!".to_string()  // Default implementation
  }
}

struct Person {
  name: String,
}

impl Greet for Person {
  fn greet(&self) -> String {
    format!("Hello, I'm {}!", self.name)  // Override the default
  }
}

struct Robot;

impl Greet for Robot {
  // Uses default implementation - no override needed
}

#[test]
fn trait_defaults() {
  let person = Person { name: "Alice".to_string() };
  assert_eq!(person.greet(), "Hello, I'm Alice!");

  let robot = Robot;
  assert_eq!(robot.greet(), "Hello!");
}

Borrowing

Borrowing is a way to share data without copying it. It’s a central concept in Rust. It’s useful for performance and memory management. The compiler will enforce that you don’t have multiple mutable references to the same data. It also means you don’t have to worry about freeing memory.

Add traits Copy & Clone and as long as the types are all Copy the whole struct can be copied. Debug and PartialEq traits are so we can use assert_eq! to compare structs.

#[derive(Copy, Clone)]
#[derive(Debug, PartialEq)]
struct Point {
  x: i32,
  y: i32,
}

#[test]
fn borrowing() {
  let p1 = Point { x: 1, y: 2 };
  let p2 = p1; // p1 is copied to p2 and both are still valid


  assert_eq!(p1, Point {x: 1, y: 2});
  assert_eq!(p2, Point {x: 1, y: 2});

  let s = String::from("Hello");
  let r1 = &s;  // Immutable reference to s
  let r2 = &s;  // Second immutable reference to s OK
  //let r3 = &mut s; // Cannot borrow as mutable because there are immutable references

  assert_eq!(r1, "Hello");
  assert_eq!(r2, "Hello");

  let mut s2 = String::from("There"); // Needs to be mutable to borrow as mutable
  let r3 = &mut s2;                   // No other references to so mutable borrow is OK
  assert_eq!(r3, "There");

  *r3 = String::from("World");
  assert_eq!(r3, "World");
}

Lifetimes

Lifetimes are a way to specify how long a reference is valid. This is useful for functions that return references to data. It also means that you can’t accidentally return a reference to data that will go out of scope.

#[allow(clippy::needless_lifetimes)]
fn print_refs<'a, 'b>(x: &'a i32, y: &'b i32) { // These lifetimes can be inferred
  println!("x is {}, y is {}", x, y);           // but shown to explain how they work.
}

#[allow(clippy::extra_unused_lifetimes)]
fn failed_borrow<'a>() {
  let _x = 12;
  // let _y: &'a i32 = &_x; // This would fail because x's lifetime will end at the
  // end of the function, but 'a lifetime is determined by the caller of the
  // function which will be longer than x's.
}

#[test]
fn lifetimes() {
  let (i, j) = (4, 9);
  print_refs(&i, &j);
  failed_borrow();
}

Closures

Closures are anonymous functions that can capture outer variables. This is useful for example, to pass different implementations to a iterators.

fn do_something_with(list: &[i32], closure: impl Fn(i32) -> i32) -> Vec<i32>{
  list.iter().map(|&x| closure(x)).collect()
}


#[test]
fn basic_closure() {
  let outer_var = 42;
  let closure = |i| outer_var + i; // This can be passed elsewhere and subsequently called

  assert_eq!(do_something_with(&[1, 2, 3], closure), &[43, 44, 45]);
}

#[test]
fn closures() {
  let mut haystack = vec![1, 2];
  haystack.push(3);
  let contains = move |needle| haystack.contains(needle);

  // haystack is now owned by the closure and cannot be used outside of it

  assert!(contains(&1));
  refute!(contains(&4));
}

Iterators

Iterators are lazy, meaning they don’t do anything until you consume them. Common methods include map, filter, collect, fold, enumerate, and more.

#[test]
fn iterators() {
  let nums = vec![1, 2, 3, 4, 5];

  // map: transform each element
  let doubled: Vec<i32> = nums.iter().map(|x| x * 2).collect();
  assert_eq!(doubled, vec![2, 4, 6, 8, 10]);

  // filter: keep only elements that match a condition
  let evens: Vec<i32> = nums.iter().filter(|&&x| x % 2 == 0).copied().collect();
  assert_eq!(evens, vec![2, 4]);

  // Chaining: combine multiple operations
  let result: Vec<i32> = nums.iter()
    .filter(|&&x| x > 2)
    .map(|x| x * 10)
    .collect();
  assert_eq!(result, vec![30, 40, 50]);

  // fold: reduce to a single value
  let sum: i32 = nums.iter().fold(0, |acc, x| acc + x);
  assert_eq!(sum, 15);

  // enumerate: get index with each element
  let indexed: Vec<(usize, i32)> = nums.iter().copied().enumerate().collect();
  assert_eq!(indexed, vec![(0, 1), (1, 2), (2, 3), (3, 4), (4, 5)]);

  // for loops use iterators under the hood
  let mut total = 0;
  for n in nums.iter() {
    total += n;
  }
  assert_eq!(total, 15);
}

More iterator methods: take, skip, zip, any, all, find.

#[test]
fn more_iterators() {
  let nums = vec![1, 2, 3, 4, 5];

  // take: get first n elements
  let first_three: Vec<i32> = nums.iter().take(3).copied().collect();
  assert_eq!(first_three, vec![1, 2, 3]);

  // skip: skip first n elements
  let skip_two: Vec<i32> = nums.iter().skip(2).copied().collect();
  assert_eq!(skip_two, vec![3, 4, 5]);

  // zip: combine two iterators
  let letters = vec!['a', 'b', 'c'];
  let paired: Vec<(i32, char)> = nums.iter().copied().zip(letters.iter().copied()).collect();
  assert_eq!(paired, vec![(1, 'a'), (2, 'b'), (3, 'c')]);

  // any: check if any element matches
  assert!(nums.iter().any(|&x| x > 4));
  refute!(nums.iter().any(|&x| x > 10));

  // all: check if all elements match
  assert!(nums.iter().all(|&x| x > 0));
  refute!(nums.iter().all(|&x| x > 2));

  // find: get first element matching condition
  assert_eq!(nums.iter().find(|&&x| x > 3), Some(&4));
  assert_eq!(nums.iter().find(|&&x| x > 10), None);
}

Standard Library

Reading directories and files

The standard library provides std::fs for filesystem operations.

use std::fs;
use std::io::Write;

#[test]
fn reading_files() {
  // Create a temporary file for testing
  let test_file = "/tmp/rust_guide_test.txt";
  fs::write(test_file, "Hello, Rust!").expect("Failed to write file");

  // Read entire file to string
  let contents = fs::read_to_string(test_file).expect("Failed to read file");
  assert_eq!(contents, "Hello, Rust!");

  // Read as bytes
  let bytes = fs::read(test_file).expect("Failed to read file");
  assert_eq!(bytes, b"Hello, Rust!");

  // Clean up
  fs::remove_file(test_file).expect("Failed to remove file");
}

#[test]
fn working_with_directories() {
  let test_dir = "/tmp/rust_guide_test_dir";

  // Create a directory
  fs::create_dir_all(test_dir).expect("Failed to create directory");

  // Create some files in the directory
  fs::write(format!("{}/file1.txt", test_dir), "Content 1").expect("Failed to write");
  fs::write(format!("{}/file2.txt", test_dir), "Content 2").expect("Failed to write");

  // Read directory entries
  let entries: Vec<String> = fs::read_dir(test_dir)
    .expect("Failed to read directory")
    .filter_map(|entry| entry.ok())
    .map(|entry| entry.file_name().to_string_lossy().to_string())
    .collect();

  assert_eq!(entries.len(), 2);
  assert!(entries.contains(&"file1.txt".to_string()));
  assert!(entries.contains(&"file2.txt".to_string()));

  // Clean up
  fs::remove_dir_all(test_dir).expect("Failed to remove directory");
}

Path manipulation

Use std::path::Path and std::path::PathBuf for working with file paths.

Jump to top