Магические приёмы тестирования приложений на Эликсире. Часть 1
Одной из болевых точек, которую можно нащупать при переходе на Эликсир, может стать тестирование. Тестирование — неотъемлемая часть разработки любого приложения. Тесты служат не только отличной «живой» документацией, но и своеобразной подушкой безопасности при рефакторинге кода, следовательно, тестирование приложений должно быть осуществлено надлежащим образом. В данной статье сотрудники компании Onfido рассказывают о трудностях, с которыми повстречались на этом тернистом пути, и как с ними справились.
Предупреждение: Всё, о чём говорится в статье, относится только к ExUnit
. Вполне возможно, многие из описанных далее проблем при использовании других тестовых фреймворков (например, espec
) вас не коснутся. Никто не настаивает на выборе ExUnit
, но крайне важно задуматься о проектных решениях, особенно если все используемые вами тестовые фреймворки совершенно различны.
DAMP вместо DRY!
Очень известная среди тестировщиков фраза, означающая, что читабельность тестов, обусловленная использованием в них описательных и выразительных фраз, важнее принципа «не повторяйся». На то есть две причины:
- Тесты — отличный источник живой документации. Они прекрасно передают намерения своего создателя и делают более очевидным назначение того или иного модуля или функции. Для этого код тестов должен быть по максимуму читабельным;
- Тесты не тестируются. А значит, имеет смысл сводить к минимуму логическую часть и делать их как можно проще.
Рассмотрим действие данного принципа на примере:
describe "when the address was already validated and the feature flag is enabled" do
setup :address_already_validated, :enabled_feature_flag
test "it runs the validation again" do
# [...]
end
end
describe "when the address was already validated and the feature flag is disabled" do
setup :address_already_validated, :disable_feature_flag
test "it does not run the validation again" do
# [...]
end
end
Итак, здесь приведены два блока describe
с похожими действиями после setup
: и в том и в другом вызывается функция address_already_validated
. Посмотрев на этот пример, у большинства разработчиков возникнет непреодолимое желание избавиться от повторяющегося кода и связать внешний контекст describe
с блоком setup
для функции address_already_validated
.
В простом примере такое решение может показаться достаточно безобидным, но чем крупнее приложение, тем больше будет заметна его непригодность. Главная цель: посмотрев на тест, без лишних движений понять, зачем он нужен и что он делает (если вы рубист, то можно с уверенностью сказать, что в первое время работы над новым проектом вы сталкивались с тем, что мозг просто разрывается на части после прочтения документации и попыток разобраться во всех, связанных с этим тестом контекстах).
Описательность укрепляет свои позиции и в ExUnit
, поскольку вложенные блоки describe
создавать нельзя (Жозе Валим логически обосновал этот запрет здесь). Такое проектное решение кажется непривлекательным, но в то же время оно является ярким примером того, что Эликсир направлен на обеспечение долгосрочной поддержки проекта, а следовательно, и высокой скорости разработки).
Разработка через тестирование: Детройт против Лондона
Думаю, большинство сложностей, с которыми можно столкнуться, можно объяснить различием подходов к тестированию.
Детройтская школа TDD, созданная в девяностые Кентом Беком и его командой, представляет классический подход, заключающийся в максимизации эффекта от регрессионного тестирования путём уменьшения использования «дублёров» (стабов и моков) в тестах. Но это неизбежно приводит к излишнему покрытию тестового кода (и всем связанным с этим проблемам). Кроме того, при выборе такого подхода страдает обратная связь при проектировании.
Основой лондонской школы TDD является уделение первоочередного внимания тестируемому объекту и изоляция всех его зависимостей. Последователи данной школы склонны мыслить в терминах модульного тестирования, потому что работа ведётся над отдельными единицами кода. Обратная связь при таком TDD выше, но процесс должен быть дополнен интеграционными тестами, которые помогут убедиться, что все части работают как единое целое.
Когда дело касается функциональных языков программирования, чаще всего можно встретить приверженцев детройтского метода, так как им присуща привычка писать код как можно чище (тем самым минимизируя побочные эффекты). Также для таких языков при проведении модульного тестирования характерен свободный запуск функций из других модулей.
Поскольку автор является последователем лондонского метода, то ему привычно использовать моки даже если вызываемая функция или модуль не имеют побочных эффектов. Такая схема обеспечивает груду страданий, и мешает конкурентно запускать тесты при наличии моков. А теперь пришла пора перейти к стратегиям тестирования приложений на Эликсире.
Магические приёмы тестирования
Сэнди Метц в своём выступлении 2013 года (рекомендуется посмотреть!) представила следующую опорную схему для создания тестов так, чтобы они не дублировали друг друга:
Входящий запрос (Incoming Query) и команда (Command) не требуют постановки моков, но мы всё равно рассмотрим парочку примеров для каждого случая. С исходящей командой (Outgoing Command) дела обстоят сложнее, здесь точно понадобятся заглушки.
Допустим, необходимо написать тесты для следующего модуля:
defmodule Demo.Validators.AddressValidator do
@callback validation_level() :: String.t
@callback set_validation_level(String.t) :: :ok
@callback validate(String.t) :: boolean
def validation_level() do
Application.get_env(:demo, :validation_level)
end
def set_validation_level(level) do
Application.put_env(:demo, :validation_level, level)
end
def validate(address) do
emit_event("address_validated")
address
|> String.length()
|> validate_length()
end
defp validate_length(address_length) when address_length < 32, do: true
defp validate_length(_address_length), do: false
defp emit_event(event_description), do: Demo.Events.EventEmitter.emit(event_description)
end
Это модуль валидации адреса, который всего-навсего проверяет, чтобы в заданном адресе было не более 32 символов. Также предположим, что функция validate
порождает событие (побочный эффект). Данный модуль также записывает в конфигурации и считывает оттуда параметр :validation_level
, который представлен в данном примере только в качестве входящей команды и который означает степень проводимой валидации (проверка каждого символа или только длины адреса).
Входящий запрос
Как показано на схеме выше, в данном случае нужно проверить корректность результата тестируемой функции. Просто запускаем функцию и проводим проверку (с помощью assert
) по заданным аргументам. Что может быть проще!
defmodule Demo.Validators.AddressValidatorTest do
use ExUnit.Case, async: true
alias Demo.Validators.AddressValidator, as: Subject
describe "when the address is fewer than 32 chars" do
test "it validates the address" do
assert Subject.validate("valid address") == true
end
end
describe "when the address is longer than 32 chars" do
test "it invalidates the address" do
assert Subject.validate("some very very very long address") == false
end
end
end
Входящая команда
В данном случае будем тестировать побочные эффекты от запуска команды, распространяющиеся на всё приложение.
defmodule Demo.Validators.AddressValidatorTest do
use ExUnit.Case, async: true
alias Demo.Validators.AddressValidator, as: Subject
test "it sets the validation level to the provided parameter" do
expected_result = "some_validation_level"
Subject.set_validation_level("some_validation_level")
result = Subject.validation_level()
assert result == expected_result
end
end
В этот раз нас интересует функция set_validation_level
. Запускаем функцию в тесте и определяем побочные эффекты от команды, запустив функцию validation_level
. Именно это действие представляет наибольший интерес в тестах такого рода. Обратите внимание, что так как сообщение является входящим, не стоит ожидать, что Application.config
получит put_env
с верными аргументами, ведь тогда детали реализации просочатся в тест . Этот случай тоже достаточно прост! Остался последний.
Исходящая команда
Здесь необходимо убедиться, что команда вызывается с правильными аргументами. Однако прежде чем перейти к самому тесту, необходимо изменить кое-что в коде уже готового приложения, а именно добавить зависимость (в данном случае Demo.Events.EventEmitter
). Для этого определим новый атрибут модуля:
@event_emitter Application.get_env(:demo, :event_emitter)
А затем используем его в функции, порождающей события:
defp emit_event(event_description), do: @event_emitter.emit(event_description)
Теперь в тестовом окружении будет доступен модуль-заглушка (в config/mix.exs
определяем :event_emitter
как Demo.Events.EventEmitterMock
). Сам модуль выглядит следующим образом:
defmodule Demo.Events.EventEmitterMock do
@callback emit(String.t) :: :ok
def emit("address_validated") do
send(self(), :address_validated_event_emitted)
:ok
end
end
Далее используем обмен сообщениями между процессами, чтобы убедиться, что побочный эффект появился. Процесс посылает сообщение самому себе (первый аргумент, переданный в send
), что поможет нам в будущем проверить его в тесте. Также проводим сопоставление с образцом по аргументу функции, которое проследит за тем, чтобы исходящая команда была вызвана с правильными аргументами.
Преимущество создания моков с явными контрактами — легкость в понимании теста и работе с контекстом. Недостаток же состоит в том, что логика тестов рассредоточена по разным файлам, что затрудняет её восприятие.
Код теста выглядит довольно просто:
defmodule Demo.Validators.AddressValidatorTest do
use ExUnit.Case, async: true
alias Demo.Validators.AddressValidator, as: Subject
test "it emits the :address_validated event" do
Subject.validate("test address")
assert_receive :address_validated_event_emitted
end
end
Как уже говорилось ранее, чтобы убедиться, что команда была вызвана, посмотрим на сообщения. Поэтому в тесте нужно просто проверить почтовый ящик текущего процесса и методом assert удостовериться, что получено нужное нам сообщение.
Стоит отметить, что эффективность данного подхода сводится к нулю, если тестируемый код порождает новый процесс, что достаточно частая история в Эликсире. К примеру, модуль Demo.Events.EventEmitter
мог бы находиться за пределами пула воркеров (poolboy
), и тогда отправка сообщений к self()
не сработала бы.
От модуля-заглушки к GenServer
Лучшее решение текущей проблемы — превратить модуль-заглушку в GenServer. Новый модуль будет таким:
defmodule Demo.Events.EventEmitterMock do
use GenServer
def start_link([]) do
GenServer.start_link(__MODULE__, [], [])
end
def init(state) do
{:ok, state}
end
def handle_call({:subscribe, pid}, _from, listeners) do
{:reply, :ok, [pid | listeners]}
end
def handle_call({:emit_event, "address_validated"}, _from, listeners) do
send_to_listeners(listeners, :address_validated_event_emitted)
{:reply, :ok, listeners}
end
defp send_to_listeners(listeners, message) do
for listener <- listeners do
send(listener, message)
end
end
end
Примечание: данный GenServer не содержит реализаций обёрток для handle_call
, потому что в этом примере GenServer вызывается напрямую (9 строчка в приведённом ниже коде).
Модуль-заглушка отслеживает процессы, которые «подписаны» на его события. Получая :emit_eventcall
, он передаёт сообщение всем подписанным на него слушателям. Это означает, что процесс теста должен быть подписан на GenServer. Собственно, вот что нужно изменить в коде теста …
setup :subscribe
test "it emits the :address_validated event" do
Subject.validate("test address")
assert_receive :address_validated_event_emitted
end
defp subscribe(context) do
:poolboy.transaction(:demo_pool, &GenServer.call(&1, {:subscribe, self()}), 5000)
context
end
… всего лишь определить setup
до запуска теста. Если бы мы использовали библиотеку poolboy
, то именно так нужно было бы «подписаться» на модуль-заглушку (при условии если :demo_pool
определён в config/test.exs
должным образом).
Заключение
В данной статье были освещены особенности подхода к тестированию приложений, написанных на Эликсире, отдельное внимание было уделено тестированию приложений с заглушками.
Во второй части статьи будут рассмотрены следующие недостатки представленных методов и пути их решения:
-
Рассредоточенность логики тестов по разным файлам, затрудняющая её понимание;
-
Интуитивное создание заглушек (не основанное на поведениях).