A library for decoupling module-level dependencies to simplify testing. It also provides a convenient interface for creating mocks of these dependencies.
Add :module_resolver to the list of dependencies in mix.exs:
def deps do
[
{:module_resolver, "~> 0.1.0"}
]
endYou may use ModuleResolver in a module that is expected to be mocked. For example:
defmodule MyModule do
use ModuleResolver, default_impl: MyModuleDefaultImplementation
@callback some_function(integer()) :: {:ok, integer()}
end
defmodule MyModuleDefaultImplementation do
@behaviour MyModule
@impl true
def some_function(counter), do: {:ok, counter}
endHere MyModule uses ModuleResolver and specifies the default implementation module through default_impl option. After defining a callback in the behaviour module and the function itself in the implementation module, the call to MyModule.some_function/1 will be delegated to the MyModuleDefaultImplementation.some_function/1.
To define mocks in tests you may do this in the test_helper.exs file:
ModuleResolver.Mocks.defmocks([MyModule], mock_factory: Mox, postfix: "Mock")The first agrument here is a list of behaviour modules and the second one is options. There are two possible options:
mock_factory, implementation ofBeamMetrics.Mocks.MockFactorybehaviour. It can beMoxorHammoxas well.postfix, will be added to the end of the behaviour module name to create a mock module name. Can be omitted. By default:"Mock'
To use these mocks, you need to disable implementation mounting at compile time. Add the following to the test.exs file:
config :module_resolver, compile_default_impl: falseNow call to MyModule.some_function/1 in test environment wil be delegated to MyModuleMock.some_function/1. In other environments the module under default_impl will be compiled into behaviour.
By default, implementation is compiled into the behaviour module, and after compilation, the result roughly looks like:
defmodule MyModule do
def some_function(count), do: MyModuleDefaultImplementation.some_function(count)
endWhen compile_default_impl: false is set, the implementation is determined at runtime. If there is no mock for a given behavior, a default implementation will be used, so integration tests can use the actual implementation without defining mocks.
The default_impl option may be omitted:
defmodule MyModule do
use ModuleResolver
@callback some_function(integer()) :: {:ok, integer()}
defmodule DefaultImpl do
@behaviour MyModule
@impl true
def some_function(counter), do: {:ok, counter}
end
endIn this case default implementation will be set as __MODULE__.DefaultImpl. In the code above it will be MyModule.DefaultImpl. It's helpful when you need to decouple existed modules. For example, we have a module such as:
defmodule MyExistedModule do
@spec existed_function(integer()) :: {:ok, integer()}
def existed_function(counter), do: {:ok, counter}
endTo use MyExistedModuleMock instead of MyExistedModule in tests, you need to follow 5 steps:
- Add
use ModuleResolver - Replace
@specwith@callback. - Wrap all function definitions in the
DefaultImplmodule - Add
@behaviour MyExistedModuleto the top of theDefaultImplmodule and@impl trueto each function. - Add
MyExistedModuleto the mocks list inModuleResolver.Mocks.defmocks/2
As result:
defmodule MyExistedModule do
use ModuleResolver
@callback existed_function(integer()) :: {:ok, integer()}
defmodule DefaultImpl do
@behaviour MyExistedModule
@impl true
def existed_function(counter), do: {:ok, counter}
end
enddefmodule BenchTestsBehaviour do
use ModuleResolver, default_impl: BenchTestsImplementation
@callback some_fun(integer()) :: {:ok, integer()}
end
defmodule BenchTestsImplementation do
@behaviour BenchTestsBehaviour
@impl true
def some_fun(number), do: {:ok, number}
endbenchee code:
Benchee.run(
%{
"implementation direct call" => fn ->
Enum.each(
0..100_000,
fn num -> BenchTestsImplementation.some_fun(num) end
)
end,
"behaviour call" => fn ->
Enum.each(
0..100_000,
fn num -> BenchTestsBehaviour.some_fun(num) end
)
end
},
time: 10,
memory_time: 2
)Results with config :module_resolver, compile_default_impl: false:
Name ips average deviation median 99th %
implementation direct call 20.88 47.89 ms ±12.12% 46.78 ms 72.11 ms
behaviour call 10.55 94.81 ms ±11.37% 91.97 ms 131.05 ms
Comparison:
implementation direct call 20.88
behaviour call 10.55 - 1.98x slower +46.92 ms
Memory usage statistics:
Name Memory usage
implementation direct call 59.52 MB
behaviour call 65.63 MB - 1.10x memory usage +6.10 MBResults with config :module_resolver, compile_default_impl: true:
Name ips average deviation median 99th %
implementation direct call 22.32 44.79 ms ±7.09% 44.26 ms 56.62 ms
behaviour call 21.81 45.86 ms ±8.48% 45.40 ms 55.53 ms
Comparison:
implementation direct call 22.32
behaviour call 21.81 - 1.02x slower +1.07 ms
Memory usage statistics:
Name Memory usage
implementation direct call 59.52 MB
behaviour call 59.52 MB - 1.00x memory usage -0.00018 MB