on
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.
Injecting Mocks
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/0
doesn’t exist on theEnglish
module.
🛑 Global Override
The Elixir library mock (wrapping the Erlang library meck under the hood) allows overriding any module globally.
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
Here the 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).
🎉 rewire
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 mox
.
# 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 import Rewire
.
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.
Limitation
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.
La Fin
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