В последнее время я поучаствовал в нескольких дискуссиях, которые выявили то, о чем я и так давно догадывался: очень многие программисты не понимают, зачем в тестировании нужны моки. Если ваш ответ — «чтобы не ходить из тестов в сторонние сервисы», или «чтобы не разворачивать весь мир для одного хиленького юнит-теста» — текст ниже может оказаться вам полезен.
Разумеется, моки помогают избавиться от необходимости вызывать сторонние API, или разворачивать тестовую базу, чтобы протестировать функцию агрегации цен товаров в корзине, но нужны они не для этого. Все вышеперчисленное — лишь приятный побочный эффект. Или неприятный, как твердят противники моков, потому что внешний API может измениться, а суммирование данных из базы — поломаться на самом вызове базы. И оба сценария тоже надо бы протестировать.
Я настоятельно рекомендую ознакомиться с блистательным текстом Жозе Валима на эту тему; я не буду повторяться и предположу, что точка зрения автора самой изящной из виденных мной библиотеки моков — mox — вам известна. Текст ниже — лишь дополнения, основанные на моем личном опыте, немного многословные ради того, чтобы быть внятными для разработчиков из смежных цехов (Java, Go, Python, etc.)
Я, преимущественно, воюю с облыжными заявлениями типа такого: «Стабы и моки нарушают инкапсуляцию». Не нарушают, если разработчик им не велит.
Зачем нужны моки
Моки нужны в первую очередь для того, чтобы проверять контракты. В Java/.NET это интерфейсы. В Erlang/Elixir — behaviours. В питоне — публичный API классов.
В мире руби очень часто можно встретить такой код в тестах (пример из README для flexmock, но это не имеет значения; рассматривайте как псевдокод):
require 'flexmock/test_unit'
class TestDog < Test::Unit::TestCase
def test_dog_wags
tail_mock = flexmock(:wag => :happy)
assert_equal :happy, tail_mock.wag
end
end
Что мы тут натворили? — Каждый раз, когда вызывается метод wag
, мы спокойно возвращаем :happy
. Что нормально, но только пока оригинальная функция wag
чистая. Но руби — насквозь объектный, да еще и рельсовики любят кэшировать всё и вся в переменных инстанса. Поэтому на практике наш мок подменяет код типа такого:
def wag
if @breed != :toy_terrier
@mood = :happy
end
end
Как наш мок соотносится с кодом? — Да никак. Потому что в следующем вызове мы можем полагаться на то, что @mood
был переопределён, или мы могли оказаться в ветке тестирования поведения той-терьера (который — по моим наблюдениям, которые могут оказаться ошибочными, но это сейчас не важно — никогда ничему не рад). Возражение, что надо понимать, что и как тут мокать — не принимается, тесты не должны дублировать код, и уж тем более не должны создавать когнитивную нагрузку, сравнимую с написанием самого кода.
Так что там было про контракты? — Создатели всё той же flexmock
предусмотрели и это (пример оттуда же):
flexmock("temperature_controller").
should_receive(:read_temperature).
times(3).
and_return(10, 12, 14)
Тут ситуация уже не настолько критична: мы проверяем, что наше приложение вызывает нашу функцию ровно три раза и подменяем возвращаемые значения. Годится? — Да, но тоже только пока функция чистая. Если мы забудем в реализации обновить @current_temperature
, а сенсор присылает дельты — пиши пропало (оригинальная, сиречь — тестируемая — функция тут вернёт 10
(начальное значение), 2
(первая δ), 2
(вторая δ).
А теперь давайте представим, что мы тестируем параллельное выполнение в нескольких потоках (сенсоров много и наш контроллер получает обновления от всех них). Ну упс, чё. С такими моками далеко в этом тестировании не уедешь.
Disclaimer: я намеренно упрощаю примеры; я знаю, о возможности мокнуть метод — лямбдой, завести хранилище, из лямбд сохранять event log, а потом его анализировать. Но во-первых так никто, кроме упоротых гиков, не делает, а во-вторых — это превращает тестирование в искусство жонглирования мандаринами на узком мосту без перил.
Контракты в эликсире
Примеры ниже будут на эликсире, просто потому, что мне нравится этот язык, я могу писать на нем без необходимости проверять код в REPL, и я наизусть знаю код библиотек, с этим связанных (пришлось слать несколько PR с улучшениями и баг-фиксами). Концепция применима в любом языке. Просто подмените слово «behaviour» — словом «interface» — и всё. Даже в Хаскеле это всё вполне реализуемо, именно из-за того, что мы подменяем не абы какие куски кода (чему воспротивится система типов), а инстансы целиком.
Ключевым трюком, делающим всё вышеизложенное возможным — является Dependency Injection. Всё то, что вы хотите протестировать — не должно быть приколочено шиферными гвоздями к конкретной реализации. Есть сторонний сервис? — Никогда не пишите в коде приложения WeatherComAPIImpl.get(city)
. Пишите вместо этого: ExternalService.get(city, service \\ WeatherComAPIImpl), do: service.get(city)
. Тогда подменить сервис будет проще, а необходимость его подменить возникнет сразу же, как только вы начнете ваш код тестировать. Это касается не только каких-то внешних API, но практически всех подсистем вашего приложения: чем меньше связности (прямых вызовов одной подсистемы из другой) — тем легче ограничиться простыми юнит-тестами в большинстве случаев, и не разворачивать весь мир на домашнем компьютере (и даже в первой стадии CI). Интеграционные тесты — про другое. Из менее очевидных плюсов: вы сможете тестировать взаимодействие с частями системы, которые еще не написаны в соседней команде, а также каждая следующая компиляция проекта будет гораздо быстрее, из-за меньшего количества прямых зависимостей.
Итак, пусть у нас есть табло температуры, получающее показания от пяти сенсоров, и мы тестируем сенсор, который отправляет данные на табло каждую секунду (обновление данных реализовано железякой). Определим behaviour.
defmodule Board do
@spec on_update(Board.t(), :float) :: Board.t()
@callback on_delta(sensor, delta)
end
defmodule Sensor do
@spec update(Board.t(), :float) :: :ok
@callback update(board, delta)
end
Реализация может слать сообщение процессу, имплементирующему Board
в акторной модели, дергать метод напрямую в классическом ООП, публиковать сообщение в брокер типа Kafka
в любой парадигме, бить током операциониста, который введет значение в Board
напрямую c клавиатуры в тоталитарной стране. Это не имеет значения.
У нас, наверное, есть функция init_board/2
типа такой, как показано ниже — и реализация интерфейса:
def init_board(id, sensors, init_temp) when is_list(sensors) do
# можно еще проверить, что все сенсоры — имплементируют Sensor
%Board{id: id, sensors: sensors, temperature: init_temp}
end
@impl Board
def on_update(%Board{} = board, delta) do
%Board{board | temperature: board.temperature + delta}
end
Я намеренно опускаю много маловажных деталей реализации, чтобы сосредоточиться на главном. Проверки там, и прочее.
Теперь мы можем протестировать этот код с разных сторон. Сначала — определим мок для сенсора и запустим реализацию Board
с ним. Что надо будет проверить?
SensorMock.update/2
был вызван снаружи (железякой, или имитирующим ее кодом — без разницы) с конкретными параметрами: существующим и известным системе табло и правильной дельтой;хотелось бы еще проверить, что табло обновилось, и если это процесс, то у него есть стейт и мы всегда можем вызвать :sys.get_state/1, а если это ООП — скорее всего, мы сохранили это значение куда-нибудь, но даже если нет — не беда.
Не беда, потому что мы теперь можем вернуть настоящую реализацию сенсора на место — и мокнуть Board
. Или даже обоих одновременно. И проверить, что BoardMox.on_update/2
был вызван, с правильными параметрами, нужное число раз.
Более того, мы можем использовать мок только для одного сенсора из пяти (пятисот, пятидесяти тысяч), и удостовериться, что все работает так, как надо, когда сенсоров много.
Типичная реализация функции в моке
К сожалению, эта и заключительная главки относятся исключительно к эликсиру, потому что мне неизвестны подобные имплементации для других языков. Если вам известны — поделитесь, пожалуйста.
В эликсире обычно используется мок-функция, которая просто шлёт сообщение в вызвавший процесс, а там оно всё и проверяется. Вот типичный пример из моих тестов:
parent = self() # наш процесс
MyMoxImpl
|> allow(parent, fn -> GenServer.whereis(MyOtherImpl) end)
|> expect(:some_fun, 2, fn id, state, payload ->
parent
|> send({:on_some_fun, id, state, payload})
|> then(fn _ -> :ok end)
end)
assert_receive {:on_some_fun, MyOtherImpl, %{}, %{}}
Ожидание expect/4
— это, понятно, проверка, что функция была вызвана два раза с тремя аргументами — и мы их пересылаем сообщением в основной процесс, чтобы там проверить. А вот что такое allow/3
? Это важнейший механизм, позволяющий отслеживать и проверять контракты в высококонкурентной среде. Библиотека nimble_ownership позволяет чётко разграничить, каким процессам можно вызывать наш мок, а каким — нет. И если вызов будет совершен кем-то, кому мы не доверяем — тест упадет.
А в интеграционных тестах, или даже просто в процессе разработки — отключите мок, включите настоящую имплементацию, — и сходите на weather.com.
Дальше, наверное, нужно уже экспериментировать самостоятельно.
Убедил, чертяка; но у нас весь проект сильно связан
I’ve been there, как говорят наши заокеанские коллеги. Поэтому я и написал маленькую библиотечку, которая позволит легко вычленить behaviour из существующего кода (не обязательно своего, поэтому ваша хвалёная IDE тут не поможет) и нагенерировать весь недостающий бойлерплейт для тестов. https://hexdocs.pm/bex/Mix.Tasks.Bex.Generate.html
Удачного мокоприкладства!