Utilizing Stubs for Test-Driven Development

Introduction and Context Setting

Welcome to the second lesson in our exploration of Test-Driven Development (TDD) using Rust. In the previous lesson, we delved into the use of dummies to isolate dependencies in tests. This lesson will shift our focus to another vital kind of test double — Stubs.

By the end of this lesson, you will understand what stubs are and how to implement them in your tests using Rust. This technique is particularly useful for isolating dependencies like external services, making your tests more reliable and manageable.

Understanding Stubs in Testing

In testing, test doubles help us isolate parts of our application. We've already covered dummies. Stubs, which are a more active type of test double, allow us to provide predefined responses to method calls during testing. Unlike other test doubles, stubs don’t track interactions but are ideal for certain scenarios.

Stubs are especially useful when testing functions that depend on external services or complex dependencies. They allow us to simulate function outputs, making tests faster and more predictable. Stubs focus on ensuring your application's logic operates as expected without checking the correctness of external dependencies.

Example: Crafting a WeatherAlertService Using Stubs

To demonstrate stubs, we will create a WeatherAlertService using stubs in a TDD process with Rust.

We'll develop a WeatherAlertService that fetches data from a WeatherService. This service will issue alerts based on specific conditions. Utilizing the actual data source is impractical for testing, so we’ll use stubbed data instead.

Red: Writing the First Test

Create a new test module in your Rust project and set up the following test:

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

    struct WeatherServiceStub {
        temperature: i32,
        conditions: String,
    }

    impl WeatherServiceStub {
        fn set_weather(&mut self, temperature: i32, conditions: &str) {
            self.temperature = temperature;
            self.conditions = conditions.to_string();
        }

        fn get_current_weather(&self, _location: &str) -> WeatherData {
            WeatherData {
                temperature: self.temperature,
                conditions: self.conditions.clone(),
            }
        }
    }

    struct WeatherData {
        temperature: i32,
        conditions: String,
    }

    struct WeatherAlertService<'a> {
        weather_service: &'a dyn WeatherService,
    }

    impl<'a> WeatherAlertService<'a> {
        fn should_send_alert(&self, location: &str) -> String {
            let weather = self.weather_service.get_current_weather(location);
            if weather.temperature > 35 {
                "Extreme heat warning. Stay hydrated!".to_string()
            } else {
                "No alert".to_string()
            }
        }
    }

    trait WeatherService {
        fn get_current_weather(&self, location: &str) -> WeatherData;
    }

    #[test]
    fn should_return_heat_warning_when_temperature_is_above_35() {
        // Arrange
        let mut weather_service = WeatherServiceStub {
            temperature: 0,
            conditions: String::new(),
        };
        weather_service.set_weather(36, "sunny");
        let alert_service = WeatherAlertService {
            weather_service: &weather_service,
        };

        // Act
        let alert = alert_service.should_send_alert("London");

        // Assert
        assert_eq!(alert, "Extreme heat warning. Stay hydrated!");
    }
}

In this test:

  • We create a manual stub, WeatherServiceStub, that simulates weather data by setting predefined values for temperature and conditions via the set_weather method.
  • The stub's get_current_weather method returns a WeatherData struct with these predefined values, simulating expected weather conditions for the test scenario.
  • Using Rust's test framework, we assert whether the WeatherAlertService correctly interprets the weather data and returns the appropriate alert, specifically checking for a heat warning when the temperature exceeds 35 degrees.

Run this test, expecting it to initially fail, as WeatherAlertService does not yet appropriately implement the logic for handling these conditions.

Green: Making the Test Pass

Implement the WeatherAlertService struct with minimal logic. Here's an example of how you might structure this code:

struct WeatherAlertService<'a> {
    weather_service: &'a dyn WeatherService,
}

impl<'a> WeatherAlertService<'a> {
    fn new(weather_service: &'a dyn WeatherService) -> Self {
        Self { weather_service }
    }

    fn should_send_alert(&self, location: &str) -> String {
        let weather = self.weather_service.get_current_weather(location);
        if weather.temperature > 35 {
            "Extreme heat warning. Stay hydrated!".to_string()
        } else {
            "No alert".to_string()
        }
    }
}

Modify your project to include the structural logic necessary to pass your test. The objective is to pass this specific test scenario by implementing only the essential logic, avoiding any extraneous complexity.

Refactor: Introducing a Stub Using Rust's Mocking Capabilities

In the initial implementation, we created a manual stub, WeatherServiceStub, to simulate weather data. Now, we will explore using a Rust library to streamline the process and improve maintainability. The mockall crate, for example, allows us to create flexible and reusable stubs with minimal code.

mockall enables us to define method expectations and return values dynamically. By substituting our handcrafted stub with a mockall-based one, we achieve similar outcomes with enhanced clarity and simplicity. This reduces potential for errors and simplifies testing management.

The refactored test would then include usage of Rust's mockall crate:

use mockall::{mock, predicate::*};

mock! {
    pub WeatherService {}
    impl WeatherService for WeatherService {
        fn get_current_weather(&self, location: &str) -> WeatherData;
    }
}

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

    #[test]
    fn should_return_heat_warning_when_temperature_is_above_35() {
        // Arrange
        let mut mock_service = MockWeatherService::new();
        mock_service
            .expect_get_current_weather()
            .with(eq("London"))
            .returning(|_| WeatherData {
                temperature: 36,
                conditions: "sunny".to_string(),
            });

        let alert_service = WeatherAlertService::new(&mock_service);

        // Act
        let alert = alert_service.should_send_alert("London");

        // Assert
        assert_eq!(alert, "Extreme heat warning. Stay hydrated!");
    }
}

This approach focuses the tests more on behavior rather than setup intricacies and aligns well with the idiomatic Rust testing ecosystem.

Summary and Preparation for Practice

Throughout this lesson, we've explored the concept of stubs and how they can be a practical means to isolate dependencies in tests using Rust. Key takeaways include:

  • Stubs allow us to replace external dependencies by providing predefined return values, easing the testing of features that rely on services beyond our immediate control.
  • Through the Red-Green-Refactor cycle, we demonstrated the importance of initially writing tests that fail and subsequently implementing just the necessary logic to pass those tests.
  • We examined using the mockall crate to simplify and enhance test setup, making it easier to manage and adjust tests.

Be prepared to apply these techniques in forthcoming exercises, where you’ll explore different scenarios using stubs and enhance your skills in utilizing test doubles. This will further your ability to create dependable, thoroughly-tested Rust code.

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