На протяжении последних 25 лет меня увлекает вопрос повышения производительности инженеров в крупных ИТ-компаниях. Уже три года в качестве старшего инженера в Google я занимаюсь инфраструктурой для разработчиков в области интеграционного тестирования. До этого я проработал 11 лет в Amazon, где занимал должность ведущего инженера в подразделении, занимающимся инструментами для разработчиков. Мы создавали внутренние инструменты для работы с репозиториями, проведения код-ревью, локальной разработки, сборки ПО, непрерывного развёртывания, а также для разных видов тестирования: модульного, интеграционного, канареечного, нагрузочного и тестирования производительности. Ещё ранее я 11 лет трудился в Microsoft в качестве лида команды инженеров в различных подразделениях Office и Windows в 1990-х. Этот практический опыт в Google, Amazon и Microsoft дал мне представление о том, как сотни тысяч разработчиков пишут, проверяют, тестируют и развёртывают код в масштабных проектах.
Особенно меня интересует, как небольшие моменты неэффективности в таких крупных компаниях могут приводить к потере миллионов, а то и сотен миллионов долларов, связанной с продуктивностью, пустой тратой аппаратных ресурсов или упущенными бизнес-возможностями. Я стремлюсь облегчить жизнь инженеров, устранить рутину, повысить эффективность и поднять уровень инженерного и операционного мастерства.
Одной из самых раздражающих проблем, с которыми я сталкивался за последние 25 лет в индустрии, являются нестабильные тесты (flaky tests) — тесты, которые чаще всего проходят, но иногда необъяснимо завершаются ошибкой без явных на то причин. Вы отправляете код в репозиторий, а через двадцать минут получаете уведомление о том, что набор тестов завершился ошибкой. Вам приходится бросать всё, чем вы занимались, чтобы разобраться в причине сбоя. В итоге вы приходите к выводу, что причина явно не в ваших изменениях, и прибегаете к старому как мир трюку — запускаете тест повторно. На этот раз тест проходит успешно, и вы думаете: «Опять нестабильный тест… Ну ладно». И возвращаетесь к более важным задачам.
Это происходит нерегулярно и недостаточно часто, чтобы вы или ваши коллеги решили потратить день на исправление проблемы. Но если остановиться и подсчитать, сколько рабочего времени теряется из-за этого в течение года, результат может быть удручающим: множество часов, которые могли бы быть использованы на что-то действительно полезное.
Более того, сам факт существования нестабильных тестов подрывает доверие к ним. Когда такие тесты теряют доверие, они теряют и ценность: люди перестают воспринимать их всерьёз и просто игнорируют ошибки, что может скрывать реальную проблему в продакшен-среде.
Каковы источники нестабильности тестов? Я бы разделил их на три категории:
Категория #1: Нестабилен тестовый код.
Категория #2: Нестабилен код в продакшене.
Категория #3: Или всё остальное.
Категории #1 и #2 включают состояния гонки (race conditions), типичные проблемы многопоточности и недетерминированное поведение. Но, в конце концов, вы отвечаете за свой тестовый код и за свой код в продакшене, а значит, управляете своей судьбой. Поэтому исправьте их!
Меня больше всего беспокоит категория #3 — она представляет собой огромную свалку проблем, которые в основном находятся за пределами моего контроля, из-за чего чувствуешь себя немного беспомощным. Это проблемы инфраструктуры, которые вы сами не можете исправить. Обычно это вообще не ваша ответственность. Однако в Google, так как я работаю в команде, отвечающей за инфраструктуру, которой пользуются тысячи сотрудников для запуска интеграционных тестов, эти проблемы — очень даже наша забота.
Должен признаться, что в начале своей карьеры я наивно полагал, что большинство проблем с нестабильностью тестов относятся к категориям #1 или #2. Но со временем я понял, что всё сложнее, чем кажется, и категория поистине #3 огромна.
Я также осознал, что эта проблема невероятно трудна и похожа на луковицу: снимая слой за слоем, сталкиваешься с новыми уровнями. В этой статье я не предложу серебряной пули, но хочу объяснить, почему эта проблема так сложна, и показать часть этой сложности.
Чтобы понять источники нестабильности в этой категории, давайте сначала посмотрим на модульные тесты и затем обсудим, чем они отличаются от интеграционных тестов.
Небольшая викторина: что общего у этих двух картинок?
А вот что: у них много модульных тестов и ноль интеграционных тестов!
Чтобы мы говорили об одном и том же: привожу определения, которые будем использовать в этой статье.
Модульное (или юнит-) тестирование — это процесс тестирования отдельных компонентов или модулей кода в изоляции.
Интеграционное тестирование — это процесс тестирования взаимодействия и совместной работы различных модулей кода.
Системное интеграционное тестирование — это разновидность интеграционного тестирования, которая фокусируется на проверке всей системы или приложения в целом.
С точки зрения инфраструктуры модульные тесты довольно просты. У нас есть тестируемый код и код тестов. Обычно они выполняются на одной машине, в одном процессе, часто даже в одном потоке (если только вы не тестируете асинхронный или многопоточный код). Это означает, что существует не так много переменных, которые могут привести к сбоям. Нестабильность модульных тестов обычно связана с категориями #1 и #2 и редко с категорией #3.
Интеграционные тесты устроены иначе, потому что для них обычно требуется развернуть тестируемый код на каком-то сервере. В Google мы используем сокращение SUT.
Эта SUT не обязательно находится на одной машине, в одном процессе или одном потоке. Это распределённая система, которая может требовать развёртывания и выполнения на множестве машин в дата-центрах. А ваш код тестов, скорее всего, будет находиться на другой машине, где-то в облачной среде.
Зависимости — моя вечная головная боль
Для начала, если ваша SUT не полностью самодостаточна, у неё будут зависимости. Много зависимостей.
И проблема окажется хуже, чем вы могли предположить, потому что у этих зависимостей тоже есть зависимости, у которых, в свою очередь, есть свои зависимости, и так далее. Добро пожаловать в проблему транзитивных зависимостей! Граф ваших зависимостей быстро становится огромным.
Ещё одна задачка для вас. Представьте, что ваши интеграционные тесты абсолютно безупречны, и ваш продакшен-код тоже абсолютно безупречен. В этом фантастическом мире они оба на 100% надёжны (браво!). Это значит, что никаких проблем из категорий #1 или #2 быть не может, и единственная причина нестабильности — категория #3.
У вас есть всего три зависимости с надёжностью 99%, 95% и 92%. Какая будет общая надёжность ваших тестов?
Это простая математическая формула. Общая надёжность вычисляется как произведение (Pi) надёжностей всех зависимостей, которые могут привести к сбою вашего теста, если они недоступны или работают с ошибками. Таким образом, в лучшем случае надёжность ваших тестов будет равна: 99% * 95% * 92% = 87%
Когда вы понимаете, как работает эта математика, становится очевидным, что даже небольшая ненадёжность каждой из зависимостей складывается в значительную ненадёжность.
Какая же зависимость может быть настолько плохой, чтобы её надёжность была всего 92%? Спросите вы. На самом деле, часто бывает так, что вашей тестовой среде запрещено обращаться к продакшен-среде зависимостей, потому что такой вызов не является идемпотентным (то есть он меняет состояние зависимости). В этом случае вы вынуждены использовать тестовую среду зависимости, которая, вероятно, не такая стабильная, как продакшен-среда.
Что происходит, когда акула прегрызает ваш сетевой кабель?
Ещё в 90-х Дойч и другие специалисты из Sun Microsystems сформулировали заблуждения о распределённых вычислениях. Первые три из них:
Сеть надёжна
Задержки равны нулю
Пропускная способность бесконечна
Эти три утверждения имеют прямое отношение к категории #3. В интеграционном тестировании вся коммуникация между вашими тестами и SUT или между SUT и его зависимостями, скорее всего, происходит по сети — иногда внутри страны, а иногда и по всему миру.
Что произойдёт, если акула перегрызёт ваш сетевой кабель?
Вы мне не верите? Вот фото акулы, которая на самом деле грызёт (или пытается перегрызть) сетевой кабель:
Любая нестабильность в сети напрямую превращается в нестабильность ваших тестов. Более того, помимо нестабильности, такая коммуникация ещё замедляет процесс, поэтому ваши тесты занимают гораздо больше времени, чем должны, просто потому, что они «ждут» завершения сетевого вызова.
Скорость света по прямой линии от Сан-Франциско до Нью-Йорка составляет 16 мс, но в реальности передача данных между восточным и западным побережьями США занимает 60–70 мс. Это может показаться незначительным, но если у вас десятки или сотни сетевых вызовов для каждого теста, а тестов тысячи, это складывается в значительные задержки.
Состояние системы — ещё одна моя головная боль
Если вам не повезло и вы тестируете stateless-систему, вашему тесту потребуется, чтобы SUT содержала какое-либо состояние, чтобы ответы на вызовы к SUT имели смысл.
Например, если вы тестируете банкомат, вам может понадобиться убедиться, что в SUT есть тестовые аккаунты и балансы, чтобы при вызове GetBalance("Carlos") вернулся ожидаемый вами результат. Это задача по предварительному наполнению SUT данными, обычно в ту базу данных, которая является частью вашей архитектуры.
Предварительное наполнение данными — это само по себе сложная задача. Наполнять ли данными перед запуском всего набора тестов, или позволить каждому тесту самостоятельно добавлять нужные ему данные? В любом случае, это создаёт ещё одну точку потенциального сбоя, даже если ваши тесты сами по себе работают идеально. Для некоторых наших сложных интеграционных тестов требуется загрузка гигабайтов данных в базу. Это увеличивает время выполнения, добавляет сложность к тестам… а также создаёт ещё одну точку отказа.
Если ваш тест заполняет данные перед запуском и меняет состояние SUT, проводит ли он чистку после завершения? Восстанавливает ли он систему в исходное состояние, чтобы следующий тест проходил в чистой системе? Тест X может провалиться из-за того, что тест X-n оставил после себя временное состояние. Технически это относится к категории #1 (нужно исправить тестовый код!), но это часто воспринимается как проблема из категории #3, просто потому что состояние — это часть инфраструктуры тестовой среды.
Я впервые столкнулся с этой проблемой ещё в 90-х, когда работал в Microsoft. Я был QA-лидом и отвечал за выпуск всех инструментов проверки грамматики и орфографии в Microsoft Office XP. Я настроил cron-задачу, чтобы ежедневно пропускать миллионы предложений через наши инструменты проверки, чтобы убедиться, что они не вызывают сбоев.
Каждое утро, приходя на работу, я смотрел результаты за ночь, и где-то всегда был сбой с отчётом, например: «Предложение ‘xyz’ вызвало сбой испанского проверщика грамматики». Тогда я вручную пропускал предложение ‘xyz’ через этот проверщик, чтобы воспроизвести проблему перед тем, как написать баг-репорт, но оно не вызывало сбоя. Это дико раздражало!
В конце концов я понял, что, возможно, за тысячи предложений до xyz какое-то другое предложение повредило участок памяти (это был старый код на C, известный такими проблемами, как переполнения буфера и ошибки, связанные со смещением индекса на одну позицию при работе с массивами). Это повреждение было недостаточным, чтобы полностью сломать систему, но достаточным, чтобы гораздо позже, когда проверщик дошёл до xyz, он столкнулся с повреждённой памятью.
Реальная проблема заключалась в том, что состояние было нарушено задолго до моего теста, но это было не сразу очевидно. Технически это относится к категории #2, так как проблема заключалась в нестабильном продакшн-коде.
Я в итоге написал сложную систему отладки, чтобы с помощью бинарного поиска по миллионам предложений в корпусе выявить минимальную комбинацию предложений, которая нарушала состояние. Это был увлекательный вызов с точки зрения компьютерных наук, но и настоящая головная боль.
Даже в идеальном мире, где каждый тест полностью очищает состояние после себя, возникают сложности из-за одновременного выполнения тестов. Можно ли запускать несколько тестов одновременно? Если да, существует ли риск их конфликта? Если у вас есть статическая, долгоживущая тестовая среда, где одновременно выполняются несколько наборов тестов, вы можете получить неожиданные, невоспроизводимые результаты.
Есть ли решение всему этому сумасшествию?
Увы, волшебного средства от нестабильности интеграционных тестов не существует. Эта проблема преследует нашу отрасль уже десятилетиями, даже в таких компаниях, как Google, Amazon или Microsoft. Однако есть ряд мер, которые можно предпринять.
В Google мы используем герметичные (полностью изолированные) и временные тестовые среды, чтобы смягчить эту проблему. Это, в свою очередь, вызвало собственный набор сложностей, которые нам пришлось решать, а также усложнило нашу инфраструктуру. Тем не менее, это принесло и заметные преимущества. Если интересно, я поделюсь нашей историей в следующей статье.
Все актуальные методы и инструменты тестирования можно освоить на онлайн-курсах OTUS: в каталоге можно посмотреть список всех программ, а в календаре — записаться на открытые уроки.
Кстати, ближайший урок пройдет 21 ноября, на тему тестирования REST API-сервисов на Python. Если интересно, подробности по ссылке.