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 действия:
- он должен определять один или несколько моков
- он должен установить конфигурацию приложения с моком
# 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!