In our last post, we explored how Dependency Injection (DI) is a powerful design pattern that can improve our ExUnit tests.
In this article, we will dive deeper into the topic of DI in Elixir, focusing on the Rewire library for Elixir projects.
We will cover Rewire's core concepts, how to get started with it, and practical examples. We will also see how to use Rewire alongside Mox.
Let's get started!
Introduction to Rewire
One of the challenges we faced in our previous article was the lack of a structured way to define and inject dependencies into our modules. We had to manually define our mocks for testing.
This is where Rewire and Mox come into play:
- Rewire provides a more structured and flexible way to implement DI in Elixir projects.
- Mox is a library that allows us to define mocks for our tests.
Combining these two tools can significantly improve the testability and modularity of our Elixir applications.
Let's get started by setting up a sample project that leverages both libraries.
Why Use Rewire and Mox for Elixir?
To recap part one of the series, we discussed the benefits of DI for testability and modularity. We saw how we can use pass-in dependencies via function parameters:
- We have the
EmailScanner
module that relies on aSpamFilterService
to check if an email is spam or not:
defmodule EmailScanner do
def scan_email(spam_filter_service, email) do
spam_filter_service.check_spam(email)
end
end
- We have the
SpamFilterService
module that implements the spam checking logic:
defmodule SpamFilterService do
def check_spam(email_content) do
String.contains?(email_content, "spam")
end
end
- We also have a
MockSpamFilterService
module that implements theSpamFilterService
behaviour for testing purposes:
defmodule MockSpamFilterService do
def check_spam(_email), do: false
end
- Finally, we have a test that uses the
MockSpamFilterService
to test theEmailScanner
module:
defmodule EmailScannerTest do
use ExUnit.Case
test "scan_email with non-spam email returns false" do
non_spam_email = %Email{content: "Hello, world!"}
assert false == EmailScanner.scan_email(MockSpamFilterService, non_spam_email)
end
end
In Elixir, modules are stateless, so the primary way to pass dependencies to a module is via function parameters. While Elixir modules can have attributes, these are used for compile-time information and metadata, not for holding runtime state.
Take the EmailScanner
module, for example. We have to pass the SpamFilterService
as a parameter to the scan_email
function. This is unnecessary, as the only reason to have this function parameter is to make the module testable.
Additionally, it creates a few problems with code readability and navigation:
- Because the module is expecting
SpamFilterService
as a parameter, we can't easily see what the module depends on. - The compiler can't catch issues with the module implementation, because we can pass any module that implements the
SpamFilterService
behaviour.
This approach might work well for small projects, but as our project grows, we might find ourselves repeating the same pattern over and over again. With Rewire, we don't have to worry about these issues. We can just focus on writing clean and maintainable code while keeping any testing concerns, mocks, and stubs in our test files.
Getting Started with Rewire and Mox in Your Elixir Project
Let's now dive into using Rewire and Mox in practice.
Step 1: Create a New Elixir Project
Before incorporating Rewire and Mox, create a new Elixir project:
mix new email_scanner
This command generates a new Elixir project named email_scanner
, including a supervision tree structure.
Step 2: Add Dependencies
To use Rewire and Mox, you need to add them to your project's dependencies. Update your mix.exs
file as follows:
defp deps do
[
{:rewire, "~> 1.0", only: :test},
{:mox, "~> 1.0", only: :test}
]
end
After updating the dependencies, run mix deps.get
in your terminal to fetch and install them.
Next, let's define our two primary modules:
defmodule EmailScanner do
def filter_email(email) do
email
|> mark_as_important()
|> SpamFilterService.check_spam()
end
defp mark_as_important(email) do
important_senders = ["boss@example.com", "hr@example.com"]
updated_email =
if Enum.any?(important_senders, fn sender -> sender == email.sender end) do
%{email | important: true}
else
email
end
updated_email
end
end
We are making our code example a bit more realistic. The filter_email
function marks emails from important senders as important and checks if the email is spam using the SpamFilterService
module.
Next, we'll define the SpamFilterService
module:
defmodule SpamFilterService do
def check_spam(email_content) do
String.contains?(email_content, "spam")
end
end
Let's create a basic test for the EmailScanner
module:
defmodule EmailScannerTest do
use ExUnit.Case
describe "filter_email/2" do
test "marks email as important from specific sender and checks for spam" do
important_sender_email = %{sender: "boss@example.com", content: "Please review the attached report.", important: false}
non_important_sender_email = %{sender: "random@example.com", content: "Check out these deals!", important: false}
# Filtering emails sent from the important sender
assert %{important: true, is_spam: false} = EmailScanner.filter_email(important_sender_email)
# Filtering emails sent from a non-important sender
assert %{important: false, is_spam: false} = EmailScanner.filter_email(non_important_sender_email)
end
end
end
In the above code, the EmailScanner
module relies on the SpamFilterService
module to check if an email is spam or not. However, we can't test the EmailScanner
module without also testing the SpamFilterService
module, which is not ideal.
We need to mock the SpamFilterService
module so that we can test the EmailScanner
module in isolation.
Step 3: Configuring Mox
Mox requires a bit of setup in your test configuration. Open or create a test/test_helper.exs
file and add the following line to define a mock based on a protocol or behaviour your project uses:
ExUnit.start()
Mox.defmock(EmailScanner.SpamFilterServiceMock, for: EmailScanner.SpamFilterService)
Mox makes it easy for us to generate mocks based on behaviours or protocols, which is essential for testing modules that rely on these abstractions.
Once our mock is defined, we can use it in our tests instead of the real implementation. With Rewire, we can inject these mocks into our modules without relying on function parameters.
Core Concepts of Rewire
Rewire simplifies the DI process in Elixir by providing a macro-based approach to define and inject dependencies. It fits seamlessly within Elixirβs ecosystem, promoting clean and maintainable code.
Dependency Injection with Rewire in the EmailScanner
Module
Letβs implement the EmailScanner
module, which relies on a SpamFilterService
to check if an email is spam or not. Using Rewire, we can easily inject this dependency. Take a look at the following code:
defmodule EmailScanner do
def filter_email(email) do
email
|> mark_as_important()
|> SpamFilterService.check_spam()
end
defp mark_as_important(email) do
important_senders = ["boss@example.com", "hr@example.com"]
updated_email =
if Enum.any?(important_senders, fn sender -> sender == email.sender end) do
%{email | important: true}
else
email
end
updated_email
end
end
Mocking with Mox for Testing
To test the EmailScanner
filter function, we can use Mox to mock the SpamFilterService
module:
defmodule EmailScannerTest do
use ExUnit.Case
import Rewire
import Mox
rewire EmailScanner, SpamFilterService: SpamFilterServiceMock
# Ensure mocks are verified after each test
setup :verify_on_exit!
describe "filter_email/2" do
test "marks email as important from specific sender and checks for spam" do
important_sender_email = %{sender: "boss@example.com", content: "Please review the attached report.", important: false}
non_important_sender_email = %{sender: "random@example.com", content: "Check out these deals!", important: false}
# Stub the SpamFilter service to return false for all emails
stub(SpamFilterServiceMock, :check_spam, fn _email -> :false end)
# Filtering emails sent from the important sender
assert %{important: true, is_spam: false} = EmailScanner.filter_email(important_sender_email)
# Filtering emails sent from a non-important sender
assert %{important: false, is_spam: false} = EmailScanner.filter_email(non_important_sender_email)
end
end
end
Let's break down what is happening in the above test:
-
rewire EmailScanner, SpamFilterService: SpamFilterServiceMock
: This line uses Rewire to replace theSpamFilterService
dependency in theEmailScanner
module withSpamFilterServiceMock
for the scope of this test module. It effectively changes the behavior ofEmailScanner
to use the mock service instead of its real dependency. -
setup :verify_on_exit!
: A setup callback that ensures all expectations on mocks (defined using Mox) are met by the end of each test, or else the test fails. This is crucial for verifying that the mocked functions are called as expected. -
Then, we define a test case that:
- Creates two email maps, one from an "important" sender and one from a "non-important" sender.
- Uses stub to define the behavior of the
SpamFilterServiceMock
, socheck_spam/1
always returnsfalse
, simulating a scenario where no email is considered spam. - Calls
filter_email/2
on both emails, expecting the function to correctly identify and mark the important email and to correctly interact with the spam filter (mocked to always returnfalse
for spam checks).
Under the hood, Rewire is doing a couple of interesting things. First, it's important to understand the philosophy behind Rewire and the approach the author decided to take. rewire
works by using macros to create a copy of the module. So, for every test, Rewire creates a new module with the specified stubs.
Creating a copy of each module instead of overriding the original module allows us to run tests in parallel without any side effects.
Things to Consider When Using Rewire and Mox
When using Rewire and Mox in your Elixir projects, consider the following:
-
Asynchronous Testing Compatibility:
Rewire fully supports asynchronous testing with
async: true
. Unlike global overrides used by tools like Meck, Rewire creates a separate module copy for each test. This ensures that tests can run in parallel without interfering with each other. - Integration with Mox: Rewire complements Mox perfectly by focusing on dependency injection without dictating the source of the mock module. This synergy allows for efficient and seamless integration between the two, making them an excellent pair for Elixir testing.
- Impact on Test Speed: Rewire might slightly slow down your tests, although the effect is typically minimal. Comprehensive performance data from large codebases is still pending.
- Test Coverage Accuracy: Yes, test coverage is accurately reported with Rewire, ensuring that you can trust your test coverage metrics.
- Compatibility with Stateful Processes: Rewire works well with stateful processes, provided that these processes are started after their module has been rewired. For processes started beforehand (like a Phoenix controller), Rewire may not be effective since rewiring can no longer be applied. It's recommended to use Rewire primarily for unit tests where this limitation doesn't apply.
- Erlang Module Rewiring: Rewire cannot directly rewire Erlang modules. However, it allows for Erlang module references to be replaced within Elixir modules, offering a workaround for this limitation.
- Handling Nested Modules: Rewire will only replace dependencies within the specifically rewired module. Surrounding or nested modules will remain unaffected, maintaining references to the original modules. For complete control, you may need to rewire these modules individually.
-
Formatter Configuration for Rewire:
To prevent mix format from adding parentheses around Rewire, update your
.formatter.exs
file withimport_deps: [:rewire]
. This ensures that Rewire syntax is correctly formatted without unnecessary parentheses.
And that's it!
Wrapping Up
In this post, we've explored how Rewire and Mox can help with dependency injection in Elixir.
Stephan Behnke, the creator of Rewire, was motivated by a desire for a more elegant solution to dependency injection in Elixir, especially for unit testing. I believe he succeeded in providing a great tool for the Elixir community.
That said, Rewire is not a silver bullet and it might not be the right tool for every project. It is important to evaluate Rewire alongside tools like Meck and make a decision based on your project and team's needs.
Happy coding!
P.S. If you'd like to read Elixir Alchemy posts as soon as they get off the press, subscribe to our Elixir Alchemy newsletter and never miss a single post!
Top comments (0)