CTO Vetmanager, PHP Developer, Ironman 70.3

Как написать нехрупкий тест?

Юнит тесты обладают несколькими важными особенностями.

  • защита от багов
  • устойчивость к рефакторингу
  • быстрая обратная связь
  • простота поддержки

Достичь всего одновременно практически невозможно. Чтобы создать оптимальные тесты, вам неизбежно нужно будет идти на компромиссы. Главная проблема при написании тестов - неустойчивость к рефакторингу или хрупкость. Хрупкие тесты реально раздражают, они не помогают работать, а только мешают.

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

Я делал несложную аналитику на Python и мне понадобился класс, который будет выдавать даты определенных стадий использования программы.

Пример: Пользователь зарегистрировался 1 января 2020 года и прекратил платить за программу 1 января 2021 года.

Мне нужно по нему получить:

Дата “Конец триала” - до 15го января он пользовался триальной версией, поскольку у нас триал 14 дней бесплатного использования. Дата “Почти конец” - этот период я выдумал сам, сейчас это будет 2 ноября. Мне интересно посмотреть насколько активно пользователи используют программу за 60 дней до окончания срока. Период “Конец” - 1 января 2021 г. Просто последний день.

В реальности у меня больше этапов, упустил лишнее

Итак, в датах думать не очень удобно, давайте думать в простых числах. Вот такой класс:

class DayOfStages():
    __days: int
    
    def __init__(self, days: int):
        # Валидируем входящее число.
        if (days < 100) :
            raise ValueError("Days can't be less than 100")
        self.__days = days

    def end_of_trial(self): 
        return 14

    def alsmost_the_end(self):
        return self.__days - 60

    def finish(self): 
        return self.__days

Будем тестировать. Чтобы сделать тест устойчивым к рефакторингу нам нужно чем-то жертвовать. Очень часто можно безболезненно жертовать таким показателем как “Защита от багов”. Делайте меньше проверок и делайте их менее точными там где это возможно. Можно перефразировать - Не делайте точную проверку, если в этом нет необходимости.

Как же этим жертвовать? А зачем тесты писать если они нас не защищают от багов? Читаем дальше…

Пишем тесты:

Проверяем конструктор

В конструкторе у нас происходит валидация days >= 100.

Хороший тестировщик должен разделить входящие данные согласно классам эквивалентности. Разделить входящие данные(то что мы можем всунуть в переменную days) на группы по определенному признаку. Затем проверить с числом из каждого класса.

У нас будут такие классы эквивалентности: “Числа меньше 100”, “Число 100”, “Числа больше 100”, “Не числа”.

Мы проверяем как будет работать конструктор с такими вариантами соответственно: DayOfStages(1), DayOfStages(100), DayOfStages(1000), DayOfStages('fsdfsd')

Не делайте так! Не делайте так без особой необходимости. Вы программист, а не тестировщик! Вы переносите часть бизнес правил в тесты. Тесты теперь знают слишком много про бизнес правила и должны будут меняться при каждой смене бизнес правил. Пожертвуйте свойством “Защита от багов” в этом месте. Что если мы проверим только то что в принципе у нас есть защита в конструкторе и напишем один тест:


    with self.assertRaises(ValueError):
        DayOfStages(1)

Один этот тест проверит, что у нас в конструкторе происходит валидация входящих параметров. Этого очень часто достаточно. У нас сейчас нет 100% уверенности, что наш код без багов, но мы получили тест, который не упадет при первом же изменении бизнес правил. Когда нам скажут что нужно сделать не 100 дней минимум, а 90 мы поменяем всего лишь одну строку кода. Мы не будем переписывать все тесты связанные с конструктором.

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

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

Пишем тесты на метод end_of_trial

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

Напишите такой тест, когда ваш гимнаст упадет. После того как будет допущен первый баг.

Проверяем метод alsmost_the_end

Мы знаем логику метода и у нас есть соблазн написать суперточный тест:

    self.assertEqual(
        DaysQuartiles(365).alsmost_the_end(),
        365 - 60
    )

Не спеши, ковбой, ты сейчас допускай тот же косяк. Ты пишешь хрупкий тест, это правило еще 100 раз изменится и тебе придется переписывать и код и тест. Еще и нарушаешь принцип DRY.

Напиши менее точный тест:

    self.assertLess(
        DaysQuartiles(365).alsmost_the_end(),
        365
    )

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

Проверяем метод finish

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

Выводы

Не пишите хрупкие тесты. Хрупкие тесты дают только иллюзию защиты от багов, по факту же это превращается в бесконечные правки. За каждой исправленной строкой рабочего кода произойдет как минимум одна правка в коде тестов. Удовольствия от такого вы не получите. Защиты от багов в этом тоже никакой нет. Только миллионы ложных срабатываний.

  • Не делайте точную проверку, если в этом нет необходимости.
  • Не сравнивайте в тестах ассоциативные массивы целиком. Допустим: метод должен вернуть ['id' => 1, 'title' => 'test'] проверьте только наличие id. Используйте правило страховочной сетки и не спешите проверять другие свойства. Сравнивая результат с цельным массивом - вы точно напишите хрупкий тест. Добавив одно свойство в этот массив вам придется переписать все тесты, хотя код на самом деле работал.
  • Не сравнивайте ответ сервера с эталоном. То есть, вы проверяете ответ вашего API и проверяете что $testJson == $responseJson. Просто добавив еще одно свойство в ответ вам придется переписывать и код и тесты. Тесты буду выдавать ложные срабатывания, привлекать ваше внимание, когда на самом деле все хорошо.

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

PS

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