Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 53 additions & 62 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,23 +1,22 @@
# ModuleResolver

Библиотека для разделения(decoupling) зависимостей на уровне модулей с целью упрощения тестирования. Так же предоставляет удобный интерфейс для создания моков этих зависимостей.
A library for decoupling module-level dependencies to simplify testing. It also provides a convenient interface for creating mocks of these dependencies.

## Добвление `module_resolver`'a
## Installation

Добавляем библиотеку в `mix.exs` файл:
Add `:module_resolver` to the list of dependencies in `mix.exs`:

```elixir
def deps do
[
...,
{:module_resolver, "~> 0.1.0"}
]
end
```

## Использование в модулях
## Usage

Для того чтобы иметь возможность в тестах подменять модуль моками необходимо в модуле с колбеками использовать `use ModuleResolver`, например:
You may use `ModuleResolver` in a module that is expected to be mocked. For example:

```elixir
defmodule MyModule
Expand All @@ -33,42 +32,42 @@ defmodule MyModuleDefaultImplementation
def some_function(counter), do: {:ok, counter}
end
```
Here `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`.

Параметр `default_impl` передает имя модуля, который будет использоваться по умолчанию. Например, результатом вызова `MyModule.some_function(5)` будет результат вызова `MyModuleDefaultImplementation.some_function(5)`.

Параметр `default_impl` можно опустить:
To define mocks in tests you may do this in the `test_helper.exs` file:

```elixir
defmodule MyModule
use ModuleResolver
ModuleResolver.Mocks.defmocks([MyModule], mock_factory: Mox, postfix: "Mock")
```

@callback some_function(integer()) :: {:ok, integer()}
The first agrument here is a list of behaviour modules and the second one is `options`. There are two possible options:

defmodule DefaultImpl
@behaviour MyModule

@impl true
def some_function(counter), do: {:ok, counter}
end
end
```
- `mock_factory`, implementation of `BeamMetrics.Mocks.MockFactory` behaviour. It can be `Mox` or `Hammox` as 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'`

В таком случае модуль по умолчанию будет формироваться из неймспейса модуля(в нашем случае `MyModule`) и `DefaultImpl`. Т.е в примере выше это будет `MyModule.DefaultImpl`. Это удобно для быстрого разделения зависимостей в существующих модулях.
Например, имеем изначально стандартный модуль:
To use these mocks, you need to disable implementation mounting at compile time. Add the following to the `test.exs` file:

```elixir
config :module_resolver, compile_default_impl: false
```

Now 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.

## Compile-time/runtime

By default, implementation is compiled into the behaviour module, and after compilation, the result roughly looks like:

```Elixir
defmodule MyModule
@spec some_function(integer()) :: {:ok, integer()}
def some_function(counter), do: {:ok, counter}
def some_function(count), do: MyModuleDefaultImplementation.some_function(count)
end
```
Для того чтобы использовать его Mock в тестах нам необходимо сделать всего 4 небольших шага:
1. Добавить `use ModuleResolver`
2. Существующие спеки перенести в callback'и
3. Все определения функций завернуть в дополнительный модуль `DefaultImpl`
4. В `DefaultImpl` прописать `@behaviour MyModule` в начало и `@impl true` у каждой функции

В итоге получаем:
When `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.

## Other use cases

The `default_impl` option may be omitted:

```elixir
defmodule MyModule
Expand All @@ -85,48 +84,42 @@ defmodule MyModule
end
```

## Использование в тестах

После того как модули были настроены, можно использовать их моки в тестах. Для этого необходимо в `test_helper.exs` указать список модулей первым параметром и двумя опциями:
- `mock_factory` с помощью которой будут созданы моки
- `postfix`, который будет добавлен к названию модуля behaviour чтобы получить имя mock модуля
In 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:

```elixir
ModuleResolver.Mocks.defmocks([MyModule, MyAnotherModule], mock_factory: Mox, postfix: "Mock")
defmodule MyExistedModule
@spec existed_function(integer()) :: {:ok, integer()}
def existed_function(counter), do: {:ok, counter}
end
```
В результате этой команды будут созданы моки `MyModuleMock` и `MyAnotherModuleMock`, которые будут автоматически вызываться при каждом вызове любой функции из модулей `MyModule` и `MyAnotherModule` соответственно.

С помощь обязательной опции `mock_factory` необходимо передать модуль генерации моков. Модуль генерации должен имплементировать поведение `ModuleResolver.Mocks.MockFactory`. Можно так же использовать `Mox` или `Hammox`.

Опцию `postfix` можно опустить, тогда она по умолчанию будет равна `Mock`
To use `MyExistedModuleMock` instead of `MyExistedModule` in tests, you need to follow 5 steps:

## Конфигурация
1. Add `use ModuleResolver`
2. Replace `@spec` with `@callback`.
3. Wrap all function definitions in the `DefaultImpl` module
4. Add `@behaviour MyExistedModule` to the top of the `DefaultImpl` module and `@impl true` to each function.
5. Add `MyExistedModule` to the mocks list in `ModuleResolver.Mocks.defmocks/2`

По умолчанию модуль имплементации будет выбираться в момент компиляции, при этом на этом этапе функции имплементации будут "вмонтированы" в функцию поведения. В итоге получится нечто вроде:
As result:

```elixir
defmodule MyModule
use ModuleResolver, default_impl: MyModuleDefaultImplementation
@callback some_function(integer()) :: {:ok, integer()}

def some_function(count), do: MyModuleDefaultImplementation.some_function(count)
end
```

Относительно решенения без `module_resolver`a overhead составляет лишь 1 дополнительный вызов функции в стек вызовов.
defmodule MyExistedModule
use ModuleResolver

Использование модулей в тестах требует runtime определения модуля имплементации, для этого необходимо сконфигурировать `module_resolver`, передав параметр `compile_default_impl: false`. Например, в конфиг `test.exs` добавить:
@callback existed_function(integer()) :: {:ok, integer()}

```elixir
config :module_resolver, compile_default_impl: false
defmodule DefaultImpl
@behaviour MyExistedModule

@impl true
def existed_function(counter), do: {:ok, counter}
end
end
```

В таком случае поведение будет выбираться каждый раз в момент вызова функции из модуля behaviour

## Benchmarking

Сравнение времени выполнения на примере следующих модулей:

```elixir
defmodule BenchTestsBehaviour do
use ModuleResolver, default_impl: BenchTestsImplementation
Expand Down Expand Up @@ -165,7 +158,7 @@ Benchee.run(
)
```

Результаты при настройке конфигурации `config :module_resolver, compile_default_impl: false`:
Results with `config :module_resolver, compile_default_impl: false`:

```bash
Name ips average deviation median 99th %
Expand All @@ -183,7 +176,7 @@ implementation direct call 59.52 MB
behaviour call 65.63 MB - 1.10x memory usage +6.10 MB
```

Результаты при настройке конфигурации `config :module_resolver, compile_default_impl: true`:
Results with `config :module_resolver, compile_default_impl: true`:

```bash
Name ips average deviation median 99th %
Expand All @@ -199,6 +192,4 @@ Memory usage statistics:
Name Memory usage
implementation direct call 59.52 MB
behaviour call 59.52 MB - 1.00x memory usage -0.00018 MB

**All measurements for memory usage were the same**
```
10 changes: 2 additions & 8 deletions lib/module_resolver.ex
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
defmodule ModuleResolver do
@moduledoc """
Библиотека для разделения(decoupling) зависимостей на уровне модулей с целью упрощения тестирования
A library for decoupling dependencies at the module level to simplify testing.
"""

defmodule InvalidOptionError do
Expand Down Expand Up @@ -46,9 +46,6 @@ defmodule ModuleResolver do
args = Macro.generate_arguments(arity, env.module) |> Macro.escape()
spec = type_spec |> Macro.escape()

# quote теперь используется только непосредственно для inject'a кода
# Только 2 вещи происходят в контексте модуля behaviour:
# добавление спеки и определение функции. Всё остальное происходит в контексте ModuleResolver
quote bind_quoted: [implementation: implementation, fun_name: fun_name, args: args, spec: spec] do
Elixir.Kernel.@(spec(unquote(spec)))

Expand All @@ -59,8 +56,7 @@ defmodule ModuleResolver do
end
end

@spec implementation(behaviour_module(), fallback_impl :: implementation_module()) ::
implementation_module()
@spec implementation(behaviour_module(), fallback_impl :: implementation_module()) :: implementation_module()
def implementation(behaviour, fallback_impl) do
Storage.get_implementation_module(behaviour) || fallback_impl
end
Expand All @@ -72,8 +68,6 @@ defmodule ModuleResolver do
if compile_only_default?() do
default_impl
else
# Здесь так же не обойтись без контекста caller'a, так как
# проверять какой сервис дернуть необходимо в момент вызова имплементируемой функции
quote do
unquote(__MODULE__).implementation(unquote_splicing([implementation_module, default_impl]))
end
Expand Down
4 changes: 2 additions & 2 deletions lib/module_resolver/mocks.ex
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
defmodule ModuleResolver.Mocks do
@moduledoc """
Модуль хранящий всю функциональность связанную с моками
The module stores all the functionality associated with mocks.
"""
alias ModuleResolver.Storage

defmodule MockFactory do
@moduledoc """
Поведение для генератора моков
The behaviour for mock generator. It should have `defmock/1` function to define mocks
"""

@type t :: module()
Expand Down
2 changes: 1 addition & 1 deletion lib/module_resolver/storage.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ defmodule ModuleResolver.Storage do
alias ModuleResolver.Storage.AppEnv

@moduledoc """
Модуль behaviour для хранения имплементаций в runtime
Behaviour module for storing implementations at runtime
"""
@type t :: module()

Expand Down
2 changes: 1 addition & 1 deletion lib/module_resolver/storage/app_env.ex
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
defmodule ModuleResolver.Storage.AppEnv do
@moduledoc """
Модуль имплементации ModuleResolver.Storage с использованием Application environment
ModuleResolver.Storage implementation module using application environment
"""

@behaviour ModuleResolver.Storage
Expand Down
6 changes: 4 additions & 2 deletions test/module_resolver/mocks_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import Mox

defmodule TestBehaviour1 do

Check warning on line 6 in test/module_resolver/mocks_test.exs

View workflow job for this annotation

GitHub Actions / build and test (1.13.4, 25.3.2.21)

MockDefaultImpl1.init/0 is undefined (module MockDefaultImpl1 is not available or is yet to be defined)

Check warning on line 6 in test/module_resolver/mocks_test.exs

View workflow job for this annotation

GitHub Actions / build and test (1.13.4, 25.3.2.21)

MockDefaultImpl1.increment/1 is undefined (module MockDefaultImpl1 is not available or is yet to be defined)
use ModuleResolver, default_impl: MockDefaultImpl1

@callback init() :: {:ok, integer()}
Expand Down Expand Up @@ -31,7 +31,7 @@
ModuleResolver.Mocks.defmocks([TestBehaviour1, TestBehaviour2], mock_factory: Mox)
end

test "defines mocks for behaviours with given postfix" do
test "defines mocks for behaviours with the given postfix" do
expected_implementations = %{
TestBehaviour1 => ModuleResolver.MocksTest.TestBehaviour1NotAMock,
TestBehaviour2 => ModuleResolver.MocksTest.TestBehaviour2NotAMock
Expand All @@ -44,8 +44,10 @@
end

defp expect_storage_put_implementations(expected_implementations) do
count = Enum.count(expected_implementations)

StorageMock
|> expect(:put_implementation_module, 2, fn behaviour, mock ->
|> expect(:put_implementation_module, count, fn behaviour, mock ->
assert behaviour in Map.keys(expected_implementations)
assert mock == expected_implementations[behaviour]
end)
Expand Down
10 changes: 5 additions & 5 deletions test/module_resolver/storage/app_env_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@ defmodule ModuleResolver.Storage.AppEnvTest do
end

describe "get_implementation_module/1" do
test "returns nil if there are no implementations stored" do
refute AppEnv.get_implementation_module(__MODULE__)
end

test "returns implementation from storage" do
test "returns an implementation from storage" do
:ok = AppEnv.put_implementation_module(__MODULE__, TestModule)
assert AppEnv.get_implementation_module(__MODULE__) == TestModule
end

test "returns nil if there are no implementations stored" do
refute AppEnv.get_implementation_module(__MODULE__)
end
end

describe "put_implementation_module/2" do
Expand Down
5 changes: 2 additions & 3 deletions test/module_resolver_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@ defmodule ModuleResolverTest do

import Mox

# Здесь необходимо записать в env переменную отрицательный флаг `компиляции только имплементации по умолчанию`
# для проверки основных сценариев
# To prevent direct compilation of the default implementation, the `compile_default_impl` option must be set to false.
Application.put_env(:module_resolver, :compile_default_impl, false)

defmodule TestBehaviour do
Expand All @@ -22,7 +21,7 @@ defmodule ModuleResolverTest do
@callback increment(integer()) :: integer()
end

# Возвращаем стандартное поведение при котором происходит выбор имплементации при компиляции
# We return the option value `true` to check for these cases.
Application.put_env(:module_resolver, :compile_default_impl, true)

defmodule TestOnlyDefaultBehaviour do
Expand Down