Мы часто слышим о полезности и необходимости юнит тестов. До сих пор на слуху парадигма TDD, когда мы пишем тесты еще до написания самой логики.
Я уточню, что речь в статье будет именно про юнит тесты — тесты, проверяющие изолированный участок кода без внешних вызовов.
И юнит тесты — это действительно хорошо. Кажется. Потому что их реальная полезность часто бывает переоценена. Не в том плане, что юнит тесты бесполезны — нет, они полезны. Но не так, как хотелось бы.
Когда-то я просто писал код и предполагал, что с хорошим тестовым покрытием станет намного проще ловить баги. Мы сделали высокое покрытие тестами, и в итоге стали ловить баги еще и в тестах. (с)
Почему я разочаровался в юнит тестах?
Юнит тесты — это не статический код, его так же надо поддерживать наравне с основным.
Одним из важных аспектов применения юнит тестов — это проверка, что код работает корректно (соблюдается контракт) после изменений в нем. Но, если в код добавилась новая ветка или вызов какого‑то сервиса — нам надо править и тестирование, ветку надо обработать в существующих тестах и/или написать под нее отдельный тест, а новые вызовы надо замокать.
Но как мы проверим, что соблюдается предыдущий контракт, если вместе с кодом поправим и сервисы? Все верно, никак. Нет никакой гарантии, что код целиком работает корректно. А тест на исправленный код мы уже исправили.
Еще более сложная ситуация, если логика проверяемой функции серьезно переработана — в итоге, мы получаем не только то, что написано выше, но еще и тест полностью переписать придется.
По сути, большая часть юнит тестов не пригодна для поддержки приложения, они полезны только на начальных этапах, чтобы проверить, что заложенная разработчиком логика корректно отрабатывает.
Почему я решился на 100% тестирование?
Вообще, я придерживаюсь такой логики: тестировать нужно то, что нужно тестировать. А что не нужно тестировать — не тестируем. Это очень просто, если не учитывать 2 фактора:
Понятие «нужно протестировать» у каждого очень своеобразное.
Поэтому за тем, чтобы «нужное» тестировалось, надо неустанно следить.
Было решено установить текущий уровень покрытия и никогда его не опускать, только поднимать.
Однако, в какой‑то момент я обнаружил такую тенденцию: функции в одну строку, которые тестировать в общем случае нет необходимости, тестами покрываются, а алгоритмически сложные покрыты очень слабо. По сути, слабое тестирование «нужных к тестированию» функций компенсировалось тестированием «не нужных к тестированию». По сути, цели мы достигли, но не той, которая ожидалась.
Поэтому я решил попробовать следующее.
Мы поднимаем уровень тестирования до 100%. Понятно, что написать тесты сразу на все мы не можем, поэтому...
Исключаем пакеты, которые протестированы не полностью. т. е., по сути, в циферках у нас стоит 100, но по факту на первом этапе проверялось «ничего».
Далее попакетно начинаем поднимать покрытие до 100%. Не спеша, по чуть‑чуть откусывая время от спринтов на это дело.
Таким образом мы сделали постепенное поднятие тестового покрытия. Да, теперь мы тестируем все — и нужное, и ненужное. Но первоначальная цель достигнута: мы точно тестируем то, что нужно тестировать.
На всякий случай уточню, что некоторые пакеты было решено оставить в исключениях навсегда: например, сущности, конфиги, перечисления, исключения и т. д.
Помогло ли это? Определенно, да. Но есть нюанс.
Оно помогло не так, как предполагалось.
Изначально предполагалось, что тесты помогут в долгосрочной поддержке приложения. Но оказалось, что юнит тесты для этого непригодны. Почему? Да потому что юнит тесты — это не статический код, его так же надо поддерживать наравне с основным. Да, это цитата из первого подраздела статьи. Теперь при любых изменениях в протестированном коде в большинстве случаев мы лезем править и тесты к нему.
Но как же оно тогда помогло?
Мы стали делать меньше ошибок по невнимательности. Типичный пример: пишем в контроллере POST вместо GET. Или использовали не ту переменную в функции. Теперь мы сами себя проверяем при написании, поэтому и ошибок допускается намного меньше.
Мы стали лучше проверять граничные случаи. Теперь нам приходится тестировать все ветки в проверяемом коде. Поэтому мы точно проверим, что случай достигаем и корректно работает. А также чаще находим какие‑то граничные случаи, которые надо обработать.
Когда же юнит тесты действительно полезны?
Проверка сложного алгоритмически метода. В идеале, без внешних вызовов. Мы проверяем, что все ветки кода достигаемы и корректно отрабатывают.
Функции с неизменным контрактом. Бывают модифицирующие функции, в которые мы передаем некий набор данных, а получаем модифицированный. Прекрасный пример — функция, разворачивающая дерево во множество. Или функция проверки временного отрезка.
При первом написании кода. Проверяем свою внимательность, в общем, тут пояснять больше нечего.
Тесты заставляют проектировать. Про это не упоминается в самой статье, но я не мог этого не отметить в качестве положительной стороны тестов. Итак: короткие функции с меньшим числом зависимостей легче тестировать. А код меньше хочется дублировать, если под него надо писать тест. Да и вообще, хочется сделать функцию хочется сделать «проще», когда понимаешь, что под нее надо писать еще и тест.
Вместо вывода
На данный момент я не очень уверен, что последнее решение является корректным.
С какой‑то стороны, написание тестов при написании нового кода — это не задача на овер много часов. Да, мы пишем тесты на функцию в 1 строку — но и написание этого теста составляет 5 минут. Да, тесты приходится править каждый раз при изменении кода — но они уже спасли нас от многих «ошибок невнимательности», и, вероятно, смогут спасти и дальше.
Но сейчас мне кажется, что такое покрытие тестами дало нам больше, чем потраченное на их написание время.
Комментарии (22)
c0r3dump
26.09.2024 08:24Мне кажется больше всего пользы с тестов, когда они пишутся другим человеком и приходят вместе с задачей уже разработчику. Жалко, что такое мне редко встречалось.
brutfooorcer Автор
26.09.2024 08:24+1Для некоторых функций у нас похожее реализовано - есть параметризированный тест, аналитики прописывают условия в json формате и подкладывают их в задачу.
Но в общем случае, такое далеко не всегда в принципе можно реализовать. Обычно, граничные случаи приведены в задаче, и они должны быть протестированы. Но зачастую в коде граничных случаем много больше, чем возможно описать в задаче.
Если в задаче не пишется псевдокод. Но это уже совсем другая история.
MeGaZip
26.09.2024 08:24Но, если в код добавилась новая ветка или вызов какого-то сервиса – нам надо править и тестирование, ветку надо обработать в существующих тестах и/или написать под нее отдельный тест, а новые вызовы надо замокать.
Есть функция, которая получает какие-то значения на входе, и возвращает какое-то значение на выходе, которое мы сравниваем с ожидаемым - это и есть тест.
Если у нас появилась необходимость изменить поведение функции, то есть на входе/выходе у нас теперь не то, что мы ожидали раньше, то нам нужно создавать новую функцию, которая будет работать по-другому, и под неё добавить ещё один тест.
Есть контракт (интерфейс) и если логика изменилась, то это будет нарушение контракта, так что его просто нужно расширить, а изменяя поведение старых функций, мы нарушаем принципы SOLID.
saymon_says
26.09.2024 08:24+1Да здравствуют декораторы на декораторы)
MeGaZip
26.09.2024 08:24Допустим вчера у нас был интерфейс и реализация, в которой мы получаем результат умножения.
public interface IWorker { int DoSomething(int input1, int input2); } public class Worker : IWorker { public int DoSomething(int input1, int input2) => input1 * input2; } // TEST
А сегодня нам бизнес сказал, что нужно не умножать, а делить и получать результат деления.
И это всё так же должен быть тот же самый метод, в котором нам нужно изменить интерфейс и реализацию?public interface IWorker { // Меняем интерфейс double DoSomething(int input1, int input2); } public class Worker : IWorker { // Меняем реализацию public double DoSomething(int input1, int input2) => input1 / input2; } // Меняем тесты
Я склоняюсь к тому, что теперь должна быть ещё одна реализация.
public interface IWorker { int DoSomething(int input1, int input2); double DoSomething2(int input1, int input2); } public class Worker : IWorker { // начальная реализация public int DoSomething(int input1, int input2) => input1 * input2; // Добавляем реализацию public double DoSomething2(int input1, int input2) => input1 / input2; } // Добавляем TEST2
И если завтра нам нужно будет вернуть умножение, то в самой программе, мы просто изменим вызов worker.DoSomething2(4, 2) на worker.DoSomething(4, 2), и даже тесты не придётся править, и кстати все тесты, что сегодня, что завтра будут успешны.
saymon_says
26.09.2024 08:24+5А когда нужно будет остановиться и перестать писать строки мертвого кода? Вчера был один метод, сегодня новый, завтра ещё требование изменилось и захотят сложение, а мы уже два метода написали, покрыли тестами и держим просто так получается?
MeGaZip
26.09.2024 08:24А если уйти от противоречий того, что я пишу, и написать как нужно?
То есть вы поддерживаете способ изменения интерфейса и реализации?int DoSomething(int input1, int input2);
наdouble DoSomething(int input1, int input2);
MyraJKee
26.09.2024 08:24+1Хз почему минусы понаставили. Тоже глаз зацепился за этот пункт. Вопрос дискуссионный. Что хорошо для небольшого микросервиса или сервиса, может быть плохо для огромного монолита.
Aleus1249355
26.09.2024 08:24+1Как я разочаровался в юнит тестах КОГДА решил, что единственный вариант получить от них пользу — 100% покрытие
WieRuindl
26.09.2024 08:24+4Вот к каким мыслям пришёл я по поводу тестов:
1) самое важное, что они дают - это понимание, хороший ли у тебя получается код. Если код тестировать сложно и неудобно - это плохой код. Написание теста не должно становиться болью, и если это вдруг становится болью, то это сразу же сигнал, что ты что-то делаешь не так
2) соответственно, покрыто тестами должно быть все, что имеет какую-либо логику. Если класс принимает какие-либо аргументы на входе, что-то с ними делает, и выдаёт какой-то результат, то это должно быть проверено
3) в конечном итоге ты пишешь тест не для каких-то абстрактных KPI, а для себя любимого. Всегда есть вероятность, что при каком-нибудь рефакторинге ты что-то случайно заденешь, и это что-то выстрелит в проде, и тогда ж именно тебе позвонят в 5 утра с криками, что все сломалась, и надо срочно чинить. Если есть возможность хоть как-то обезопасить себя от этого, то не стоит эту возможность упускать
Я, кстати, как раз размышляю над собрать все свои мысли в кучку, добавить примеров и оформить презентацию для коллег на работе, и если кто-нибудь накидает мне умных мыслей в копилку, то буду благодарен :)
ayrtonSK
26.09.2024 08:24А пробовали TDD? Для всего чуть сложнее crud это сразу делает и дизайн верный и тестируем не руками, а сразу тест. Итоговое время разработки сопоставимо чем без тестов, ведь на ручное тестовое мы тоже тратим время.
Простые вещи, слишком очевидные, не обязательно тестировать.
Тесты великая вещь в open source проектах, там можно найти примеры, как оно должно работать.
Тесты очень сильно помогают при рефакторинге, мы просто не боимся делать его.
brutfooorcer Автор
26.09.2024 08:24А пробовали TDD?
В общем то, у меня так и получается. Пару строк кода - под него сразу тест. Не совсем TDD, но почти то же самое. Мне так намного удобнее.
SimSonic
26.09.2024 08:24+4На своих проектах тоже пришёл к выводу, что огромной пользы от юнит-тестов нет и получить не выйдет, как покрытие не повышай. Плюс лично я против того, чтобы, когда вносятся нефункциональные изменения в боевой код (рефакторинг), требовалось изменять тесты. Потому что тест должен защитить боевой код от потенциальных проблем при рефакторинге, а изменяя его — ты защищаешь этот самый баг ) И, к слову, даже наличие проблем в непроверенных краевых вызовах какого-то метода может спокойно быть нивелировано реальной невозможностью этот код таким образом вызвать (в рамках ввода пользователя).
Теперь у нас развёрнутая пирамида тестирования — больше всего кейсов у QA, backend-разработчики пишут преимущественно тесты по веткам пользовательский сценариев процессов (как хотите называйте, e2e / интеграционные), а юнитами покрываются только изолированные кирпичики. А TDD очень хорошо заходит, когда реально от QA или с боя прилетает баг — сперва тестом воспроизводится, затем фиксится.
yri066
26.09.2024 08:24Писать тесты для своего кода это значит сомневаться в своих скилах. Это признак слабости.
BronzeSeal
26.09.2024 08:24В разработке тесты больше помогают понять задачу, чем проверяют работоспособность. Тем более, как правильно писали выше, наличие тестов не гарантирует работоспособность модуля.
Я считаю, что тесты это больше слепок поведения, нежели проверки и нужны они для обезболивания расширения/изменения функционала. Тем более, привести в порядок тесты(написать/починить имеющиеся) + отрефачить код намного дешевле, чем рефачить без юнитов.
Vasjen
Скорее всего, из-за непонимания или неправильно использования юнит тестов. Вы же сами пишите, что
То есть вы уже сталкиваетесь с ситуацией, когда из-за плохих тестов при внедрении нового функционала (или обновления его), вам приходится переписывать тесты. И теперь, из-за этого, вы решили вообще все покрыть тестами. Противоречие?
Хорошие тесты не должны завязываться на реализацию, а проверяют исключительно выходное значение при передачи различных параметров на вход. И место для тестирования - это бизнес слой, в первую очередь. Поэтому он должен быть изолированным, и должен быть представлен в каких-то формах, которые можно протестировать. Разумеется, в реальной жизни так далеко не всегда можно сделать, но лучше идти в эту сторону, улучшить структуру приложения, и покрывать бизнес логику тестами, а не стремиться покрыть все тестами на 100%.
brutfooorcer Автор
А как вы предполагаете написать тест на функцию так, что бы они никогда не переписывался, если вам априори придется учитывать ветвления в коде и зависимости?
Ну, это не так. Мы решили покрывать тестами 100 кода, что бы быть уверенными, что те функции, которые надо тестировать, будут протестированы. А все остальное, получается, идет прицепом. В общем то, это есть в статье.
Абсолютно верно. Это значит, что мы имеем либо функцию без зависимостей (что бы их не мокать), либо проверяем ее вместе со всеми зависимостями (что я бы уже не назвал юнит тестом).
Ну и иногда у функции внутренний контракт меняется, когда меняются условия в ветках или добавляются/изменяются ветки.
Об этом и статья)
losse_narmo
То, что функция имеет 100% покрытия не значит, что она протестирована
Тестировать надо не строки (ради % покрытия), а поведение (в том числе те самые граничные условия).
Например, у нас есть функция, которая должна заменять все табуляции, переносы строк и т.п. на пробелы.
Функция из 1 строки (просто вызов replace).
Любой тест даст сразу 100% покрытия, но не любой тест проверит что все нужные символы заменены на пробелы
brutfooorcer Автор
Я с вами полностью согласен. Вроде, все написанное мной, не противоречит этому)
Vasjen
Я Вам о том и говорю, что если есть какие-то тесты, которые завязаны на реализацию методов, в которых есть какие-то ветвления и зависимости – это плохие тесты, вы на каждых чих будете вынуждены их переписывать. Чтобы такого не было, нужно пересматривать структуру логики, декомпозировать методы и т.д.
Уже есть понимание, что надо тестировать - бизнес логику. Бизнес логики от всего кода приложения может быть процентов 20, может и больше. Но вокруг нее всегда много кода, который не нужно покрывать именно юнит тестами. И лучше покрыть 80% от бизнес логики, что может быть 20-40% от всего кода, чем покрыть 100% всего кода. Вы эти тесты замучаетесь поддерживать и переписывать, а самое главное, что их ценность стремиться к нулю.
Vasjen
Опять же, без конкретных фрагментов – это абстрактные разговоры. Зависимости разные бывают. Если зависимости внешние - то да, их не надо включать, если это зависимости внутренние, сервисы какие-то, валидаторы - то почему бы и нет. Это реально сложно организовать нормальное тестирование, просто потому, что сложно писать какие-то бизнес правила в функциональном стиле, где что-то подается на вход, что-то всегда есть на выходе. На эту тему немало есть книг и статей.
Я лишь к тому, что гонка за цифрой в 100% покрытие кода – это путь в никуда. Единственное, кому этот путь может быть выгоден – менеджерам для отчетов, для согласования бюджетов и сроков, для раздувания штата тестировщиков и т.д.
brutfooorcer Автор
Отвечу вам на оба поста.
Я с вами согласен. Возможно, вы не заметили, но мои 100% - это не бездумное тестирование всего и вся - есть часть кода, которую решено не тестировать. Именно по той причине, что ее не надо тестить. Поэтому и покрытие выходит 100%, и мы точно знаем, что в нужных классах/пакетах протестировано все.
Ну и про тесты и реструктуризацию кода - в статье есть упоминание про то, что тесты заставляют задуматься над архитектурой кода. Но проблема в том, что вы сами пишете о том, что это а) довольно сложно и б) не всегда реально. Прибавьте к этому поддержку легаси кода... и получаем то, что имеем.
Но в общем случае - вашу позицию я понимаю и вполне с ней согласен.