Что такое mock и как его сделать в Elixir?

В Elixir mock — это подставной (временный, подменный) модуль или функция, используемая в тестах для имитации поведения внешних зависимостей: API, базы данных, сторонних библиотек, процессов и т.п. Это особенно важно при юнит-тестировании, когда необходимо протестировать модуль изолированно от его окружения.

Elixir не имеет встроенного механизма мокирования как в некоторых ООП-языках (Java, Python), но предоставляет гибкие средства через контрактные интерфейсы и библиотеку Mox, которая официально поддерживается командой Elixir.

Как устроено мокирование в Elixir

Мокирование строится на поведенческом контракте (behaviour) и внедрении зависимости через параметры или конфигурацию.

1. Определение behaviour (контракта)

defmodule MyApp.ExternalAPI do
@callback get_data(String.t()) :: {:ok, any()} | {:error, String.t()}
end

Это определение интерфейса, который должны реализовать как настоящий, так и подставной модуль.

2. Реализация настоящего модуля

defmodule MyApp.RealAPI do
@behaviour MyApp.ExternalAPI
def get_data(id) do
\# настоящий вызов API
{:ok, "данные из API для #{id}"}
end
end

3. Добавление зависимости через конфигурацию

\# config/config.exs
config :my_app, :external_api, MyApp.RealAPI

4. Использование зависимости в коде

defmodule MyApp.DataService do
def fetch(id) do
api = Application.get_env(:my_app, :external_api)
api.get_data(id)
end
end

5. Установка Mox

Добавьте зависимость в mix.exs:

def deps do
\[
{:mox, "~> 1.0", only: :test}
\]
end

После этого запустите mix deps.get.

6. Определение мока

\# test/support/mocks.ex
Mox.defmock(MyApp.MockAPI, for: MyApp.ExternalAPI)

И подключите файл в test/test_helper.exs:

ExUnit.start()
Mox.Server.start_link(\[\])
Code.require_file("support/mocks.ex", \__DIR_\_)

7. Конфигурация мока в тесте

use ExUnit.Case, async: true
setup :set_mox_global
setup :verify_on_exit!
test "обрабатывает ответ от мока" do
MyApp.MockAPI
|> expect(:get_data, fn "123" -> {:ok, "тестовые данные"} end)
Application.put_env(:my_app, :external_api, MyApp.MockAPI)
assert MyApp.DataService.fetch("123") == {:ok, "тестовые данные"}
end

Ключевые особенности Mox

  • expect(module, fun_name, arity, fun) — задаёт поведение мока.

  • Все вызовы проверяются, и если они не произошли — тест падает.

  • По умолчанию моки работают только в одном процессе — set_mox_global позволяет использовать их в async: true тестах.

Альтернатива: ручной мок без Mox

Если не используется Mox, можно просто передать модуль как параметр:

def fetch(id, api \\\\ MyApp.RealAPI) do
api.get_data(id)
end

В тесте:

defmodule FakeAPI do
def get_data(\_id), do: {:ok, "фейковые данные"}
end
test "тест с ручным моком" do
assert MyApp.DataService.fetch("42", FakeAPI) == {:ok, "фейковые данные"}
end

Этот подход прост и не требует зависимостей, но не проверяет количество вызовов или аргументы.

Использование Mox.expect/4 и Mox.stub_with/2

Можно задать стандартное поведение с помощью stub_with:

Mox.stub_with(MyApp.MockAPI, MyApp.RealAPI)

А затем выборочно expect нужные вызовы.

Пример с несколькими вызовами

MyApp.MockAPI
|> expect(:get_data, fn "id1" -> {:ok, "one"} end)
|> expect(:get_data, fn "id2" -> {:ok, "two"} end)

Можно использовать expect/3 с числом вызовов:

expect(MyApp.MockAPI, :get_data, 2, fn _ -> {:ok, "result"} end)

Моки в Elixir реализуются через контракты и подмену модулей, благодаря чему достигается явное управление зависимостями, тестируемость и чистота кода. Использование Mox позволяет гарантировать, что все вызовы действительно произошли, и упрощает поддержку крупных проектов.