Многие программисты при выборе между интеграционным и юнит-тестом отдают предпочтение юнит-тесту (или, иными словами, модульному тесту). Некоторые считают интеграционные тесты антипаттерном, некоторые просто следуют модным тенденциям. Но давайте посмотрим, к чему это приводит. Для реализации юнит-теста mock-объекты навешиваются не только на внешние сервисы и хранилища данных, но и на классы, реализованные непосредственно внутри программы. При этом, если мокируемый класс используется в нескольких других классах, то и mock-объект будет содержаться в тестах на несколько классов. А поскольку тестируемое поведение принято задавать внутри теста (смотри given-when-then, arrange-act-assert, test builder), то поведение моки каждый раз заново задаётся в каждом тесте, и нарушается принцип DRY (хотя дублирования кода может и не быть). Кроме того, поведение класса декларируется в mock-объекте, но сама эта декларация не проверяется, поэтому со временем задекларированное в моке поведение может устареть и начать отличаться от реального поведения мокируемого класса. Это вызывает целый ряд сложностей:
1)Во-первых, при изменении функционала сложно вообще вспомнить, что помимо класса и тестов на него нужно изменить ещё и моки этого класса. Давайте рассмотрим цикл разработки в рамках TDD: «созданиеизменение тестов на функционал -> созданиеизменение функционала -> рефакторинг». Mock-объекты являются декларированием поведения класса и не имеют отношения ни к одной из этих трёх категорий (не являются тестами на функционал, несмотря на то, что в тестах используются, и уж тем более не являются самим функционалом). Таким образом, изменение mock-объектов классов, реализованных внутри программы, не укладывается в концепцию TDD.
2)Во-вторых, сложно найти все места мокирования этого класса. Я не встречал ни одного инструмента для этого. Тут можно или написать свой велосипед, или смотреть все места использования этого класса и отбирать те, где создаются моки. Но при неавтоматизированном поиске можно и ошибиться, проглядеть что-нибудь. Тут у вас, наверное возник вопрос: если проблема столь фундаментальна, как описывает автор, неужели никому не пришло в голову реализовать инструменты, упрощающие её решение? У меня есть гипотеза на этот счёт. Несколько лет назад я начал писать библиотеку, которая должна была собирать mock-объект так же, как IOC-контейнер собирает обычный класс, и автоматически создавать и прогонять тесты на поведение, описываемое в моках. Но затем я отказался от этой идеи, потому что нашёл более элегантное решение проблемы моков: просто не создавать эту проблему. Вероятно, по схожей причине специализированный инструмент для поиска моков конкретного класса или не реализован, или малоизвестен.
3)В-третьих, мест мокирования класса может быть много, и изменение их всех — рутинное занятие. Если программист вынужден делать рутину, которую невозможно автоматизировать, то это явный признак того, что с инструментами, архитектурой или рабочими процессами что-то не в порядке.
Надеюсь, суть проблемы ясна. Далее я опишу пути решения этой проблемы и расскажу, почему, с моей точки зрения, интеграционные тесты предпочтительнее юнит-тестов.
В качестве решения проблемы я предлагаю применять мокирование только для внешних сервисов и хранилищ данных, а в остальных случаях использовать реальные классы, т.е. писать интеграционные тесты вместо модульных. Некоторые программисты скептически относятся к интеграционным тестам, и эта идея им не понравилась бы.
Давайте рассмотрим, какие аргументы приводят противники интеграционных тестов.
Высказывание 1. Интеграционные тесты в меньшей степени помогают в поиске ошибок, нежели юнит-тесты
Доказательство:
Давайте представим, что в каком-то классе, использующемся повсеместно, была допущена ошибка. После этого покраснели тесты непосредственно самого класса, а также все интеграционные тесты, в которых использовался этот класс. В итоге половина тестов в проекте красная. Как понять, в чём причина покраснения тестов? С какого теста начать? А вот если бы вместо класса использовался его mock-объект, то покраснели бы только тесты этого класса.
Опровержение:
Давайте вспомним рабочий процесс в рамках TDD: «красные» тесты, сигнализирующие об ошибке -> созданиеизменение функционала -> «зелёные» тесты. Соответственно, при изменении функционала, программист сначала изменяет тесты так, чтобы они тестировали новый функционал. Поскольку в коде ещё находится устаревший функционал, то тесты не проходят. Затем программист правит код функционала, и тесты проходят. Если программист поработал с классами, но не с их тестами, то он действовал не в рамках TDD.
Но даже если программист изменил код, но не изменил тесты и не проверил их прохождение, то падение тестов может отследить сервер непрерывной интеграции, который автоматически прогоняет тесты при каждом пуше в систему контроля версий. Автор изменений увидит сообщение о падении тестов, по горячим следам вспомнит какие классы он правил, и в первую очередь начнёт разбираться с тестами именно этих классов. Если программист непреднамеренно внёс баг в некоторый класс, а потом исправил его, то позеленеют не только тесты этого класса, но и все тесты, в которых этот класс использовался. Но что, если не позеленеют? Тогда это сигнал о том, что изменения в классе привели к изменению поведения других классов, где этот класс использовался, и теперь или в этих классах появились ошибки, или их тесты отклонились от логики приложения.
Возможен и другой случай. Если по какой-то причине класс, в котором допустили ошибку, не был хорошо покрыт тестами, то юнит-тесты на моках вообще не выявили бы проблему. Интеграционные же тесты хотя бы просигнализируют о проблеме, хотя для выявления проблемного класса и придётся прибегнуть к старой доброй трассировке.
Подводя итог: если вы следуете TDD, то покраснение тестов тех классов, которые вы не изменяли, является преимуществом, потому что сигнализирует о проблемах. Если вы не следуете TDD, но используете непрерывную интеграцию, то покраснение «лишних» тестов для вас не такая уж и проблема. Если вы не следуете TDD и не выполняете регулярную прогонку тестов, то для вас актуальна проблема выявления соответствия «упавший тест — проблемный класс». В таком случае лучше решать проблему дублирования знания в моках и отсутствия тестов на поведение, декларируемое в моках, не при помощи использования интеграционных тестов вместо модульных, а при помощи других средств (о них поговорим чуть позже).
Высказывание 2. Интеграционные тесты в меньшей степени помогают в проектировании, нежели модульные
Доказательство:
Модульное тестирование, в отличие от интеграционного, вынуждает программистов инжектировать зависимости через конструктор или свойства. А если использовать интеграционное тестирование вместо модульного, то джуниор может зависимости прямо в коде класса инстанцировать. А мне архитектурные записки писать и коды-ревью проводить очень некогда. Да и поручить некому. И не хочется.
Опровержение:
На самом деле, не только модульное тестирование способно принудить программиста к инжектированию зависимостей. С этим отлично справляется IOC-container. На самом деле, если вы инжектируете зависимости, то вы наверняка используете IOC-container. Можно конечно и самому написать фабрику создания самого главного класса, в котором находится точка входа. Но IOC-container решает многие типовые проблемы и упрощает жизнь. Например, вы можете одной строчкой кода сделать какой-либо класс синглтоном, не вникая в подводные камни реализации синглтона. Так что, если вы инжектируете зависимости, но не используете IOC-container, то я рекомендую начать это делать.
В общем, если вы используете модульное тестирование, то вы почти наверняка используете IOC-container. Если вы используете IOC-container, то он побуждает программиста инжектировать зависимости. Можно конечно создать объект не используя IOC-container, но точно также можно создать класс, не снабдив его модульным тестом. Так что, я не вижу у модульных тестов весомых преимуществ в плане побуждения к исполнению принципа Inversion of control.
К тому же, можно не принуждать программистов поступать нужным вам образом за счёт ограничений в архитектуре, а просто объяснить преимущества инжектирования зависимостей и использования IOC-контейнера. Принуждение силой, как и любое насилие, может вызвать встречное сопротивление.
Высказывание 3. Чтобы покрыть тестами один и тот же функционал, интеграционных тестов потребуется гораздо больше, чем модульных
Доказательство:
Автор статьи с громким названием «Интеграционные тесты — удел жуликов» пишет о том, что он со всей страстью ненавидит интеграционные тесты и считает их вирусом, приносящим бесконечную боль и страдание. Свои мысли он обосновывает так:
Вы пишите интеграционные тесты, потому что не способны написать совершенные модульные тесты. Вам знакома эта проблема: все ваши тесты прошли, но в программе всё равно обнаруживается дефект. Вы решаете написать интеграционный тест, чтобы убедиться, что весь путь исполнения программы работает как надо. И всё вроде бы идёт нормально, пока вы не подумаете: «А давайте использовать интеграционные тесты везде». Плохая идея! Количество возможных путей исполнения программы нелинейно зависит от размера программы. Для покрытия тестами веб-приложения с 20ю страницами вам потребуется как минимум 10 000 тестов. Возможно миллион. При написании 50 тестов в неделю, вы напишите только 2 500 тестов в год, а это 2,5% процента от нужной суммы. И после этого вы удивляетесь, почему тратите 70% вашего времени, отвечая на звонки пользователей?! Интеграционные тесты — пустая трата времени. Они должны остаться в прошлом.
Опровержение:
Автор той статьи даёт следующее определение интеграционного теста:
I use the term integrated test to mean any test whose result (pass or fail) depends on the correctness of the implementation of more than one piece of non-trivial behavior.
Интеграционный тест — такой тест, результат прохождения которого зависит от правильности реализации более чем одного кусочка нетривиальной логики (метода).
Как видите, в этом определении нет ни слова о том, что интеграционные тесты можно писать только на главный класс, в котором находится точка входа, но автор вышеупомянутой статьи в своих рассуждениях неявно опирается именно на это условие.
Согласно TDD, тесты предназначены для проверки функционала (feature), а не путей исполнения программы. Следуйте TDD, и вы не столкнётесь с теми проблемами, о которых говорил этот автор. Просто пишите интеграционные тесты также, как вы писали бы модульные тесты, но не мокируйте классы, реализованные в вашей программе, и вы не столкнётесь с проблемой экспоненциального увеличения количества тестов.
Высказывание 4. Интеграционные тесты выполняются дольше модульных
С этим, к сожалению, не поспоришь — интеграционные тесты почти всегда выполняются дольше модульных. Создание моки, конечно, не бесплатное и занимает какое-то время, но логика приложения, как правило, выполняется дольше. Гипотетически, вполне возможна ситуация, что тесты выполняются неудовлетворительно долго, а оптимизировать тестируемую логику в ближайшем будущем вы не собираетесь. И вполне логичным решением может стать оптимизация тестов. Например, использование моков.
Способы борьбы с дублированием и устареванием знания в моках
Первый способ, как я уже говорил — использовать моки только для декларирования поведения внешних сервисов и хранилищ данных.
Второй способ — автоматизированно проверять актуальность задекларированного в моке поведения. Например, вы можете автоматически создавать и прогонять соответствующий тест. Но тогда нужно учесть, что мокируемый класс может иметь свои зависимости, часть из которых может быть внешними сервисами. Для быстродействия, можно сначала тестировать уникальное поведение (указанное в моках) классов самого нижнего слоя, затем поведение классов, которые используют предыдущие классы, и так далее. Тогда, если какое-то одинаковое поведение декларируется в моках в нескольких местах, то его можно будет проверить только один раз.
Можно для каждого уникального случая мокирования вручную написать тест и каким-то образом задать соответствие между мокой и тестом на неё, и поручить программистам вручную поддерживать это соответствие при изменении функционала.
Можно просто поручить программистам вручную поддерживать актуальность мок-объектов. Но тогда придётся немного изменить рабочий процесс, отойдя от классического TDD, заменить «Изменение тестов на функционал -> Изменение функционала -> ...» на «Изменение тестов на функционал -> Изменение деклараций этого поведения (в моках) -> Изменение функционала -> ...».
Для устранения проблемы дублирования кода при мокировании можно поместить все моки на один класс в отдельное хранилище. Это упростит этап «Изменение деклараций поведения в моках», но может уменьшить читаемость юнит-теста — тут решайте сами, исходя из собственных приоритетов.
Заключение
Мартин Фаулер давно заметил формирование двух разных школ TDD — классической школы и мокистов:
Now I'm at the point where I can explore the second dichotomy: that between classical and mockist TDD. The big issue here is when to use a mock (or other double).
The classical TDD style is to use real objects if possible and a double if it's awkward to use the real thing. So a classical TDDer would use a real warehouse and a double for the mail service. The kind of double doesn't really matter that much.
A mockist TDD practitioner, however, will always use a mock for any object with interesting behavior. In this case for both the warehouse and the mail service.
Обе эти школы имеют свои преимущества и недостатки. Лично я считаю, что недостатки классического TDD более приемлемые и решаемые, нежели недостатки мокисткого TDD. Ну, а кто-то может считать наоборот — он может прекрасно справляться с последствиями применения мокисткого TDD и не считать приемлемыми проблемы, возникающие при классическом TDD. Почему бы и нет? Все люди разные, и каждый имеет право на свой стиль. Я лишь привёл доводы, почему лично мне классика нравится больше, но окончательный выбор остаётся за вами.
Автор: FiresShadow