Pull to refresh

Еще пара слов о моках

Reading time6 min
Views1.3K

В последнее время я поучаствовал в нескольких дискуссиях, которые выявили то, о чем я и так давно догадывался: очень многие программисты не понимают, зачем в тестировании нужны моки. Если ваш ответ — «чтобы не ходить из тестов в сторонние сервисы», или «чтобы не разворачивать весь мир для одного хиленького юнит-теста» — текст ниже может оказаться вам полезен.

Разумеется, моки помогают избавиться от необходимости вызывать сторонние 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

Удачного мокоприкладства!

Tags:
Hubs:
Total votes 6: ↑5 and ↓1+9
Comments5

Articles