Rewire: A new Approach to Dependency Injection in Elixir
I’ve been working with Elixir for 3 years full-time now and while I think it’s an exceptional language and development environment, the testing story always felt incomplete to me. Something was missing. In this post, I’ll explain what that is and how I attempted to fix it.
While I strive to minimize the use of mocks, I find they are still quite useful in certain situations.
Before Elixir, I’ve mainly worked with Java. For better or worse, in Java you have a multitude of options to inject dependencies into your classes. Most notably,
@Autowired that allows you to simply override annotated fields during testing with your mock. Could not be simpler.
In Elixir things are a little different. That’s because Elixir does not have classes or class fields. Modules are stateless. So how does one inject a dependency in Elixir?
Let’s look at this simplified example and explore our options:
defmodule Conversation do def start(), do: English.greet() end
🛑 Function Arguments
The easiest approach does not require any libraries: passing-in dependencies using the function arguments.
defmodule Conversation do def start(lang_mod \\ English), do: lang_mod.greet() end
defmodule MyTest do use ExUnit.Case, async: false test "start/0" do defmodule EnglishMock do def greet(), do: "g'day" end assert Conversation.start(EnglishMock) == "g'day" end end
While many (including myself) find this to look “odd” at first, it is admittedly easy to do.
However, it comes with quite a few drawbacks:
- Your application code is now littered with testing concerns.
- Navigation in your code editor doesn’t work as well.
- Searches for usages of the module are more difficult.
- The compiler is not able to warn you in case
greet/0doesn’t exist on the
🛑 Global Override
defmodule MyTest do use ExUnit.Case, async: false # not concurrently! import Mock test "start/0" do with_mock English, [greet: fn() -> "g'day" end] do assert Conversation.start() == "g'day" end end end
English module is temporarily replaced with a mock that stubs out the
greet function. So far so good - but it comes with a cost. One of ExUnit’s most valuable features is the ability to run tests concurrently. However, to stub out modules globally we have to exempt this test module from being run concurrently (notice the
async: false). This might seem like a small price to pay but if your application grows you might soon find yourself with a slow test suite. This can easily be avoided!
🛑 Configuration Lookup
The more or less official mocking library for Elixir is mox.
# in test_helper.exs Mox.defmock(EnglishMock, for: English) Application.put_env(:myapp, :english, EnglishMock)
defmodule Conversation do def start(), do: english().greet() defp english(), do: Application.get(:myapp, :english, English) end
defmodule MyTest do use ExUnit.Case, async: true # concurrently! import Mox test "start/0" do stub(English, :greet, fn -> "g'day" end) assert Conversation.start() == "g'day" end end
mox provides a mock that is “injected” into the module under test by doing a lookup in the app’s configuration.
The advantage is that the “odd” function parameter is gone, but all of the other issues are still there. But at least it can be run concurrently since the mock is set up per process (and each test module is its own process in ExUnit).
I wasn’t satisfied with any of these options. So I experimented a little with Elixir metaprogramming and the result was rewire.
It focuses purely on dependency injection and can be used with any mocking library, like
# in test_helper.exs Mox.defmock(EnglishMock, for: English)
defmodule MyTest do use ExUnit.Case, async: true # concurrently! import Rewire import Mox rewire Conversation, English: EnglishMock # inject! test "start/0" do stub(EnglishMock, :greet, fn -> "g'day" end) assert Conversation.start() == "g'day" end end
By following this approach, we keep our production code completely free of testing concerns and the test can still be run concurrently!
You can use it with any mocking library, not just
mox. Or just stubs you defined yourself. It only cares about dependency injection.
Ehm, But How Does it Work?
rewire is a macro, imported via
Let’s look at what code the macro generated here:
defmodule Conversation.R518 do def start(), do: EnglishMock.greet() end alias Conversation.R518, as: Conversation
First, it generates a copy of the original module with the
English reference replaced by
EnglishMock. You might also notice that the module name has changed. Since the module might be rewired in multiple places, this is supposed to prevent naming collisions.
Then, it adds an alias to the rewired module under the original name.
You might wonder how it generates a new module from the original one. The library finds the module’s source file path by calling
module_info, parses the code into an AST with
Code.string_to_quoted, traverses the AST to replace any rewired dependencies using
Macro.traverse and evaluates the result with
Code.eval_quoted. Check out the source code for details.
As far as I know, the only situation where you cannot use
rewire to inject your dependencies is when you are dealing with a process that has been started before your test.
Take for example a Phoenix controller test. Since you’ll be writing tests against the server (using
ConnCase), a dependency in the controller cannot be rewired after the fact.
I hope you enjoyed this blog post. If you have any questions or feedback, please leave a comment. And if you’re curious, try out rewire yourself.comments powered by Disqus