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

Дисклеймер: в статье будут примеры кода на C#, но я постаралась сделать их максимально понятными для людей, знакомых с любым языком программирования. Писать тесты будем на функцию кормления кота:

Проверь то, не знаю что

Хорошо ли вы понимаете, что проверяют ваши тесты? Пфф, что за вопрос, ведь вы писали их на конкретный сценарий. На практике часто оказывается так, что тесты проверяют не то, что вы задумывали. Падают не в тех местах, где должны. Проходят, когда в коде есть ошибка. Например, вы решили написать позитивный тест на нашу функцию, сценарий “Если передать валидное значение количества порций, кот будет покормлен”. Тест может выглядеть как-то так:

Хороший ли это тест? А давайте проверим. Изменим в нашей функции условие на минимальное количество порций: 

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

Вывод номер раз: проверяйте, что ваш тест падает в ситуациях, когда должен упасть. Делать это для всех тестов, конечно, накладно, но я рекомендую это упражнение начинающим тестописателям, а также при написании сложных интеграционных тестов.

Вывод номер два: не забывайте про технику граничных значений.

Секунды имеют значение

Как определить, быстро ли проходят ваши тесты? Установить разумные для вашей системы и команды значения. Как определить, могут ли ваши тесты проходить быстрее? Экспериментировать. Часто тесты искусственно замедлены по одной из следующих причин:

  • содержат лишние действия

  • не используют моки там, где это оправдано

  • без необходимости обращаются к файловой системе

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

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

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

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

Скажи зависимости нет

Чаще всего для тестов (особенно сквозных end-to-end) вам нужно много мноооого данных. Некоторые команды приходят к решению использовать уже готовые - созданные руками на тестовой среде или скопированные с прода. В чем опасность? В том, что эти данные в любой момент могут поменяться, ваши тесты их не контролируют. Я много раз сталкивалась с нестабильностями в тестах именно по этой причине. Посмотрим, как это выглядит:

Что с этим делать? Создавать данные прямо в тестах. Не забывать чистить данные в конце. Не забывать про независимость тестов.

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

Вместо послесловия

Всё вышесказанное было выстрадано годами работы, но не является руководством к действию. Автор признает, что в вашей ситуации описанные изменения могут привести к ухудшению тестовой системы. Думайте своей головой, но не забывайте сверяться с реальностью. Всем бобра и зеленых автотестов!

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


  1. cupraer
    09.07.2023 04:06
    +7

    Никогда не устану поражаться, как люди упорно игнорируют единственно правильный способ тестирования подобного рода сценариев — property-based testing.

    Технологии 25 лет скоро, а такое ощущение, будто против нее заговор. Вместо того, чтобы изобретать вручную все стопиццот пограничных условий (и обязательно парочку пропустить) — можно же определить набор свойств для вашего тестируемого объекта, и проверять именно их, отдав бездушной машине право нагенерировать входные данные (включая такие, о которых ни разработчик, ни тестировщик, — никогда не подумает).

    В вашем случае — это два свойства: принадлежность кота и количество порций. Ну так и тестируйте ровно то, что хотите получить на выходе:

    1. когда кот мой, исключение AuthenticationException не выбрасывается, и наоборот.

    2. когда кот мой и количество порций между 1 и 4, проверяйте сколько кот уже съел, иначе проверяйте, что выбрасывается соответствующее исключение.

    Вот еще ссылка для шарпа: https://www.codit.eu/blog/property-based-testing-with-c/

    А вот оригинальная разработка: https://en.wikipedia.org/wiki/QuickCheck

    Джон Хьюз — умнейший человек и очень сильный разработчик, но в маркетинге он, явно, слабоват.


    1. robert_ayrapetyan
      09.07.2023 04:06

      Интересно, как проверить, например, функцию, убирающую пробелы и меняющую цифры на точки во входной строке с помощью этого подхода?


      1. cupraer
        09.07.2023 04:06
        +1

        функцию, убирающую пробелы и меняющую цифры на точки во входной строке

        Если это не фигура речи, и вам действительно интересно, то вот:

        ▸ свойство №1: в результирующей строке не должно быть пробелов и/или цифр
        ▸ свойство №2: в результирующей строке должны присутствовать точки в местах, где на входе были цифры или точки, на всех остальных позициях ввод должен быть сохранен

        В псевдокоде:

        for all input <- inputs do
          outcome = transformed(input)
        
          refute outcome.find('[[::space::]]')
          refute outcome.find('[[::digit::]]')
        
          idx = 0
          for i ∈ [0, length(input)) do
            case input_char
              '[[::space::]]' → assert true
              '[[::digit::]]' → assert(outcome[++idx]) == '.'  
              other → assert(outcome[++idx]) == other
            end  
          end
        end

        inputs вам нагенерит тестовый фреймворк, причем таких, о которых вы и подумать не сумеете.


        1. robert_ayrapetyan
          09.07.2023 04:06

          1. Можно ли контролировать inputs и насколько гибко?

          2. Есть ли поддержка моков (например, чтоб замоканный вызов БД также все время возвращал разный ответ)?


          1. cupraer
            09.07.2023 04:06
            +1

            Конечно можно, как угодно гибко.

            Моки-то тут причем? Они вообще ортогональны, не вижу, какая связь, нужны моки — сделайте моки.

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


  1. ultimatet41
    09.07.2023 04:06
    +2

    Я бы ещё рекомендовал юзать DI

    И если юзаются внешние либы, то не стоит забывать, что в них тоже могут быть баги)


  1. LeonidM
    09.07.2023 04:06

    Ну пример со сменой мин кол-ва порций и не должен быть красным. Проверка на условие меньше 2, а не меньше или равно.


    1. cupraer
      09.07.2023 04:06

      Речь, видимо, о том, что код изменился до неузнаваемости, а тесты вообще ничего про это не сказали. Так быть не должно, иначе основная функция тестов (валидация корректности рефакторинга) сводится к нулю.


      1. Scarethebear Автор
        09.07.2023 04:06

        Да, всё верно)


      1. M1straL
        09.07.2023 04:06

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


        1. cupraer
          09.07.2023 04:06

          Нет.


      1. iBljad
        09.07.2023 04:06

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


  1. M1straL
    09.07.2023 04:06

    "добавить обращение к заглушке вместо реального сервиса "

    Если сервис в какой-то момент изменит внутреннюю логику или вывод, то вы об этом из этого теста не узнаете. Поправьте если я не прав.

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


    1. Scarethebear Автор
      09.07.2023 04:06

      Для этого вам нужны интеграционные тесты.