Managing Dependencies in TDD with Rust

Introduction to Managing Dependencies in TDD

In previous lessons, we've explored the fundamentals of Test Driven Development (TDD) — the Red-Green-Refactor cycle — and how to set up a testing environment using Rust and Cargo. Now, we shift our focus to a key aspect of TDD: managing dependencies. Managing dependencies ensures that each unit of your application can be tested in isolation, which is crucial in TDD for maintaining code reliability and robustness.

In this lesson, we will examine how to use traits for abstraction in Rust, allowing us to effectively manage dependencies. Through simple examples, we will demonstrate applying the Red-Green-Refactor cycle in this context. Let's dive in.

Understanding Dependencies and Traits

Dependencies in software development refer to the components or systems on which a piece of code relies to function properly. In the context of testing, dependencies can complicate unit tests because they might introduce external factors that affect test outcomes. To ensure tests are isolated and independent, we use abstractions.

A trait in Rust acts as a way to define shared behavior. By programming against traits, developers can easily swap out implementations, making code more modular and test-friendly.

For example, consider a logger that a component uses to record actions. By abstracting the logger using a trait, you decouple the component from a specific logging implementation. This abstraction allows you to replace the actual logger with a mock or fake when testing, thus focusing on testing the component, not its dependencies.

Implementing Traits in Rust

We'll create a simple logger trait called Logger to demonstrate dependency management. This trait will define a method log, which our UserManager will use:

pub trait Logger {
    fn log(&mut self, message: &str);
}

The Logger trait defines a single method log that accepts a message of type &str. This simplicity highlights the ease of creating test stubs or mocks to simulate logging during tests without invoking an actual logging mechanism.

Understanding Dependency Injection

Dependency injection is a design pattern where dependencies are provided to a component from the outside rather than being created internally. Instead of a class instantiating its own dependencies, they are "injected" through constructor parameters, method parameters, or properties. This approach promotes loose coupling between components and makes testing easier because you can inject mock or fake implementations during tests while using real implementations in production. Dependency injection is fundamental to writing testable code in TDD, as it allows you to isolate the unit being tested from its external dependencies.

Building the UserManager with Dependency Injection

Next, we build a UserManager struct by using the Logger trait. We utilize dependency injection by passing a logger as a parameter, illustrating how to maintain independence between the UserManager and any specific logging implementation.

pub struct UserManager<T: Logger> {
    pub logger: T,
    pub users: Vec<String>,
}

impl<T: Logger> UserManager<T> {
    pub fn new(logger: T) -> Self {
        UserManager {
            logger,
            users: vec![],
        }
    }

    pub fn add_user(&mut self, username: &str) {
        self.users.push(username.to_string());
        self.logger.log(&format!("User {} added", username));
    }

    pub fn get_users(&self) -> Vec<&str> {
        self.users.iter().map(AsRef::as_ref).collect()
    }
}

In UserManager, the logger is injected through the constructor function new. This allows different implementations of Logger — such as a fake for testing or a real logger for production — to be provided.

Testing with a Fake Logger

In testing, we use various methods to simulate dependencies without relying on complex or unavailable implementations. We'll create a FakeLogger struct to test the UserManager.

Here's how we do it using Rust's cargo test framework:

pub struct FakeLogger {
    logs: Vec<String>,
}

impl FakeLogger {
    pub fn new() -> Self {
        FakeLogger { logs: vec![] }
    }

    pub fn get_logs(&self) -> &Vec<String> {
        &self.logs
    }
}

impl Logger for FakeLogger {
    fn log(&mut self, message: &str) {
        self.logs.push(message.to_string());
    }
}
#[cfg(test)]
mod tests {
    use project::*;

    #[test]
    fn test_add_user_adds_valid_user_with_logging() {
        let logger = FakeLogger::new();
        let mut user_manager = UserManager::new(logger);

        user_manager.add_user("john");

        let users = user_manager.get_users();
        assert!(users.contains(&"john"));
        
        // Check if log message was added
        let logs = user_manager.logger.get_logs();
        assert!(logs.contains(&"User john added".to_string()));
    }
}

TDD Workflow in Action

  • Red: We start by writing tests for UserManager. The first test checks if a user is added correctly, while the second test verifies that logging occurs.
  • Green: Implement UserManager to pass these tests, ensuring that both the user addition and logging functionalities work as expected.
  • Refactor: The current implementation is effective, although you should always look for opportunities to improve code readability and maintainability.

Summary and Preparation for Hands-on Practice

In this lesson, we covered how to manage dependencies in unit testing using traits and dependency injection in Rust. We explored the use of fake objects to isolate components during tests. Here's a recap of the key points:

  • Abstract dependencies using traits to facilitate test isolation.
  • Implement dependency injection to pass dependencies like loggers.
  • Use a fake logger to simulate dependencies in unit tests.
  • Always apply the TDD cycle: Red - Write a failing test, Green - Implement minimal code to pass, Refactor - Optimize the code without changing its functionality.

In the following hands-on practice sessions, you will consolidate these concepts by applying them using TDD. Continue practicing to deepen your understanding and proficiency in TDD with Rust and effective dependency management.

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