In previous lessons, we explored the fundamentals of Test-Driven Development (TDD), the Red-Green-Refactor cycle, and the setup of a testing environment. 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'll explore how to use abstraction in Ruby to manage dependencies effectively. Using simple examples, we will demonstrate how to apply the Red-Green-Refactor cycle in this context. Let's dive in.
Dependencies in software development refer to the components or systems a piece of code relies on to function properly. In the context of testing, dependencies can complicate unit tests because they might introduce external factors that affect the test outcomes. To ensure tests are isolated and independent, we use abstractions.
In Ruby, abstractions can be achieved through dynamic typing, modules, and mixins. By programming with these abstractions, you 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 with a module or a class implementing an interface, you can 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.
We'll create a simple logger abstraction using Ruby to demonstrate dependency management. This abstraction will define a method log
, which our UserManager
will use:
Ruby1module Logger 2 def log(message) 3 raise NotImplementedError, 'Log method must be implemented' 4 end 5end
The Logger
module acts as an interface, requiring any class including it to implement the log
method. This simplicity highlights the ease of creating stubs or mocks for logging during tests without invoking an actual logging mechanism.
Next, we build a UserManager
class by using the Logger
abstraction. We utilize dependency injection to pass in a logger, illustrating how to maintain independence between the UserManager
and any specific logging implementation.
Ruby1class UserManager 2 def initialize(logger) 3 @logger = logger 4 @users = [] 5 end 6 7 def add_user(username) 8 @logger.log("User #{username} added") 9 @users << username 10 end 11 12 def get_users 13 @users 14 end 15end
In UserManager
, the logger is injected through the constructor, which allows you to provide different implementations of Logger
— such as a fake for testing or a real logger for production.
In testing, we use several different methods to simulate dependencies without relying on complex or unavailable implementations. We'll create a FakeLogger
class to test the UserManager
.
Here's how we do it with RSpec:
Ruby1class FakeLogger 2 include Logger 3 4 attr_reader :logs 5 6 def initialize 7 @logs = [] 8 end 9 10 def log(message) 11 @logs << message 12 end 13end 14 15RSpec.describe UserManager do 16 it 'logs user addition' do 17 fake_logger = FakeLogger.new 18 user_manager = UserManager.new(fake_logger) 19 user_manager.add_user('john') 20 expect(fake_logger.logs).to eq(['User john added']) 21 end 22end
- Red: Start by writing a test for
UserManager
that checks if a user is added correctly and the logging occurs. - Green: Implement
UserManager
to pass these tests, ensuring that both the user addition and logging functionalities work as expected. - Refactor: Always look for opportunities to improve code readability and maintainability.
In this lesson, we covered how to manage dependencies in unit testing using abstractions and dependency injection in Ruby. We explored using fake objects to isolate components during tests. Here's a recap of the key points:
- Abstract dependencies using modules and dynamic typing 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 Ruby and effective dependency management.