Definject
Unobtrusive Dependency Injector for Elixir
Why?
Let's say we want to test following function with mocks for Repo
and Mailer
.
def send_welcome_email(user_id) do
%{email: email} = Repo.get(User, user_id)
welcome_email(to: email)
|> Mailer.send()
end
Here's how you use one of the existing mock libraries:
def send_welcome_email(user_id, repo \\ Repo, mailer \\ Mailer) do
%{email: email} = repo.get(User, user_id)
welcome_email(to: email)
|> mailer.send()
end
First, I believe that this approach is too obtrusive as it requires modifying the function body to make it testable. Second, with Repo
replaced with repo
, the compiler can no longer guarantee the existence of Repo.get/2
function.
definject
does not require you to modify function arguments or body. Instead, you just need to replace def
with definject
. It also allows injecting different mocks to each function. It also does not limit using :async
option as mocks are contained in each test function.
Installation
The package can be installed by adding definject
to your list of dependencies
in mix.exs
:
def deps do
[{:definject, "~> 0.6.0"}]
end
By default, definject
is replaced with def
in all but the test environment. Add the below configuration to enable in other environments.
config :definject, :enable, true
Usage
definject
definject
transforms a function to accept a map where dependent functions can be injected.
import Definject
definject send_welcome_email(user_id) do
%{email: email} = Repo.get(User, user_id)
welcome_email(to: email)
|> Mailer.send()
end
is expanded into
def send_welcome_email(user_id, deps \\ %{}) do
%{email: email} = (deps[&Repo.get/2] || &Repo.get/2).(User, user_id)
welcome_email(to: email)
|> (deps[&Mailer.send/1] || &Mailer.send/1).()
end
Note that local function calls like welcome_email(to: email)
are not expanded unless it is prepended with __MODULE__
.
Now, you can inject mock functions in tests.
test "send_welcome_email" do
Accounts.send_welcome_email(100, %{
&Repo.get/2 => fn User, 100 -> %User{email: "mr.jechol@gmail.com"} end,
&Mailer.send/1 => fn %Email{to: "mr.jechol@gmail.com", subject: "Welcome"} ->
Process.send(self(), :email_sent)
end
})
assert_receive :email_sent
end
definject
raises if the passed map includes a function that's not called within the injected function.
You can disable this by adding strict: false
option.
test "send_welcome_email with strict: false" do
Accounts.send_welcome_email(100, %{
&Repo.get/2 => fn User, 100 -> %User{email: "mr.jechol@gmail.com"} end,
&Repo.all/1 => fn _ -> [%User{email: "mr.jechol@gmail.com"}] end, # Unused
strict: false,
})
end
mock
If you don't need pattern matching in mock function, mock/1
can be used to reduce boilerplates.
test "send_welcome_email with mock/1" do
Accounts.send_welcome_email(
100,
mock(%{
&Repo.get/2 => %User{email: "mr.jechol@gmail.com"},
&Mailer.send/1 => Process.send(self(), :email_sent)
})
)
assert_receive :email_sent
end
Note that Process.send(self(), :email_sent)
is surrounded by fn _ -> end
when expanded.
License
This project is licensed under the MIT License - see the LICENSE file for details