Pull to refresh

Моки и явные контракты

Reading time 8 min
Views 54K
Original author: José Valim

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


Ниже представлен вольный перевод статьи, в которой José Valim — создатель языка Elixir — высказал своё мнение на проблему использования моков, с которым я полностью согласен.




Несколько дней назад я поделился своими мыслями по поводу моков в Twitter:



Мок — полезный инструмент в тестировании, но имеющиеся тестовые библиотеки и фреймворки зачастую приводят к злоупотреблению этим инструментом. Ниже мы рассмотрим лучший способ использования моков.


Что такое мок?


Воспользуемся определением из англоязычной википедии: мок — настраиваемый объект, который имитирует поведение реального объекта. Я сделаю акцент на этом позже, но для меня мок — это всегда существительное, а не глагол [для наглядности, глагол mock везде будет переводиться как "замокать" — прим. перев.].


На примере внешнего API


Давайте рассмотрим стандартный пример из реальной жизни: внешнее API.


Представьте, что вы хотите использовать Twitter API в веб-приложении на фреймворке Phoenix или Rails. В приложение приходит запрос, который перенаправляется в контроллер, который, в свою очередь, делает запрос к внешнему API. Вызов внешнего API происходит прямо в контроллере:


defmodule MyApp.MyController do
    def show(conn, %{"username" => username}) do
        # ...
        MyApp.TwitterClient.get_username(username)
        # ...
    end
end

Стандартным подходом при тестирования такого кода будет замокать (опасно! замокать в данном случае является глаголом!) HTTPClient, которым пользуется MyApp.TwitterClien:


mock(HTTPClient, :get, to_return: %{..., "username" => "josevalim", ...})

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


Не так быстро. Основная проблема при моке HTTPClient заключается в создании сильной внешней зависимости [англ. coupling везде переведена как "зависимость" — прим. перев.] к конкретному HTTPClient. Например, если вы решите использовать новый более быстрый HTTP-клиент, не изменяя поведение приложения, большая часть ваших интеграционных тестов упадет, потому что все они зависят от конкретного замоканного HTTPClient. Другими словами, изменение реализации без изменения поведения системы все равно приводит к падению тестов. Это плохой знак.


Кроме того, так как мок, показанный выше, изменяет модуль глобально, вы больше не сможете запустить эти тесты в Elixir параллельно.


Решение


Вместо того, чтобы мокать HTTPClient, мы можем заменить MyApp.TwitterClient чем-нибудь другим во время тестов. Давайте посмотрим, как решение может выглядеть на Elixir.


В Elixir все приложения имеют конфигурационные файлы и механизм для их чтения. Используем этот механизм, чтобы настроить клиент Twitter'a для различных окружений. Код контроллера теперь будет выглядеть следующим образом:


defmodule MyApp.MyController do
    @twitter_api Application.get_env(:my_app, :twitter_api)

    def show(conn, %{"username" => username}) do
        # ...
        @twitter_api.get_username(username)
        # ...
    end
end

Соответствующие настройки для различных окружений:


# config/dev.exs
config :my_app, :twitter_api, MyApp.Twitter.Sandbox

# config/test.exs
config :my_app, :twitter_api, MyApp.Twitter.InMemory

# config/prod.exs
config :my_app, :twitter_api, MyApp.Twitter.HTTPClient

Сейчас мы можем выбрать лучшую стратегию получения данных из Twitter для каждого из окружений. Sandbox может быть полезен, если Twitter предоставляет какой-нибудь sandbox для разработки. Наша замоканная версия HTTPClient позволяла избежать реальных HTTP-запросов. Реализация этой же функциональности в данном случае:


defmodule MyApp.Twitter.InMemory do
    def get_username("josevalim") do
        %MyApp.Twitter.User{
            username: "josevalim"
        }
    end
end

Код получился простым и чистым, а сильной внешней зависимости от HTTPClient больше нет. MyApp.Twitter.InMemory является моком, то есть существительным, и для его создания вам не нужны никакие библиотеки!


Необходимость явных контрактов


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


Мы уже имеем три реализации Twitter API и лучше сделать их контракты явными. В Elixir описать явный контракт можно с помощью behaviour:


defmodule MyApp.Twitter do
    @doc "..."
    @callback get_username(username :: String.t) :: %MyApp.Twitter.User{}

    @doc "..."
    @callback followers_for(username :: String.t) :: [%MyApp.Twitter.User{}]
end

Теперь добавьте @behaviour MyApp.Twitter в каждый модуль, который реализует этот контракт, и Elixir поможет вам создать ожидаемый API.


В Elixir мы полагаемся на такие behaviours постоянно: когда используем Plug, когда работаем с базой данных в Ecto, когда тестируем Phoenix channels и так далее.


Тестирование границ


Сначала, когда явные контракты отсутствовали, границы приложения выглядели так:


[MyApp] -> [HTTPClient] -> [Twitter API]


Поэтому изменение HTTPClient могло приводить к падению интеграционных тестов. Сейчас приложение зависит от контракта и только одна реализация этого контракта работает с HTTP:


[MyApp] -> [MyApp.Twitter (contract)]


[MyApp.Twitter.HTTP (contract impl)] -> [HTTPClient] -> [Twitter API]


Тесты такого приложения изолированы от HTTPClient и от Twitter API. Но как нам протестировать MyApp.Twitter.HTTP?


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


Лично я бы протестировал MyApp.Twitter.HTTP на реальном Twitter API, запуская эти тесты по-необходимости во время разработки и каждый раз при сборке проекта. Система тегов в ExUnit — библиотеке для тестирования в Elixir — реализует такое поведение:


defmodule MyApp.Twitter.HTTPTest do
    use ExUnit.Case, async: true

    # Эти тесты будут работать с Twitter API
    @moduletag :twitter_api

    # ...
end

Исключим тесты с Twitter API:


ExUnit.configure exclude: [:twitter_api]

При необходимости включим их в общий тестовый прогон:


mix test --include twitter_api


Также можно запустить их отдельно:


mix test --only twitter_api


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


  1. Изменение в HTTPClient приводит только к падению тестов на MyApp.Twitter.HTTP
  2. Вы не мокаете (осторожно! мок в данном случае является глаголом!) HTTPClient. Вместо этого, вы передаете его как зависимость через файл конфигурации, подобно тому, как мы делали для Twitter API
  3. Вам все еще нужен способ протестировать работу вашего клиента до выкатки в production.

Вместо создания мока HTTPClient можно поднять dummy-сервер, который будет эмулировать Twitter API. bypass — один из проектов, который может в этом помочь. Все возможные варианты вы должны обсудить со своей командой.


Примечания


Я бы хотел закончить эту статью разбором нескольких общих проблем, которые всплывают практически в каждом обсуждении моков.


Создание "тестируемого" кода


Цитата из elixir-talk mailing list:


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

Я бы сказал, что речь идет не о создании "тестируемого" кода, а об улучшении дизайна [от англ. design of your code — прим. перев.].


Тест — это пользователь вашего API, как и любой другой код, который вы пишите. Одна из идей TDD заключается в том, что тесты — это код и ничем не отличаются от кода. Если вы говорите: "Я не хочу делать мой код тестируемым", это означает "Я не хочу уменьшать зависимость между компонентами" или "Я не хочу думать о контракте (интерфейсе) этих компонентов".


Нет ничего плохого в нежелании уменьшать зависимость между компонентами. Например, если речь идет о модуле работы с URI [имеется ввиду модуль URI для Elixir — прим. перев.]. Но если мы говорим о чем-то таком же сложном, как внешнее API, определение явного контракта и наличие возможности заменять реализацию этого контракта сделает ваш код удобным и простым в сопровождении.


Кроме того, оверхэд минимален, так как конфигурация Elixir-приложения хранится в ETS, а значит вычитывается прямо из памяти.


Локальные моки


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


defmodule MyModule do
    def my_function do
        # ...
        SomeDependency.heavy_work(arg1, arg2)
        # ...
    end
end

Можно избавиться от зависимости, передав её как аргумент. В данном случае будет достаточно передачи анонимной функции:


defmodule MyModule do
    def my_function(heavy_work \\ &SomeDependency.heavy_work/2) do
        # ...
        heavy_work.(arg1, arg2)
        # ...
    end
end

Тест будет выглядеть следующим образом:


test "my function performs heavy work" do
    # Симулируем долгое вычисление с помощью отправки сообщения тесту
    heavy_work = fn(_, _) -> 
        send(self(), :heavy_work)
    end

    MyModule.my_function(heavy_work)

    assert_received :heavy_work
end

Или, как было описано ранее, можно определить контракт и передать модуль целиком:


defmodule MyModule do
    def my_function(dependency \\ SomeDependency)
        # ...
        dependency.heavy_work(arg1, arg2)
        # ...
    end
end

Изменим тест:


test "my function performs heavy work" do
  # Симулируем долгое вычисление с помощью отправки сообщения тесту
  defmodule TestDependency do
    def heavy_work(_arg1, _arg2) do
      send self(), :heavy_work
    end
  end

  MyModule.my_function(TestDependency)

  assert_received :heavy_work
end

Вы также можете представить зависимость в виде data structure и определить контракт с помощью protocol.


Передать зависимость как аргумент намного проще, поэтому, если возможно, такой способ должен быть предпочтительнее использования конфигурационного файла и Application.get_env/3.


Мок — это существительное


Лучше думать о моках как о существительных. Вместо того, чтобы мокать API (мокать — глагол), нужно создать мок (мок — существительное), который реализует необходимый API.


Большинство проблем от использования моков возникают, когда они используются как глаголы. Если вы мокаете что-то, вы изменяете уже существующие объекты, и зачастую эти изменения являются глобальными. Например, когда мы мокаем модуль SomeDependency, он изменится глобально:


mock(SomeDependency, :heavy_work, to_return: true)

При использовании мока как существительного, вам необходимо создать что-то новое, и, естественно, это не может быть уже существующий модуль SomeDependency. Правило "мок — это существительное, а не глагол" помогает находить "плохие" моки. Но ваш опыт может отличаться от моего.


Библиотеки для создания моков


После прочитанного у вас может возникнуть вопрос: "Нужно ли отказываться от библиотек для создания моков?"


Все зависит от ситуации. Если библиотека подталкивает вас на подмену глобальных объектов (или на использование моков в качестве глаголов), изменение статических методов в объектно-ориентированном или замену модулей в функциональном программировании, то есть на нарушение описанных выше правил создания моков, то вам лучше отказаться от неё.


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


Заключение


Одна из задач тестирования системы — нахождение правильных контрактов и границ между компонентами. Использование моков только в случае наличия явного контракта позволит вам:


  1. Защититься от засилья моков, так как контракты будут создаваться только для необходимых частей системы. Как было упомянуто выше, вряд ли вы захотите прятать взаимодействие со стандартными модулями URI и Enum за контрактом.
  2. Упростить поддержку компонентов. При добавлении новой функциональности к зависимости, вам нужно обновить контракт (добавить новый @callback в Elixir). Бесконечный рост @callback укажет на зависимость, которая берет на себя слишком много ответственности, и вы сможете раньше расправиться с проблемой.
  3. Сделать вашу систему пригодной для тестирования, потому что взаимодействие между сложными компонентами будет изолировано.

Явные контракты позволяют увидеть сложность зависимостей в вашем приложении. Сложность присутствует в каждом приложении, поэтому всегда старайтесь делать её настолько явной, насколько это возможно.

Tags:
Hubs:
+9
Comments 3
Comments Comments 3

Articles