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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Комментарии (19)


  1. Vasjen
    26.09.2024 08:24
    +1

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

    Скорее всего, из-за непонимания или неправильно использования юнит тестов. Вы же сами пишите, что

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

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

    Хорошие тесты не должны завязываться на реализацию, а проверяют исключительно выходное значение при передачи различных параметров на вход. И место для тестирования - это бизнес слой, в первую очередь. Поэтому он должен быть изолированным, и должен быть представлен в каких-то формах, которые можно протестировать. Разумеется, в реальной жизни так далеко не всегда можно сделать, но лучше идти в эту сторону, улучшить структуру приложения, и покрывать бизнес логику тестами, а не стремиться покрыть все тестами на 100%.


    1. brutfooorcer Автор
      26.09.2024 08:24

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

      А как вы предполагаете написать тест на функцию так, что бы они никогда не переписывался, если вам априори придется учитывать ветвления в коде и зависимости?

      И теперь, из-за этого, вы решили вообще все покрыть тестами. Противоречие?

      Ну, это не так. Мы решили покрывать тестами 100 кода, что бы быть уверенными, что те функции, которые надо тестировать, будут протестированы. А все остальное, получается, идет прицепом. В общем то, это есть в статье.

      Хорошие тесты не должны завязываться на реализацию, а проверяют исключительно выходное значение при передачи различных параметров на вход. 

      Абсолютно верно. Это значит, что мы имеем либо функцию без зависимостей (что бы их не мокать), либо проверяем ее вместе со всеми зависимостями (что я бы уже не назвал юнит тестом).

      Ну и иногда у функции внутренний контракт меняется, когда меняются условия в ветках или добавляются/изменяются ветки.

      в реальной жизни так далеко не всегда можно сделать

      Об этом и статья)


      1. losse_narmo
        26.09.2024 08:24
        +2

        То, что функция имеет 100% покрытия не значит, что она протестирована

        Тестировать надо не строки (ради % покрытия), а поведение (в том числе те самые граничные условия).

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

        Функция из 1 строки (просто вызов replace).

        Любой тест даст сразу 100% покрытия, но не любой тест проверит что все нужные символы заменены на пробелы


        1. brutfooorcer Автор
          26.09.2024 08:24
          +1

          Я с вами полностью согласен. Вроде, все написанное мной, не противоречит этому)


      1. Vasjen
        26.09.2024 08:24

        если вам априори придется учитывать ветвления в коде и зависимости?

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

        Ну, это не так. Мы решили покрывать тестами 100 кода, что бы быть уверенными, что те функции, которые надо тестировать, будут протестированы. 

        Уже есть понимание, что надо тестировать - бизнес логику. Бизнес логики от всего кода приложения может быть процентов 20, может и больше. Но вокруг нее всегда много кода, который не нужно покрывать именно юнит тестами. И лучше покрыть 80% от бизнес логики, что может быть 20-40% от всего кода, чем покрыть 100% всего кода. Вы эти тесты замучаетесь поддерживать и переписывать, а самое главное, что их ценность стремиться к нулю.


      1. Vasjen
        26.09.2024 08:24
        +1

        Абсолютно верно. Это значит, что мы имеем либо функцию без зависимостей (что бы их не мокать), либо проверяем ее вместе со всеми зависимостями (что я бы уже не назвал юнит тестом).

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

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


        1. brutfooorcer Автор
          26.09.2024 08:24
          +1

          Отвечу вам на оба поста.

          Уже есть понимание, что надо тестировать - бизнес логику.

          Я лишь к тому, что гонка за цифрой в 100% покрытие кода – это путь в никуда.

          Я с вами согласен. Возможно, вы не заметили, но мои 100% - это не бездумное тестирование всего и вся - есть часть кода, которую решено не тестировать. Именно по той причине, что ее не надо тестить. Поэтому и покрытие выходит 100%, и мы точно знаем, что в нужных классах/пакетах протестировано все.

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

          Но в общем случае - вашу позицию я понимаю и вполне с ней согласен.


  1. c0r3dump
    26.09.2024 08:24

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


    1. brutfooorcer Автор
      26.09.2024 08:24
      +1

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

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

      Если в задаче не пишется псевдокод. Но это уже совсем другая история.


  1. MeGaZip
    26.09.2024 08:24

    Но, если в код добавилась новая ветка или вызов какого-то сервиса – нам надо править и тестирование, ветку надо обработать в существующих тестах и/или написать под нее отдельный тест, а новые вызовы надо замокать.

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

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

    Есть контракт (интерфейс) и если логика изменилась, то это будет нарушение контракта, так что его просто нужно расширить, а изменяя поведение старых функций, мы нарушаем принципы SOLID.


    1. saymon_says
      26.09.2024 08:24
      +1

      Да здравствуют декораторы на декораторы)


      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), и даже тесты не придётся править, и кстати все тесты, что сегодня, что завтра будут успешны.


        1. saymon_says
          26.09.2024 08:24
          +3

          А когда нужно будет остановиться и перестать писать строки мертвого кода? Вчера был один метод, сегодня новый, завтра ещё требование изменилось и захотят сложение, а мы уже два метода написали, покрыли тестами и держим просто так получается?


          1. MeGaZip
            26.09.2024 08:24

            А если уйти от противоречий того, что я пишу, и написать как нужно?
            То есть вы поддерживаете способ изменения интерфейса и реализации?
            int DoSomething(int input1, int input2);наdouble DoSomething(int input1, int input2);


    1. MyraJKee
      26.09.2024 08:24

      Хз почему минусы понаставили. Тоже глаз зацепился за этот пункт. Вопрос дискуссионный. Что хорошо для небольшого микросервиса или сервиса, может быть плохо для огромного монолита.


  1. Aleus1249355
    26.09.2024 08:24

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


  1. WieRuindl
    26.09.2024 08:24
    +2

    Вот к каким мыслям пришёл я по поводу тестов:

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

    2) соответственно, покрыто тестами должно быть все, что имеет какую-либо логику. Если класс принимает какие-либо аргументы на входе, что-то с ними делает, и выдаёт какой-то результат, то это должно быть проверено

    3) в конечном итоге ты пишешь тест не для каких-то абстрактных KPI, а для себя любимого. Всегда есть вероятность, что при каком-нибудь рефакторинге ты что-то случайно заденешь, и это что-то выстрелит в проде, и тогда ж именно тебе позвонят в 5 утра с криками, что все сломалась, и надо срочно чинить. Если есть возможность хоть как-то обезопасить себя от этого, то не стоит эту возможность упускать

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


  1. ayrtonSK
    26.09.2024 08:24

    А пробовали TDD? Для всего чуть сложнее crud это сразу делает и дизайн верный и тестируем не руками, а сразу тест. Итоговое время разработки сопоставимо чем без тестов, ведь на ручное тестовое мы тоже тратим время.

    Простые вещи, слишком очевидные, не обязательно тестировать.

    Тесты великая вещь в open source проектах, там можно найти примеры, как оно должно работать.

    Тесты очень сильно помогают при рефакторинге, мы просто не боимся делать его.


  1. SimSonic
    26.09.2024 08:24

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

    Теперь у нас развёрнутая пирамида тестирования — больше всего кейсов у QA, backend-разработчики пишут преимущественно тесты по веткам пользовательский сценариев процессов (как хотите называйте, e2e / интеграционные), а юнитами покрываются только изолированные кирпичики. А TDD очень хорошо заходит, когда реально от QA или с боя прилетает баг — сперва тестом воспроизводится, затем фиксится.