DEV Community

Cover image for Advanced Dependency Injection in Elixir with Rewire
Allan MacGregor πŸ‡¨πŸ‡¦ for AppSignal

Posted on • Originally published at blog.appsignal.com

Advanced Dependency Injection in Elixir with Rewire

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:
defmodule EmailScanner do
  def scan_email(spam_filter_service, email) do
    spam_filter_service.check_spam(email)
  end
end
Enter fullscreen mode Exit fullscreen mode
  • 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
Enter fullscreen mode Exit fullscreen mode
  • We also have a MockSpamFilterService module that implements the SpamFilterService behaviour for testing purposes:
defmodule MockSpamFilterService do
  def check_spam(_email), do: false
end
Enter fullscreen mode Exit fullscreen mode
  • Finally, we have a test that uses the MockSpamFilterService to test the EmailScanner 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 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)