definject
definject copied to clipboard
Unobtrusive Dependency Injector for Elixir

Unobtrusive Dependency Injector for Elixir
Why?
Let's say we want to test following function.
def send_welcome_email(user_id) do
%{email: email} = Repo.get(User, user_id)
welcome_email(to: email)
|> Mailer.send()
end
Here's one possible solution to replace Repo.get/2 and Mailer.send/1 with mocks:
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 Mailer replaced with mailer, the compiler no longer check the existence of Mailer.send/1.
definject does not require you to modify function arguments or body. It 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, "~> 1.2"}]
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
To format definject like def, add following to your .formatter.exs
locals_without_parens: [definject: 1, definject: 2]
Documentation
API documentation is available at https://hexdocs.pm/definject
Usage
use Definject
use Definject transforms def to accept a extra argument deps where dependent functions and modules can be injected.
use Definject
def 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} =
Map.get(deps, &Repo.get/2,
:erlang.make_fun(Map.get(deps, Repo, Repo), :get, 2)
).(User, user_id)
welcome_email(to: email)
|> Map.get(deps, &Mailer.send/1,
:erlang.make_fun(Map.get(deps, Mailer, 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 and modules in tests.
test "send_welcome_email" do
Accounts.send_welcome_email(100, %{
Repo => MockRepo,
&Mailer.send/1 => fn %Email{to: "[email protected]", subject: "Welcome"} ->
Process.send(self(), :email_sent)
end
})
assert_receive :email_sent
end
Function calls raise if the deps includes redundant functions or modules.
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: "[email protected]"} end,
&Repo.all/1 => fn _ -> [%User{email: "[email protected]"}] end, # Unused
strict: false
})
end
mock
If you don't need pattern matching in mock function, mock/1 can be used to reduce boilerplates.
import Definject
test "send_welcome_email with mock/1" do
Accounts.send_welcome_email(
100,
mock(%{
Repo => MockRepo,
&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.
import Definject
import Definject instead of use Definject if you want to manually select functions to inject.
import Definject
definject send_welcome_email(user_id) do
%{email: email} = Repo.get(User, user_id)
welcome_email(to: email)
|> Mailer.send()
end
License
This project is licensed under the MIT License - see the LICENSE file for details