Как я разочаровался в юнит тестах и решил, что единственный вариант получить от них пользу — 100% покрытие

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

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

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

af02d17c42ecefac216cad8975d50da3.png

Когда-то я просто писал код и предполагал, что с хорошим тестовым покрытием станет намного проще ловить баги. Мы сделали высокое покрытие тестами, и в итоге стали ловить баги еще и в тестах. ©

Почему я разочаровался в юнит тестах?

Юнит тесты — это не статический код, его так же надо поддерживать наравне с основным.

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

Но как мы проверим, что соблюдается предыдущий контракт, если вместе с кодом поправим и сервисы? Все верно, никак. Нет никакой гарантии, что код целиком работает корректно. А тест на исправленный код мы уже исправили.

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

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

Почему я решился на 100% тестирование?

Вообще, я придерживаюсь такой логики: тестировать нужно то, что нужно тестировать. А что не нужно тестировать — не тестируем. Это очень просто, если не учитывать 2 фактора:

  • Понятие «нужно протестировать» у каждого очень своеобразное.

  • Поэтому за тем, чтобы «нужное» тестировалось, надо неустанно следить.

Было решено установить текущий уровень покрытия и никогда его не опускать, только поднимать.

Однако, в какой-то момент я обнаружил такую тенденцию: функции в одну строку, которые тестировать в общем случае нет необходимости, тестами покрываются, а алгоритмически сложные покрыты очень слабо. По сути, слабое тестирование «нужных к тестированию» функций компенсировалось тестированием «не нужных к тестированию». По сути, цели мы достигли, но не той, которая ожидалась.

Поэтому я решил попробовать следующее.

  • Мы поднимаем уровень тестирования до 100%. Понятно, что написать тесты сразу на все мы не можем, поэтому…

  • Исключаем пакеты, которые протестированы не полностью. Т.е., по сути, в циферках у нас стоит 100, но по факту на первом этапе проверялось «ничего».

  • Далее попакетно начинаем поднимать покрытие до 100%. Не спеша, по чуть-чуть откусывая время от спринтов на это дело.

Таким образом мы сделали постепенное поднятие тестового покрытия. Да, теперь мы тестируем все — и нужное, и ненужное. Но первоначальная цель достигнута: мы точно тестируем то, что нужно тестировать.

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

Помогло ли это? Определенно, да. Но есть нюанс.

Оно помогло не так, как предполагалось.

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

Но как же оно тогда помогло?

  • Мы стали делать меньше ошибок по невнимательности. Типичный пример: пишем в контроллере POST вместо GET. Или использовали не ту переменную в функции. Теперь мы сами себя проверяем при написании, поэтому и ошибок допускается намного меньше.

  • Мы стали лучше проверять граничные случаи.Теперь нам приходится тестировать все ветки в проверяемом коде. Поэтому мы точно проверим, что случай достигаем и корректно работает. А также чаще находим какие-то граничные случаи, которые надо обработать.

Когда же юнит тесты действительно полезны?

  • Проверка сложного алгоритмически метода. В идеале, без внешних вызовов. Мы проверяем, что все ветки кода достигаемы и корректно отрабатывают.

  • Функции с неизменным контрактом.Бывают модифицирующие функции, в которые мы передаем некий набор данных, а получаем модифицированный. Прекрасный пример — функция, разворачивающая дерево во множество. Или функция проверки временного отрезка.

  • При первом написании кода.Проверяем свою внимательность, в общем, тут пояснять больше нечего.

  • Тесты заставляют проектировать.Про это не упоминается в самой статье, но я не мог этого не отметить в качестве положительной стороны тестов. Итак: короткие функции с меньшим числом зависимостей легче тестировать. А код меньше хочется дублировать, если под него надо писать тест. Да и вообще, хочется сделать функцию хочется сделать «проще», когда понимаешь, что под надо писать еще и тест.

Вместо вывода

На данный момент я не очень уверен, что последнее решение является корректным.

С какой-то стороны, написание тестов при написании нового кода — это не задача на овер много часов. Да, мы пишем тесты на функцию в 1 строку –, но и написание этого теста составляет 5 минут. Да, тесты приходится править каждый раз при изменении кода –, но они уже спасли нас от многих «ошибок невнимательности», и, вероятно, смогут спасти и дальше.

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

© Habrahabr.ru