Как я разочаровался в юнит тестах и решил, что единственный вариант получить от них пользу — 100% покрытие
Мы часто слышим о полезности и необходимости юнит тестов. До сих пор на слуху парадигма TDD, когда мы пишем тесты еще до написания самой логики.
Я уточню, что речь в статье будет именно про юнит тесты — тесты, проверяющие изолированный участок кода без внешних вызовов.
И юнит тесты — это действительно хорошо. Кажется. Потому что их реальная полезность часто бывает переоценена. Не в том плане, что юнит тесты бесполезны — нет, они полезны. Но не так, как хотелось бы.
Когда-то я просто писал код и предполагал, что с хорошим тестовым покрытием станет намного проще ловить баги. Мы сделали высокое покрытие тестами, и в итоге стали ловить баги еще и в тестах. ©
Почему я разочаровался в юнит тестах?
Юнит тесты — это не статический код, его так же надо поддерживать наравне с основным.
Одним из важных аспектов применения юнит тестов — это проверка, что код работает корректно (соблюдается контракт) после изменений в нем. Но, если в код добавилась новая ветка или вызов какого-то сервиса — нам надо править и тестирование, ветку надо обработать в существующих тестах и/или написать под нее отдельный тест, а новые вызовы надо замокать.
Но как мы проверим, что соблюдается предыдущий контракт, если вместе с кодом поправим и сервисы? Все верно, никак. Нет никакой гарантии, что код целиком работает корректно. А тест на исправленный код мы уже исправили.
Еще более сложная ситуация, если логика проверяемой функции серьезно переработана — в итоге, мы получаем не только то, что написано выше, но еще и тест полностью переписать придется.
По сути, большая часть юнит тестов не пригодна для поддержки приложения, они полезны только на начальных этапах, чтобы проверить, что заложенная разработчиком логика корректно отрабатывает.
Почему я решился на 100% тестирование?
Вообще, я придерживаюсь такой логики: тестировать нужно то, что нужно тестировать. А что не нужно тестировать — не тестируем. Это очень просто, если не учитывать 2 фактора:
Понятие «нужно протестировать» у каждого очень своеобразное.
Поэтому за тем, чтобы «нужное» тестировалось, надо неустанно следить.
Было решено установить текущий уровень покрытия и никогда его не опускать, только поднимать.
Однако, в какой-то момент я обнаружил такую тенденцию: функции в одну строку, которые тестировать в общем случае нет необходимости, тестами покрываются, а алгоритмически сложные покрыты очень слабо. По сути, слабое тестирование «нужных к тестированию» функций компенсировалось тестированием «не нужных к тестированию». По сути, цели мы достигли, но не той, которая ожидалась.
Поэтому я решил попробовать следующее.
Мы поднимаем уровень тестирования до 100%. Понятно, что написать тесты сразу на все мы не можем, поэтому…
Исключаем пакеты, которые протестированы не полностью. Т.е., по сути, в циферках у нас стоит 100, но по факту на первом этапе проверялось «ничего».
Далее попакетно начинаем поднимать покрытие до 100%. Не спеша, по чуть-чуть откусывая время от спринтов на это дело.
Таким образом мы сделали постепенное поднятие тестового покрытия. Да, теперь мы тестируем все — и нужное, и ненужное. Но первоначальная цель достигнута: мы точно тестируем то, что нужно тестировать.
На всякий случай уточню, что некоторые пакеты было решено оставить в исключениях навсегда: например, сущности, конфиги, перечисления, исключения и т.д.
Помогло ли это? Определенно, да. Но есть нюанс.
Оно помогло не так, как предполагалось.
Изначально предполагалось, что тесты помогут в долгосрочной поддержке приложения. Но оказалось, что юнит тесты для этого непригодны. Почему? Да потому что юнит тесты — это не статический код, его так же надо поддерживать наравне с основным. Да, это цитата из первого подраздела стать. Теперь при любых изменениях в протестированном коде в большинстве случаев мы лезем править и тесты к нему.
Но как же оно тогда помогло?
Мы стали делать меньше ошибок по невнимательности. Типичный пример: пишем в контроллере POST вместо GET. Или использовали не ту переменную в функции. Теперь мы сами себя проверяем при написании, поэтому и ошибок допускается намного меньше.
Мы стали лучше проверять граничные случаи.Теперь нам приходится тестировать все ветки в проверяемом коде. Поэтому мы точно проверим, что случай достигаем и корректно работает. А также чаще находим какие-то граничные случаи, которые надо обработать.
Когда же юнит тесты действительно полезны?
Проверка сложного алгоритмически метода. В идеале, без внешних вызовов. Мы проверяем, что все ветки кода достигаемы и корректно отрабатывают.
Функции с неизменным контрактом.Бывают модифицирующие функции, в которые мы передаем некий набор данных, а получаем модифицированный. Прекрасный пример — функция, разворачивающая дерево во множество. Или функция проверки временного отрезка.
При первом написании кода.Проверяем свою внимательность, в общем, тут пояснять больше нечего.
Тесты заставляют проектировать.Про это не упоминается в самой статье, но я не мог этого не отметить в качестве положительной стороны тестов. Итак: короткие функции с меньшим числом зависимостей легче тестировать. А код меньше хочется дублировать, если под него надо писать тест. Да и вообще, хочется сделать функцию хочется сделать «проще», когда понимаешь, что под надо писать еще и тест.
Вместо вывода
На данный момент я не очень уверен, что последнее решение является корректным.
С какой-то стороны, написание тестов при написании нового кода — это не задача на овер много часов. Да, мы пишем тесты на функцию в 1 строку –, но и написание этого теста составляет 5 минут. Да, тесты приходится править каждый раз при изменении кода –, но они уже спасли нас от многих «ошибок невнимательности», и, вероятно, смогут спасти и дальше.
Но сейчас мне кажется, что такое покрытие тестами дало нам больше, чем потраченное на их написание время.