July 15, 2023

Юнит тесты не нужны

Интро

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

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

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

И для затравки вот еще несколько тезисов из статьи которая несет бест практис в массы для юнит тестов.

3. Юнит тесты спасают от регрессии

4. Юнит тесты служат источником документации

5. Юнит тесты помогают при рефакторинге

Теперь давайте разбираться что же с каждым из этих тезисов не так.

Исторический экскурс

Но для начала вспомним знаменитую пирамиду тестов. Она имеет сотни форм и вариаций, но автор, Mike Cohn, назвал ее Agile Testing Pyramid (или test automation pyramid) и выглядит она следующим образом:

Взято без разрешения с https://www.mountaingoatsoftware.com/

Основной тезис автора - комплексные (читай UI, интеграционные и сервисные) тесты писать сложно, дорого и долго, а у нас тут аджайл вообще-то, нам нужно хоп-хоп и в продакшен уже вчера. Нам просто некогда писать тесты и скрупулезно проверять результаты нашей работы, мы продуктовый бэклог разгребаем. Штош. Во-первых, это наглая ложь. Во-вторых, я в другом городе, что ты мне сделаешь. За мат извени.

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

Прожарка юнит тестирования

Теперь вернемся к тезисам и попробуем понять уже что с ними не так.

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

В данном тезисе есть рациональное зерно - код нужно проверять, но причина проверки некорректная. Код нужно проверять не потому что я его написал и хочу обезопасить себя от самого же себя, но код нужно проверять на соответствие требованиям. Источник кода не я, а требования. Требования в виде ТЗ, сообщения в мессенджере, письма на почте. Корректность - это единственный критерий, который могут и должны проверять тесты. Юнит тесты ничего не знают о требованиях по своей природе. Юнит тесты оперируют слишком сильно изолированным контекстом, чтобы знать достаточно о том что они проверяют. Вы можете написать тесты отдельно на бизнес-логику, отдельно на веб слой, отдельно на слой хранения данных (хотя кто тестирует слой хранения данных, а?), но вы все равно не сможете гарантировать, что ваше приложение, при соединении все трех слоев, будет работать корректно.

Интеграционные тесты - могут и делают это. Причем с меньшим объемом кода и затратами на поддержку на дистанции. Далее я покажу это на примерах.


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

Это мой любимый аргумент. Как будто мы живем в мире где прочие виды тестов не делают того же самого. Юнит тесты даже не решают этой проблемы. Вы поменяли логику и теперь будете уповать на то что сотни изолированных друг от друга тестов на выдуманных данных будут проверять то что будет работать в связке на реальных данных. Тестировать на реальных данных - нельзя. Тестировать в связке это уже не юнит тесты. В итоге я, поменяв строчку в одном месте в проекте, жду что через 10 вызывов моего метода есть тест, который и проверит то что я изменил. И знаете что? Он не проверит, потому что вы замокали вызов своего сервиса в других местах. И - изоляция.

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


Юнит тесты спасают от регрессии

Не совсем понятно и никто не удосужится объяснить как. В юнит тестах не принято тестировать большой контекст, а регрессия возникает только на большом объеме кода и данных. Регрессия возможна на стыке сервисов, на цепочке вызовов. Юнит тесты тестируют только очевидные сценарии, о которых подумал разработчик, писавший тест. Зачастую, в погоне за процентом покрытия, юнит тестами покрывают также невозможные сценарии, которые никогда не происходят в реальном мире на реальных данных. Самый распространенный пример - при использовании скриптов миграции разработчик забыл добавить скрипт в список скриптов миграции. На локальном окружении все работает, потому что создание и обновление таблиц он настроил автоматически, без скриптов, а на тест, прод окружениях все сломалось.

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


Юнит тесты служат источником документации

WAT?! Нет, серьезно, я бы хотел увидеть человека, который по тестам может объянить хоть что-то в проекте. Ни один юнит тест не дает ответа с первого взгляда на него на следующие вопросы: почему тест заигнорен (если заигнорен), почему у него именно такие входные данные, почему он сломался именно сейчас, почему он работал ранее, почему он периодически падает, почему именно такие сравнения с ожидаемым результатом. А если тест не объясняет сам себя, то что, скажите мне на милость, этот несчастный тест сможет объяснить в проекте?

Интеграционные тесты могут и должны служить не документацией, но прямой отсылкой к документации. Например, я всегда пишу интеграционные тесты, отталкиваясь от бизнес-сценариев и уже название несет в себе ответ что подано на вход и что ожидается: testUpdateDocuments_shouldUpdateDocument, testUpdateDocuments_shouldReturnAccessError, testUpdateDocuments_shouldCreateNeDocument, testUpdateDocumentsWithEmptyBody_shouldReturnError.

Каждый тест проверяет бизнес-сценарий, каждый тест принимает пачку данных, которые будут отправляться в боевом окружении, каждый тест складывает данные в базу / отправляет сообщение в очередь / пишет на диск и т.д. Все сайд-эффекты проверены. При наличии идентификатороф юзкейсов можно в название теста выносить идентификатор. testUseCase12.2_negative, testUseCase12.2_positive отсылают к спецификации, которая и должна быть истиной в последней инстанции на больших проектах с большим количеством команд.


Юнит тесты помогают при рефакторинге

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

Не код, но тесты вы будете менять очень часто. Потому что какие-то тесты будут постоянно падать. Вы поменяли одну строчку в коде, но упало 40 тестов? Удачи, дружище, теперь твоя задача понять что тестируют эти 40 тестов, почему они упали, действительно ли они упали из-за того что появился баг или просто нужно поправить сами тесты.

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

Неочевидные бенефиты интеграционных тестов

  1. Чем больше интеграционных тестов написано, тем реже вам потребуется запускать модули и проверять работу через Postman, Insomnia etc. Например, у меня на проекте локально модули запускаются мною только когда я хочу что-то проверить в связке нескольких сервисов. При выполнении правок в одном сервисе, я никогда его не запускаю и процент багов потрясающе мал - сильно меньше чем было до того как я начал писать только интеграцонные тесты.
  2. Вы учитесь думать не в терминах кода, а в терминах бизнеса. Бизнесу, как правило, плевать какие вы базы используете, как вы кэшируете и что за фреймворк у вас. Бизнесу важно открыть страницу и увидеть список товаров, например. Теперь когда вы будете разговаривать, то будете говорить на одном языке - на языке сценариев использования вашим приложением.
  3. Вы будете тратить сильно меньше времени на поддержку и написание тестов. Инфраструктура интеграционных тестов пишется один раз и далее эксплуатируется. У интеграционных тестов высокий процент переиспользования инфраструктурного кода. Интеграционных тестов банально сильно меньше чем юнит.

Когда юнит-тесты действительно нужны

Бывает ограниченное число случаев, когда юнит тесты действительно нужны, а интеграционные построить очень сложно или вовсе невозможно. Вот те, которые мне известны:

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

Выводы

В качестве выводов скажу банальные вещи:

  1. Всегда включайте голову и перестаньте слепо следовать рекомендациям рандомных мужиков в интернете (даже моим)
  2. Применяйте инструменты и методологии на основе необходимости, но не рекомендаций (зачем вам 80% покрытия кода тестами, если и при 20% все отлично?)
  3. Всегда экономьте время: свое и команды. Думайте об экономии на дистанции в несколько лет.

P.S.: Эта статья выросла из поста (https://t.me/javaminds/301) в моем телеграм-канале. Подписывайтесь, кстати, если еще нет.