The TDD Mindset: Thinking in Tests with Rust

Introduction and Overview

In this lesson, we'll deepen our understanding of the Test-Driven Development (TDD) mindset by focusing on the Red-Green-Refactor cycle with a practical example centered on a calculate_total function. This example will guide you through the process of thinking in tests, prioritizing test writing, and leveraging TDD to enhance code clarity, reliability, and maintainability.

Using Rust and its built-in testing framework, we will follow these steps:

  • Begin with the Red phase by identifying and writing failing tests for the calculate_total function, which will compute the total price of items in a shopping cart.
  • Move to the Green phase to implement the minimal code required to pass each test, ensuring that the calculate_total function behaves as expected.
  • Enter the Refactor phase to improve the code structure and readability of calculate_total, employing idiomatic Rust solutions while keeping tests green.
  • Utilize Rust's built-in testing tools to efficiently integrate the TDD approach within our Rust project.

By working through this example, you'll gain practical experience with TDD principles and develop the calculate_total function in a way that showcases how TDD fosters code quality and robustness.

Example: calculate_total Function (Red Phase)

Let's begin by writing tests for a function named calculate_total, designed to compute the total price of items in a shopping cart. This is where you engage with the Red phase: write a failing test.

Let's think about how to build a function that calculates the total price of items in a shopping cart. What should the interface be? How does the consumer of the code use it? These are the questions we think about first when we "think in tests." Here's one way we might think about it in Rust.

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

    #[test]
    fn calculate_total_returns_zero_for_empty_vector() {
        let result = calculate_total(vec![]);
        assert_eq!(result, 0.0);
    }
}

Explanation:

  • We know we want a function called calculate_total(), so we'll write a test that uses it.
  • For now, we know that we want an empty vector as input to return 0, so we can write that test.
  • At this point, the code won't even compile because calculate_total doesn't exist yet. This compilation failure is part of the Red phase in TDD.

Running this will result in compilation errors, confirming we're in the Red phase and creating a clear path for subsequent development.

Example: Passing the Tests (Green Phase)

Now, let's move to the Green phase where we implement the minimal code to pass these tests.

Implement the calculate_total function in your module:

pub struct CartItem {
}

pub fn calculate_total(items: Vec<CartItem>) -> f64 {
    0.0
}

Explanation:

  • The calculate_total function takes a vector of CartItem objects. We don't really know the shape of the data yet, and that's okay!
  • Returning 0.0 is enough to get the test to pass.

By running the test suite again, we should see all tests passing, demonstrating that our function meets the required condition.

Note that we're skipping the Refactor phase here because our implementation is already as simple as possible—there's nothing meaningful to improve when we're just returning a hardcoded value. The refactor phase becomes valuable once we have more complex logic to optimize.

Example: Write Another Test (Red)

Now is the time to think about what kind of data we want to pass to our calculate_total function. We consider that we'd like to pass the name, price, and quantity as fields in the CartItem struct. The total will be the product of price * quantity. Let's do that and see how it feels:

pub struct CartItem {
    pub name: String,
    pub price: f64,
    pub quantity: u32,
}
#[cfg(test)]
mod tests {
    use project::*;

    #[test]
    fn calculate_total_returns_total_for_single_item() {
        let cart_item = CartItem {
            name: String::from("Apple"),
            price: 0.5,
            quantity: 3,
        };
        
        let result = calculate_total(vec![cart_item]);
        
        assert_eq!(result, 1.5);
    }
}

That feels pretty good; the interface seems clear. Let's see if we can get those tests passing.

Example: Make It Pass Again (Green)

Now we need to think about how to make these tests pass. What is the minimum necessary to get this to pass?

pub struct CartItem {
    pub name: String,
    pub price: f64,
    pub quantity: u32,
}

pub fn calculate_total(items: Vec<CartItem>) -> f64 {
    if !items.is_empty() {
        let item = &items[0];
        return item.price * item.quantity as f64;
    }
    
    0.0
}

When we run the tests, we should see that both tests pass! We're Green!

Example: Write a Failing Test (Red)

The existing code works! But it won't be very useful to only use the first item. If we add one more test, we can generalize more.

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

    #[test]
    fn calculate_total_should_calculate_total_for_multiple_items() {
        let items = vec![
            CartItem { name: String::from("Apple"), price: 0.5, quantity: 3 },
            CartItem { name: String::from("Banana"), price: 0.3, quantity: 2 },
        ];
        
        let total = calculate_total(items);
        
        assert_eq!(total, 2.1);
    }
}

As expected, this new test will fail, and we can move to the Green step.

Example: Make the Test Pass (Green)

Let's take a stab at getting the test to pass:

pub fn calculate_total(items: Vec<CartItem>) -> f64 {
    let mut total = 0.0;
    
    for item in items {
        total += item.price * item.quantity as f64;
    }
    
    total
}

This does the job, and we're Green again. I can't help but feel like we could have written that code a bit better. Now that we have tests that cover everything we want this function to do, let's move to the Refactor step!

Example: Refactor!

When we look at the calculate_total function, it looks pretty good. You don't always need to do anything in the "refactor" phase, and this is one of those times where we'll leave it.

This Rust idiomatic solution maintains clarity and ensures we're still Green when we run our tests.

Summary and Preparation for Practice

Throughout this lesson, we focused on refining the TDD mindset by emphasizing writing tests prior to coding and following the Red-Green-Refactor cycle. Here's what we covered:

  • Red Phase: We started by writing failing tests, like determining that calculate_total should return 0 for an empty cart and a specific total for a single item. This helped us clearly define our interface and objectives.
  • Green Phase: We implemented minimal solutions to pass each test condition, for instance, returning a simple calculation for a single CartItem or a total for an entire list of items.
  • Refactor Phase: We improved the code structure and readability, ensuring that the function remains expressive and maintainable while tests continue passing.

These steps in the TDD workflow have shown how test-first development can clarify requirements, ensure accuracy, and guide continuous improvement. This foundation prepares you for practice exercises aimed at reinforcing these techniques, highlighting how TDD fosters clarity, reliability, and robustness in software development with Rust.

Sign up

Join the 1M+ learners on CodeSignal

Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal