Do you want to pick up from where you left of?
Take me there

Mox

Mox - это библиотека для создания параллельных моков в Elixir.

Пишем тестируемый код

Моки облегачают написание тестов, но обычно они не привлекают к себе пристального внимания в контексте языка программирования, поэтому не удивительно, что о них написано меньше материала. Однако вы можете довольно просто использовать моки в Elixir! Точная методология может отличаться от той, с которой вы знакомы в других языках, но конечная цель та же: моки могут имитировать результат вывода функций и таким образом, вы можете использовать все возможные пути выполнения в вашем коде.

Прежде чем мы перейдем к более сложным вариантам использования, давайте поговорим о некоторых методах, которые могут помочь нам сделать наш код более тестируемым. Одна из простых тактик заключается в передаче модуля в аргументы функции, а не в жестком кодировании модуля внутри функции.

Например, если бы мы жестко запрограммировали HTTP-клиент внутри функции:

def get_username(username) do
  HTTPoison.get("https://elixirschool.com/users/#{username}")
end

Мы могли бы вместо этого передать HTTP-клиентский модуль в качестве аргумента, подобного этому:

def get_username(username, http_client) do
  http_client.get("https://elixirschool.com/users/#{username}")
end

Или мы могли бы использовать функцию apply/3 для достижения той же цели:

def get_username(username, http_client) do
  apply(http_client, :get, ["https://elixirschool.com/users/#{username}"])
end

Передача модуля в качестве аргумента помогает разделить concerns и если мы не слишком придирчивы к объектно-ориентированном многословии в определении, мы могли бы признать этот способ как разновидность Внедрения зависимости. Чтобы протестировать get_username/2 метод, вам нужно будет только передать модуль у которого функция get возвращает значение, необходимое для ваших утверждений в тестах.

Эта конструкция очень проста, поэтому она полезна только тогда, когда функция публичная (а не похоронена где-то глубоко внутри приватной функции).

Более гибкая тактика зависит от конфигурации приложения. Возможно, вы даже не осознавали этого, но приложение Elixir сохраняет состояние в своей конфигурации. Вместо того, чтобы жестко кодировать модуль или передавать его в качестве аргумента, вы можете прочитать его из конфигурации приложения.

def get_username(username) do
  http_client().get("https://elixirschool.com/users/#{username}")
end

defp http_client do
  Application.get_env(:my_app, :http_client)
end

Затем в вашем конфигурационном файле:

config :my_app, :http_client, HTTPoison

Эта конструкция и ее зависимость от конфигурации приложения составляют основу всего, что следует далее.

Если вы склонны к чрезмерным размышлениям, то да, вы могли бы опустить функцию http_client/0 и вызвать Application.get_env/2 напрямую, и да, вы также могли бы предоставить третий аргумент по умолчанию для Application.get_env/3 и добиться того же результата.

Использование конфигурации приложения позволяет нам иметь конкретные реализации модуля для каждой среды; вы можете ссылаться на sandbox модуль для среды dev, в то время как среда test может использовать модуль из памяти.

Однако наличие только одного фиксированного модуля для каждой среды может быть недостаточно гибким: в зависимости от того, как используется ваша функция, вам может потребоваться возвращать разные ответы, чтобы протестировать все возможные пути выполнения. Чего большинство людей не знают, так это того, что вы можете изменять конфигурацию приложения во время выполнения! Давайте взглянем на Application.put_env/4.

Представьте, что ваше приложение должно действовать по-разному в зависимости от того, был ли HTTP-запрос успешным. Мы могли бы создать несколько модулей, каждый с функцией get/1. Один модуль мог бы возвращать кортеж :ok, другой мог бы возвращать кортеж :error. Тогда мы могли бы использовать Application.put_env/4 для настройки конфигурации перед вызовом нашей функции get_username/1. Наш тестовый модуль мог бы выглядеть примерно так:

# Не делай этого!
defmodule MyAppTest do
  use ExUnit.Case

  setup do
    http_client = Application.get_env(:my_app, :http_client)
    on_exit(
      fn ->
        Application.put_env(:my_app, :http_client, http_client)
      end
    )
  end

  test ":ok on 200" do
    Application.put_env(:my_app, :http_client, HTTP200Mock)
    assert {:ok, _} = MyModule.get_username("twinkie")
  end

  test ":error on 404" do
    Application.put_env(:my_app, :http_client, HTTP404Mock)
    assert {:error, _} = MyModule.get_username("does-not-exist")
  end
end

Предполагается, что вы где-то создали необходимые модули (HTTP200Mock и HTTP404Mock). Мы добавили функцию обратного вызова on_exit в блок setup, чтобы гарантировать, что фикстура :http_client возвращается в предыдущее состояние после каждого теста.

Однако схема, подобная приведенной выше, обычно является НЕ тем, чему вам следует следовать! Причины этого могут быть не сразу очевидны.

Прежде всего, никто не гарантирует, что модули, которые мы определяем для нашего приложения :http_client, могут делать то, что нам необходимо: здесь нет принудительного исполнения контракта, который требует, чтобы модули имели функцию get/1.

Во-вторых, тесты, подобные приведенному выше, нельзя безопасно запускать асинхронно. Поскольку состояние приложения является общим для всего приложения, вполне возможно, что при переопределении :http_client в одном тесте какой-либо другой тест (выполняемый одновременно) ожидает другой результат. Возможно, вы сталкивались с подобными проблемами при выполнении тестов. За частую тесты проходят успешно, но иногда по необъяснимым причинам терпит неудачу. Осторожно!

В-третьих, такой подход может привести к путанице, потому что в конечном итоге вы можете получить кучу мок модулей, запихнутых куда-нибудь в ваше приложение. Фу.

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

Mox : решение всех проблем

Основной пакет для работы с моками в Elixir - это Mox, автором которой является сам Хосе Валим, и она решает все проблемы, изложенные выше.

Помните: в качестве предварительного условия наш код должен обратиться к конфигурации приложения, чтобы получить его настроенный модуль:

def get_username(username) do
  http_client().get("https://elixirschool.com/users/#{username}")
end

defp http_client do
  Application.get_env(:my_app, :http_client)
end

Затем вы можете подключить mox в свои зависимости:

# mix.exs
defp deps do
  [
    # ...
    {:mox, "~> 0.5.2", only: :test}
  ]
end

Установите его с помощью mix deps.get.

Далее, измените свой test_helper.exs, чтобы он выполнял 2 действия:

  1. он должен определять один или несколько моков
  2. он должен установить конфигурацию приложения с моком
# test_helper.exs
ExUnit.start()

# 1. определение динамического мока
Mox.defmock(HTTPoison.BaseMock, for: HTTPoison.Base)
# ... etc...

# 2. Переопределите параметры конфигурации (аналогично их добавлению в файл config/test.exs).
Application.put_env(:my_app, :http_client, HTTPoison.BaseMock)
# ... etc...

Пара важных моментов, на которые следует обратить внимание Mox.defmock: название слева произвольное. Названия модулей в Elixir – это просто атомы - вам не нужно нигде создавать модуль, все, что вы делаете, это “резервируете” имя для макетного модуля. За кулисами Mox на лету создаст модуль с таким названием внутри BEAM.

Вторая сложность заключается в том, что модуль, на который ссылается for:, должен иметь поведение: он должен определять обратные вызовы. Mox использует интроспекцию в этом модуле и вы можете определять мок функции только тогда, когда @callback определено. Именно так Mox обеспечивает выполнение контракта. Иногда бывает сложно найти поведенческий модуль: например HTTPoison полагается на HTTPoison.Base, но вы можете не знать этого, пока не просмотрите его исходный код. Если вы пытаетесь создать мок для пакета третьей стороны, вы можете обнаружить, что никакого поведения не существует! В этих случаях вам может потребоваться определить свое собственное behaviour и callbacks, чтобы удовлетворить потребность в контракте.

Это поднимает важный момент: вы можете захотеть использовать уровень абстракции (он же Косвенное обращение), чтобы ваше приложение не зависело от стороннего пакета напрямую а вместо этого вы использовали бы свой собственный модуль, который в свою очередь, использует пакет. Для хорошо продуманного приложения важно определить правильные “границы”, но механика mocks не меняется, так что не позволяйте сбить вас с толку.

Наконец, в ваших тестовых модулях вы можете использовать свои моки, импортировав Mox вызвав :verify_on_exit! функцию. Затем вы можете свободно определять возвращаемые значения в своих мок модулях, используя один или несколько вызовов функции expect:

defmodule MyAppTest do
  use ExUnit.Case, async: true
  # 1. Import Mox
  import Mox
  # 2. setup fixtures
  setup :verify_on_exit!

  test ":ok on 200" do
    expect(HTTPoison.BaseMock, :get, fn _ -> {:ok, "What a guy!"} end)

    assert {:ok, _} = MyModule.get_username("twinkie")
  end

  test ":error on 404" do
    expect(HTTPoison.BaseMock, :get, fn _ -> {:error, "Sorry!"} end)
    assert {:error, _} = MyModule.get_username("does-not-exist")
  end
end

Для каждого теста мы ссылаемся на один и тот же мок модуль (HTTPoison.BaseMock в этом примере) и используем expect функцию для определения возвращаемых значений для каждой вызываемой функции.

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

Добро пожаловать на mocks в Elixir!

Caught a mistake or want to contribute to the lesson? Edit this lesson on GitHub!