Меня зовут Сергей, и я инженер автоматизации тестирования.

Компания, в которой я сейчас работаю, занимается разработкой программного обеспечения, краеугольным камнем которого являются различные алгоритмы: расчёта значений, построения графов связей, проверки состояний и т.п. В связи с этим, нам очень важно уделять особое внимание unit-тестированию.

Один из моих коллег-автоматизаторов упомянул, что к нему обращаются разработчики с вопросом: "А как написать unit-тест?". Не конкретный тест, а "в принципе". Это послужило для меня поводом подготовить эту статью, и адресована она молодым программистам. Они смогут ознакомиться с рекомендациями, которым стоит следовать при разработке unit-тестов. Но также может быть любопытна и QA-инженерам - ведь полезно получить представление об аспектах тестирования, выполняемого разработчиками.

Материал подготовлен на основании книги "The Art of Unit Testing, Second Edition" за авторством Роя Ошерова (ISBN: 9781617290893). Настойчиво рекомендую к прочтению, так как там тема раскрыта более полно, и с практическими примерами.

Что такое unit-тест?

Для начала, мне стоит дать определение, которого я придерживаюсь, когда говорю о unit-тесте.

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

Этот код надежный, читаемый, поддерживаемый. Вместе с тем, что очень важно, этот код не имеет внешних зависимостей, и имеет полный контроль над объектом тестирования. Именно это отличает unit-тесты от интеграционных.

Если рассмотреть свойства unit-теста, то можно прийти к следующему набору качеств, которые такой тест должен иметь:

  1. Исполняется автоматически и часто. Для того чтобы это качество было, тесты должны быть интегрированы в процессы CI. Код приложения часто изменяется, и необходимо контролировать его качество как минимум с этой же частотой. Минимум тесты должны исполняться в момент сборки. Лучше расширить список поводов для запуска тестов, и включить в него: ежедневные (ночные) запуски; запуск перед поставкой; запуск перед выгрузкой кода в репозиторий (пушем).

  2. Легкий во внедрении. Подобный тест должно быть легко (быстро) разработать и добавить к тестовому набору. Если вы видите, что тест разрабатывать долго, то это признак того, что или вы пишете не unit-тест, или вы неправильно выбрали "размеры" объекта тестирования. Да, тестируемые модули могут быть разными по размеру. Это могут быть и отдельные методы, и несколько классов.

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

  4. Легко исполняемый. Каждый участник команды разработки должен иметь возможность запустить тест. Как локально, так и на CI-сервере. Это позволит быть уверенным любому, что он не сломал чужой код.

  5. Быстрый. Unit-тесты исполняются если не за доли секунды, то за секунды. Это является гарантией того, что они будут исполняться часто. Никому не хочется долго ждать завершения тестов. И, зачастую, продолжительные тесты просто не выгодно запускать, если у нас есть ограничения (к примеру, небольшое количество сборщиков на CI; или нам срочно нужно сделать поставку кода).

  6. Консистентный. Всегда должен быть один и тот же результат при каждом исполнении теста. Это одно из главных условий стабильности тестов.

  7. Имеет полный контроль над объектом тестирования (модулем). Это значит, что тест исключает и подменяет "общение" модуля с любыми внешними источниками: БД, файловая система, системное время, различные генераторы и т.д. В противном случае, тест не может быть консистентным.

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

Чтобы добиться получения и сохранения описанных качеств, необходимо следовать некоторым правилам.

Базовые правила разработки unit-тестов

Начну с самого очевидного – разрабатывайте unit-тесты в специализированном фреймворке. Независимо от языка программирования, который вы используете, возможно найти подходящий xUnit фреймворк, который предоставит возможность:

  1. Разрабатывать структурированные тесты. Как минимум, у вас будут атрибуты, которые будут помечать методы как "тесты".

  2. Использовать готовые методы для разных типов проверок. Использование таких методов увеличит читаемость кода и упростит анализ исполненных тестов.

  3. Формировать тестовые наборы. Вы сможете логически объединять тесты в группы, основываясь на каком-нибудь признаке. Например, относительно функционала.

  4. Видеть успешность проверок в момент их исполнения.

  5. Проводить анализ исполненных тестов: сколько тестов исполнилось, сколько не исполнялось, какие результаты проверок, причины провалов, и т.д.

  6. Разрабатывать параметризованные тесты. А значит драматично уменьшить количество необходимых строк кода при сохранении уровня тестового покрытия.

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

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

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

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

Давайте вашим тестам понятные и ёмкие названия. Считается хорошей практикой использовать следующий шаблон для наименования: ОбъектТестирования_ПроверяемыйСценарий_ОжидаемыйРезультат. На пример: UserLogon_UnknownLogin_Code401Returns. И не опасайтесь того, что сигнатуры тестов непривычно длинные, не соответствуют стандартам остального кода. Это просто особенность, которая позволяет увеличить читаемость, понятность теста.

И вот, фреймоворк выбран, структура подготовлена. Можно начать писать тесты. Но что же проверять? Следует соблюдать следующий порядок, при выборе результата взаимодействия с тестируемым объектом: возвращаемое значение, переход в состояние, взаимодействие с другими объектами. Есть некий объект тестирования и мы должны в первую очередь проверить значения, которые он возвращает. Далее проверяем изменения состояния объекта тестирования. И в последнюю очередь проверятся взаимодействие с другими объектами (в этом помогут фикции, о них позже).

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

  1. Объявление, создание, настройка объекта тестирования. Фактически, тут вы определяете то, что, и в каких условия будет тестироваться.

  2. Взаимодействие с объектом тестирования. Происходит вызов метода с нужными параметрами.

  3. Проверка результата. Результат должен проверяться при помощи нужных методов, которые вам предоставляет выбранный фреймворк тестирования.

Проектируйте ваши тесты без использования "Before" (или "SetUp") и "After" (или "TearDown") методов. Упомянутые методы необходимы, соответственно, для приведения объекта тестирования к нужному состоянию, и для возвращения состояния к первоначальному. Если после исполнения тестового метода вам нужно "откатывать" состояние, значит вы разрабатываете интеграционный тест. Вам ведь не это было нужно?

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

А теперь об одной из важнейших сторон unit-тестирования. Использование симуляций. Симуляция позволит не только обеспечить консистентность, быстроту исполнения, но и выполнять проверки исключительных ситуаций, которые весьма трудно произвести с реальными внешними зависимостями. Если кратко - позволит обеспечить полный контроль над объектом тестирования.

Симулируйте взаимодействие объекта тестирования с внешними зависимостями. В качестве внешних зависимостей могут выступать разные объекты: файловая система, потоки, программные интерфейсы, время и т.д. Для симуляции используйте фикции (mock) или заглушки (stub). Конечно, для того чтобы была возможность с легкостью подменять внешние зависимости симуляцией, ваш код должен иметь достаточный уровень абстрактности (но не увлекайтесь, иначе он станет излишне запутанным), и соответствовать принципу открытости-закрытости. Про то, как сделать свой код более тестопригодным, можно узнать из книг "Working Effectively with Legacy Code" за авторством Майкла Физерса (ISBN 978-5-8459-1530-6) и "Clean Code" Роберта Мартина (ISBN 9780132350884).

Инкапсулируйте код исходя из того, что его модули будут объектами тестирования. Предусмотрите возможность изменять область видимости при необходимости. Желательно не использовать отражения (reflection) для доступа к полям и методам. Зачастую лучше отказаться от проверки приватных методов вовсе, так как необходимо вносить изменения в объект тестирования, и тест не сможет считаться «чистым». Нам это не нужно. Полезнее найти публичный метод, который использует нужный приватный метод, и сконцентрироваться на его проверке. Это даст больше гарантий того, что всё отрабатывает как ожидается.

Соблюдение приведенных правил позволит вам приблизиться к главным свойствам unit-тестов: надежности, поддерживаемости и читаемости. Чтобы уже разработанные тесты не потеряли со временем эти свойства, необходимо поддерживать их актуальность. Всегда проводите анализ существующего тестового набора, когда: выявляются дефекты пользователем; выявляются дефекты в самом тесте; вносятся изменения в объект тестирования; выявляются конфликтующие/дублирующие тесты.

Бонус: базовые техники тест-дизайна

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

  1. Классы эквивалентности. Это хорошее решение для случаев, когда вы имеете дело с большим количеством вариантов данных для ввода.

  2. Граничные значения. Тут данные тоже группируются по эквивалентным классам, но фокусируются проверки на значения, которые находятся на «границах» классов.

  3. Таблица состояний. Этот метод эффективен при создании наборов тестов для систем со множеством вариаций состояний. Он предназначен для тестирования последовательности событий с конечным числом входных параметров.

  4. Попарное тестирование. Суть метода – сопоставление данных. Комбинаторика приходит нам на помощь, когда нужно охватить тестами максимум функционала, и при этом потратить минимальное время.

Эти и многие другие техники хорошо известны QA-инженерам. Не стесняйтесь обращаться к ним.

Вместо заключения

Хочу обратиться ко всем разработчикам (если кто-нибудь из них добрался до этих строк). Код, который не покрыт тестами – это legacy код. Никому не хочется с ним работать. Поэтому, пожалуйста, пишите unit-тесты. А QA-инженеры, я надеюсь, окажут вам в этом посильную помощь.

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


  1. Metotron0
    13.09.2023 15:46

    пожалуйста, пишите unit-тесты

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

    Я до сих пор в поисках статьи о том, что следует тестировать во фронтенде. Таких общих статей уже несколько видел, а с конкретными примерами не густо.


    1. bzzz1k Автор
      13.09.2023 15:46
      +1

      Привет. Как раз в книге по которой статейку написал, есть глава про "рост затрат времени". Очень рекомендую. Если кратко - растет время на разработку, но снижается время на поставку.

      Ищешь, что тестировать unit-тестами на фронте? Могу запилить небольшую статью на эту тему. Подтверди, плиз, что я правильно понял.


      1. Metotron0
        13.09.2023 15:46

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

        И вот что тут можно протестировать? Когда я пишу на vue v-on:click="$emit('add-to-cart')", то я уверен, что элемент сгенерирует это событие, мне нечего тут проверять. Если я пишу <button v-if="isAbleToAddToCart">Добавить в корзину</button>, то я уверен, что эта кнопка будет показана только если выполнилось какое-то условие, переключившее переменную в true. Получается, нужно лишь проверить, что это условие работает правильно. Но у меня зачастую такие вещи присылает сам сервер, или всё условие состоит из return this.inStockCount > 0, даже не знаю, стоит ли тратить время на то, чтобы задать два разных значения inStockCount и посмотреть, исчезнет ли кнопка. Конечно, исчезнет.

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


        1. tommyangelo27
          13.09.2023 15:46
          +1

          Тривиальные вещи тестировать и не нужно.


          1. Metotron0
            13.09.2023 15:46

            Других не вижу. Может, у меня уже опыт набрался, что я всё вижу довольно тривиальным.

            Вчера слушал разговор про отказ от TypeScript в некоторых проектах. Говорят, там профессионалы работают, они и так без ошибок пишут. Я TS ещё даже не распробовал, чтобы отказываться. Надеюсь, тесты не начнут выкидывать до того, как я с ними разберусь.


        1. bzzz1k Автор
          13.09.2023 15:46

          Как понимаю есть пара сложностей:

          1. Непонятно, что покрывать unit-тестами. Кажется, что все методы довольно просты, и в их логике трудно совершить ошибку (допустить дефект).

          2. Нет опыта разработки unit-тестов на front-end. На текущем проекте нет в этом необходимости.

          Если разбирать проблемы по частям, то...

          Покрывать unit-тестами на фронте нужно алгоритмы (или код с несколькими зависимостями), и то, что планируется к рефакторингу.

          К примеру, на одном из моих проектов, ещё с этапа mvp "висит" часть агрегации данных (собственно, алгоритм), которые пришли с бэка, на фронте. Сейчас это проверяется функциональными UI-автотестами. А значит, проверка занимает больше времени, требует ресурсы на конфигурацию окружения, не такая стабильная как могла бы быть, и т.д. И, разумеется, никто это рефакторить не хочет: нет unit-тестов == код legacy == никто не хочет работать с legacy. Если кто-то бы захотел, то, как вариант, стоит покрыть код unit-тестами, и вперед - red-green-refactor.

          Про отсутствие опыта. Ты же знаешь о jest. Нечего покрыть на текущем проекте из-за простоты дизайна? Не думаю, что стоит ждать подходящий проект, где можно будет научиться. Обычно, на таких проектах уже нужно уметь. Создай свой проект, под эти нужды. Начни это делать, и, со временем, количество проделанной работы точно приведет к качеству "умение в unit-тесты". Уверен, что у тебя получится.


          1. Metotron0
            13.09.2023 15:46

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

            Хочется посмотреть на какие-нибудь примеры, чтобы понять, что конкретно другие люди тестируют, и как это делают. Пример доходчивее словесного описания абстракции. А уже поняв пример, можно обобщать и рассуждать обо всём остальном.


    1. pqbd
      13.09.2023 15:46

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

      Нужно тестировать библиотеки, общие компоненты (Типа ui-kit (кнопочки и т.п.) из которого строятся все странички), утилиты - короче, библиотечное или не UI-ое

      А остальное лучше сразу end-to-end

      IMHO


      1. bzzz1k Автор
        13.09.2023 15:46
        +1

        Вот количество e2e тестов в проектах моей компании, и побудило меня (в том числе) написать статью.

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

        Отсутствие unit-тестов не позволяет, соответственно, проводить интеграционное тестирование инкрементным методом - в модулях-то мы не уверены.

        И сейчас, в основном, тестирование вынуждено концентрирует усилия на e2e проверках, и на интеграционном тестировании с подходом big bang. Приводит это к огромным тестовым наборам из e2e кейсов, а значит к очень дорогому и неповоротливому тестированию. Короче, тема для отдельного и нудного повествования. И закончится всё холиваром )


        1. pqbd
          13.09.2023 15:46
          +1

          Ну, тут мы же про фронтэнд в отрыве от остального :)


  1. insighter
    13.09.2023 15:46

    Обычно юнит-тесты разработчики пишут, а не QA. Они просто более в теме, как нужно протестировать то, что они только, что написали.

    QA - пишет функциональные, UI тесты и т.п.


    1. bzzz1k Автор
      13.09.2023 15:46

      Да, конечно, unit-тесты пишут разработчики. И они могут обратиться к QA с вопросом о конкретных кейсах. А QA подскажут - каким минимальным набором проверок можно покрыть конкретные функции.


  1. pqbd
    13.09.2023 15:46

    А что такое в определении "автоматизированный код"? Как-то цепляет и вызывает странное неприятное чувство.

    Когда вижу "надёжный, читаемый, поддерживаемый" так и хочется добавить "выберите два из трёх", хоть это и не в тему здесь.

    Я бы упомянул где-нибудь слово "независимый", ведь это реально важное свойство теста (у вас это, наверное, подразумевается в "консистентный")


    1. bzzz1k Автор
      13.09.2023 15:46
      +1

      Автоматизированный код... неудачно подобрал сокращение, спасибо. Имеется ввиду код для задач автоматизации.

      Да, Вы правы. Независимость сама по себе не нужна - только для обеспечения качества консистентности. Для этого тест быть независимым от окружения, состояния тестируемого объекта, и от других тестов.


      1. pqbd
        13.09.2023 15:46

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


        1. bzzz1k Автор
          13.09.2023 15:46

          О вкусах не спорят ) Соглашусь с Вами.


  1. AlexKMK
    13.09.2023 15:46

    Нам бы в команду такого автоматизатора как автор.