3 варианта использования для написания надежных тестовых примеров в вашей кодовой базе

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

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

Давайте начнем!

Фон

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

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

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

Причины периодически проваленных тестов

Может быть как минимум несколько причин, по которым ваши тесты могут вести себя так:

  • Тестируемая система (SUT) зависит от внешних ресурсов, например. он использует файлы, хранящиеся в месте, недоступном из тестовой среды, или вызывает реальную веб-службу.
  • Один тест зависит от другого
  • Тесты используют общие зависимости (например, базу данных) и не очищают тестовую среду после выполнения.

Случай №1: Тестируемая система зависит от внешних ресурсов

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

Если вы реализуете тесты, а тестируемый код использует такие зависимости, есть несколько моментов, на которые стоит обратить внимание:

  • Какие тесты вы пишете?
  • С какими типами зависимостей вам приходится иметь дело?

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

С другой стороны, даже если вы пишете интеграционные тесты, это не означает автоматически, что вы должны использовать все эти внешние зависимости.

Итак, как же выглядит тестируемая система, которая зависит от внешних ресурсов?

Чтобы лучше понять эту концепцию, давайте представим следующий сценарий:

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

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

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

Вдруг вы только что вспомнили, что установка системы политик происходит каждый день в 10:00. Угадайте, в какое время «юнит» тест провалился :). Оказывается, реализованный вами класс пытался подключиться к реальному веб-сервису, который был недоступен из-за процесса установки.

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

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

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

Случай № 2: Тестовые случаи зависят от порядка выполнения

Когда дело доходит до модульного тестирования, мы не должны полагаться на порядок, в котором выполняются наши тесты. Например, если testA() создает какие-то данные, мы не должны с уверенностью предполагать, что к этим данным может получить доступ testB().

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

Лично мне больше всего нравится учиться на примерах, поэтому давайте рассмотрим следующий случай:

Как видите, сначала в test_1() мы создаем объект testClaim с состоянием OPEN, затем утверждаем, что состояние утверждения правильное, и, наконец, переходим к выполнению test_2().

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

Отчет о тестировании полностью зеленый, и мы рады, что продвигаем передовой опыт написания тестов в нашей команде.

Если все работает как по маслу, давайте сделаем небольшой перерыв на кофе, а затем ☕.

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

Наш коллега только что закончил рефакторинг и перезапустил весь набор тестов, и что случилось с отчетом? Хорошо…

Как видите, один из тестов неожиданно провалился. Что только что произошло с этим тестом?

Наш напарник переименовал названия тестов, а побочным эффектом этого стал измененный порядок выполнения. Оказывается, в данном конкретном случае мы полагались на поведение по умолчанию библиотеки тестирования (JUnit 5.8.2), которая по умолчанию запускает тесты в алфавитном порядке. Даже если мы уже знаем это, мы не должны этим пользоваться, потому что это всего лишь деталь реализации библиотеки. Мы никогда не знаем, изменится ли это поведение в будущих версиях, поэтому мы должны просто притвориться, что не знаем, в каком именно порядке будут выполняться наши тесты.

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

В нашей первой попытке написать тесты нам посчастливилось сопоставить наш образ мышления с поведением JUnit, так что все тесты прошли. Мы ожидали выполнить test_1(), а затем test_2() соответственно.

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

Чтобы исправить это:

  • Если тестируемый объект отличается от случая к случаю, создайте отдельные версии внутри каждого метода тестирования (переместите объявление переменной testClaim с уровня класса на метод тестирования)
  • Если создание тестового объекта может быть одинаковым для каждого тестового примера, но этот объект не должен использоваться совместно тестами, избавьтесь от ключевого слова static рядом с переменной и используйте функциональные возможности, предоставляемые используемой вами библиотекой тестирования, что позволяет вам вызывать определенный метод перед каждым тестом, например, аннотацию @Before или @BeforeEach в JUnit

Давайте посмотрим, как мы можем решить эту проблему:

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

Случай №3: Тесты, которые забывают убирать за собой

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

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

Для простоты я покажу вам пример того, как ваши тесты могут потерпеть неудачу из-за файловой системы:

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

Теперь вы запускаете набор тестов для InvoiceReader:

Затем вы запускаете набор тестов для InvoiceWriter, и все его тесты пройдены:

Однако, когда вы запускаете все тесты вместе, они терпят неудачу:

Что там произошло? Тест внутри InvoiceReaderTest предполагает, что файл не существует в данном каталоге, но на самом деле он существует. Это потому, что предыдущий тестовый класс BulkInvoiceTest создал файл во время выполнения, но сохранил его, а не удалил.

Чтобы устранить эту проблему, вам необходимо очистить тестовую среду после завершения тестового класса. Вы можете сделать это, вызвав функцию, которая позаботится об удалении всех ранее созданных файлов. В случае Java и JUnit вы можете использовать аннотацию @AfterAll:

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

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

Лучшее решение таких вопросов — завести привычку думать наперед — задать себе вопрос: «Какие общие ресурсы я использую в своем тестовом классе?»

Что дальше?

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

Рекомендации

[1]: Кристиан Шпичаковски, Давайте понюхаем тесты #0 https://medium.com/@kszpiczakowski/lets-smell-some-tests-0-c3a2ddbf7fbb

[2]: Кристиан Шпичаковски, Сделайте свой устаревший код снова пригодным для тестирования https://medium.com/@kszpiczakowski/make-your-legacy-code-testable-again-becdb5212c38