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

Сегодня хочу поделиться опытом наших ИТ-специалистов, Евгении @heath9326, Дмитрия @bda82, Александра @ironfelixxx которые успешно сократили время тестирования на одном из проектов c часа до нескольких минут.


Навигация
Особенности проекта
А в чём проблема
Решение
Результаты
Что попробовать ещё


Особенности проекта

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

На проекте юнит- и модульному тестированию уделяется больше времени, чем интеграционным: из-за соотношения 1 тест сьют интеграции к 2–4 модульным сьютам. 

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

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

  • Множество личных окружений разработчиков и стендов: фронт, ТГ бот, шина сообщений, монолит Django. Это может привести к различиям в конфигурациях окружений, версиях библиотек и ОС. В результате один и тот же код может вести себя по-разному на разных машинах, что затрудняет воспроизведение и тестирование проблем.

  • Тестируем от 4 раз одно и то же. CI/CD и соблюдение Git-Flow с регламентом выпуска фичей предполагают несколько этапов: тестируем у себя, перед МР, перед выкладкой на каждый стенд. Отсюда больше временных затрат.

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

А в чём проблема

В начале разработки проекта в каждом тесте использовался метод def setUp(), что позволяло быстро писать тесты. Однако с увеличением количества тестов, время, затрачиваемое на тестирование, увеличивалось пропорционально.

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

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

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

— при деплое на стенды, особенно в дни релиза, когда тестирование проводилось на каждом из стендов.

 Поэтому мы решили оптимизировать все тесты проекта.

Решение

Мы использовали кастомный тестовый класс, который помогает эффективно подготовить тестовую среду к тестированию. Выбрали библиотеку django test-plus, которая предоставляет ряд полезных миксинов для тестирования различных аспектов проекта. 

Использование кастомного теста оказалось палкой о двух концах. Его внешняя легкость использования позволила долго игнорировать необходимость оптимизации и изменения подхода к тестированию.

Мы столкнулись с проблемами запуска тестов в случайном порядке и параллелизации тестов. Параллелизацию мы могли контролировать только локально, а на запуск  в случайном порядке требовалось больше времени, что мы не учли в самом начале.
Использование фреймворков типа unit test, nose, coverage и наших собственных фабрик также создавало сложности.

Практическое улучшение эффективности тестов

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

— Используйте Set up test data вместо def set up
Один из способов повысить эффективность — использовать метод setUpTestData() вместо def setUp(). Этот метод позволяет установить данные для теста, которые будут использоваться всеми тестовыми методами в классе.

Обратите внимание на разницу между @classmethod def setUpTestData() и def setUpClass(). 

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

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

— Устраните конфликты между Transaction Test Case и def SetupClass. В нашем случае конфликт возник, когда TransactionTestCase с флагом reset_sequences = True закрывает тестовую базу данных после каждого теста для всего -coverage, что может привести к потере связи с базой данных для def SetupClass.
Чтобы избежать этих конфликтов, мы использовали метод setUpTestData() из Django's TestCase класса, который не вызывает таких проблем.

Кроме того, все функции кастомного тестового класса должны быть переписаны на класс-методы, за исключением методов аутентификации пользователя, которые остаются в def Setup(). Это позволяет использовать их в классметоде setUpTestData.

Тесты, написанные с использованием Transaction Test Case в результате профилирования, стали занимать больше половины времени всех тестов, и их решили переписать с использование кастомного тест класса, наследующего от TestCase. Для этого потребовалось изменить логику работы тестируемых команд. Что потребовало дополнительных трудозатрат, но позволило закончить оптимизацию тестов.

Добавьте корректный тайминг

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

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

На unix-системах это команда time, на Windows — Measure-Command.

Нас интересует параметр real time. Два других — user и sys — отражают время, затраченное на выполнение программы и операционной системы. Real time будет превышать время внутри тестового раннера, так как включает параметры типа времени инициализации Python и Django. Различия могут быть существенными, если у вас есть библиотеки, которые медленно импортируются.

Исключить папки из coverage для эффективного отражения покрытия тестами. Исключённые папки и файлы не учитываются при подсчёте процента покрытия кода. Это может быть полезно, если есть части кода, которые не нуждаются в тестировании или которые сложно тестировать автоматически. Например, легаси код может искажать общую картину покрытия, так как для него нет тестов.

Укажите дефолтные состояние .env  для  coverage. Позволит исключить ложноотрицательные тесты.

Предположим, у вас есть переменная окружения DB_HOST, которая используется в вашем коде и должна быть проверена тестами. Однако, по умолчанию, значение этой переменной не установлено, и поэтому coverage не будет учитывать те части кода, которые зависят от DB_HOST, так как они никогда не выполнялись. Установка дефолтного значения для DB_HOST в .env файле позволит coverage считать эти части кода как покрытые, даже если конкретные тесты не устанавливают другое значение для DB_HOST.

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

В нашем случае запуск тестов в произвольном режиме позволил найти и исправить некоторые ошибки логики тестов. Для этого мы запускали coverage через скрипт непосредственно в pycharm.
При использовании -coverage --shuffle тесты падают потому что они запущены в непривычном порядке. Обычным отладчиком это поведение не сымитировать, потому что он запускает последовательно. На скрине пример настройки запуска -coverage --shuffle через отладчик, что позволило диагностировать почему тесты работали некорректно. Эта деталь позволила нам исправить все тесты.

Результаты

Затраченное на автотесты время сократилось в 10 раз

Было

Стало

Это повлияло на сокращение времени CI/CD пайплайна и обеспечило более быстрый деплой фичей и багфиксов.   

Что можно попробовать ещё

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

Например, мы заметили, что тест исполняется медленно, но мы ещё не знаем, что именно его тормозит. Для отслеживания можно использовать py-spy или встроенную библиотеку cProfile. 

Добавление TestRunner не ускорит сами тесты, но сократит время на тестирование.

  • Позволяет запускать тесты параллельно и выбирать количество ядер:

  • Использование параметра --keep-db сохраняет базу данных между тест ранами.

  •  Использование --no-migrations отключает миграцию.

Распараллеливание тестов с помощью фреймворка для тестирования или pytest-django. Мы не используем этот метод на проекте, так как тесты работают на виртуальной машине. Поэтому не рассматриваем в рамках статьи. Подробнее можно найти в книге Speed Up Your Django Tests от Adam Johnson. 

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

А если остались вопросы — давайте обсудим в комментариях

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


  1. danilovmy
    03.09.2024 15:16

    @Maxim_from_HW Огромное спасибо за очень взвешенную статью. Несравнимо лучше https://habr.com/ru/articles/837630/.

    Чуток споткнулся о ваш собственный testcase, особенно когда в django есть "TestCase", "TransactionTestCase", "SimpleTestCase", "LiveServerTestCase", а так же не документированный SeleniumTestCase. Но видать так было проще.

    Ну и объяснение отличия setUpTestData() и def setUpClass() не верное. Первое вызывается внутри второго, после проверок готовности базы данных и загрузки фикстур. (row 1466 django\test\testcases.py). в итоге setUpTestData просто способ что-либо еще сделать после того как масса тестовых данных попала в базу но перед запуском тестов.

    Спасибо так же за общие замечания про тестирование, вот бы все тесто-авторы про это знали! Буду давать своим новичкам это статью для ознакомления! Успехов в тестировании!


    1. Maxim_from_HW Автор
      03.09.2024 15:16

      Спасибо за комментарий!

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

      Насчет разницы между setUpTestData() и def setUpClass() — в вашем определении не вижу противоречия с нашим. Да, def setUpClass() вызывается внутри setUpTestData(). Но так же он конфликтует с TransactionalTestCase, которые мы используем в тестировании. setUpTestData() такого конфликта не вызывает, поэтому именно его и выбрали