Advanced Dependency Injection in Elixir with Rewire
Tags: elixir
Published: 2024-06-25 12:00:00

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 a `SpamFilterService` to check if an email is spam or not:

```elixir
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:

```elixir
defmodule SpamFilterService do
  def check_spam(email_content) do
    String.contains?(email_content, "spam")
  end
end
```

- We also have a `MockSpamFilterService` module that implements the `SpamFilterService` behaviour for testing purposes:

```elixir
defmodule MockSpamFilterService do
  def check_spam(_email), do: false
end
```

- Finally, we have a test that uses the `MockSpamFilterService` to test the `EmailScanner` module:

```elixir
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](https://github.com/stephanos/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:

```bash
mix new email_scanner
```

This command generates a new Elixir project named `email_scanner`, including a supervision tree structure.

<Banner lang="elixir" />

### 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:

```elixir
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:

```elixir
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:

```elixir
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:

```elixir
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:

```elixir
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:

```elixir
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:

```elixir
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 the `SpamFilterService` dependency in the `EmailScanner` module with `SpamFilterServiceMock` for the scope of this test module. It effectively changes the behavior of `EmailScanner` 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`, so `check_spam/1` always returns `false`, 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 return `false` 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 with `import_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](https://github.com/eproxus/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](/elixir-alchemy)!**