
Функциональные тесты — вещь полезная. Поначалу много времени они не занимают, но проект растёт, и тестов нужно всё больше и больше. Терпеть замедление скорости доставки мы не были намерены и, собравшись с силами, ускорили функциональные тесты в три раза. В статье вы найдёте универсальные советы, однако, особый эффект вы заметите именно на больших проектах.
Коротко о приложении
Моя команда разрабатывает публичное API, которое предоставляет данные пользователям 2ГИС. Когда вы заходите на 2gis.ru и ищете «Супермаркеты», то получаете список организаций — это и есть данные с нашего API. На наших 2000+ RPS почти каждая проблема становится критичной, если ломается какая-то функциональность.
Приложение написано на Scala, тесты — на PHP, база данных — PostgreSQL-9.4. Функциональных тестов у нас порядка 25000 штук, они проходят за 30 минут на специально выделенной виртуалке для общей регрессии. Нас продолжительность тестов особо не напрягала — мы привыкли, что на старом фреймоврке тесты могли идти 60 минут.
Как мы ускорили и так «быстрые» тесты
Все началось случайно. Как обычно и бывает. Мы поддерживали одну фичу за другой, попутно пописывали тесты. Их количество росло и необходимое время на выполнение — тоже. Однажды тесты начали вылезать за отведенные им лимиты по времени, а следовательно процесс их выполнения завершался принудительно. Незавершенные до конца тесты чреваты пропущенной проблемой в коде.
Мы проанализировали скорость выполнения тестов и задача по их ускорению резко стала актуальной. Так началось исследование под названием «Тесты работают медленно — исправляй».
Ниже описаны три большие проблемы, которые мы нашли в тестах.
Проблема 1: Неправильно использовали jsQuery

Все данные у нас хранятся в базе PostgreSQL. В основном — в виде json, поэтому мы активно используем jsQuery.
Вот пример запроса, который мы делали в БД, чтобы получить нужные данные:
SELECT * FROM firm WHERE json_data @@ 'rubrics.@# > 0' AND json_data @@ 'address_name = *' AND json_data @@ 'contact_groups.#.contacts.#.type = “website”' ORDER BY RANDOM() LIMIT 1
Легко заметить, что в примере несколько раз подряд используется json_data, хотя правильно было бы написать так:
SELECT * FROM firm WHERE json_data @@ 'rubrics.@# > 0 AND address_name = * AND contact_groups.#.contacts.#.type = “website”' ORDER BY RANDOM() LIMIT 1
Такие недочеты не слишком бросались в глаза, так как в тестах мы не пишем руками все запросы, а вместо этого мы используем QueryBuilder’ы, которые сами компонуют их после указания нужных функций. Мы не задумывались о том, что это может влиять на скорость выполнения запросов. Соответственно, в коде это выглядит как-то так:
$qb = $this>createQueryBulder()
->selectAllBranchFields()
->fromBranchPartition()
->hasRubric()
->hasAddressName()
->hasWebsite()
->orderByRandom()
->setMaxResults(1);
Не повторяйте наших ошибок: при наличии нескольких условий в одно поле JSONB, описывайте их все в рамках одного оператора ‘@@’. После того, как мы переделали, мы ускорили время выполнения каждого запроса в два раза. Раньше на описанный запрос уходило 7500ms, а теперь уходит 3500ms.
Проблема 2: Лишние тестовые данные

Доступ к нашему API предоставляется по ключу, у каждого пользователя API он свой. Раньше в тестах часто необходимо было модифицировать настройки ключей. Из-за этого тесты падали.
Мы решили создавать несколько ключей с нужными настройками при каждом прогоне регрессии, чтобы исключить проблемы пересечения. А так как создание нового ключа не влияет на функциональность всего приложения, данный подход в тестах ни на что не повлияет. Жили в таких условиях около года, пока не начали разбираться с производительностью.
Ключей не так много — 1000 штук. Для ускорения работы приложения мы храним их в памяти и обновляем раз в несколько минут или по требованию. Таким образом, тесты после сохранения очередного ключа запускали процесс синхронизации, окончания которого мы не дожидались — получали в ответ «504», который писался в логи. При этом приложение никак не сигнализировало о проблеме и мы думал, что все у нас замечательно работает. Сам процесс регрессионного тестирования продолжался. И в итоге получалось, что нам всегда везло и наши ключи сохранялись.
Мы жили в неведении, пока не проверили логи. Оказалось, что ключи то мы создавали, но не удаляли после прогона тестов. Таким образом их у нас накопилось 500 000.
Не повторяйте наших ошибок: если вы как-то модифицируете БД в тестах, то обязательно позаботьтесь о том чтобы БД приводилось в первоначальное состояние. После того, как мы почистили базу, процесс обновления ключей ускорился в 500 раз.
Проблема 3: Cлучайная выборка данных

Мы очень любим проверять работу приложения на разных данных. Данных у нас очень-преочень много, и периодически находятся проблемы. Например, был случай, когда нам не выгрузили данные по рекламе, но тесты вовремя отловили эту проблему. Вот поэтому в каждом запросе наших тестов можно увидеть ORDER BY RANDOM()
Когда посмотрели результаты запросов, с рандомом и без него с помощью EXPLAIN’a увидели прирост производительности в 20 раз. Если говорить про пример выше, то без рандома он отрабатывает за 160ms. Мы всерьез задумались, что нам делать, потому что от рандома полностью отказываться не очень хотелось.
Например, в Новосибирске порядка 150 тысяч фирм, и когда мы пытались найти фирму, у которой есть адрес, сайт и рубрика — то получали рандомную запись почти из всей базы. Мы решили сократить выборку до первых 100 фирм, подходящих под наши условия. Итогом раздумий стал компромисс между постоянной выборкой разных данных и скоростью:
SELECT * FROM (SELECT * FROM firm_1 WHERE json_data @@ 'rubrics.@# > 0 AND address_name = * AND contact_groups.#.contacts.#.type = "website"' LIMIT 100) random_hack ORDER BY RANDOM() LIMIT 1;
Таким простым способом мы почти ничего не потеряли при 20-кратном ускорении. Время выполнение такого запроса равна 180ms.
Не повторяйте наших ошибок: этот момент, конечно, сложно назвать ошибкой. Если у вас действительно много тестов, всегда задумывайтесь, насколько вам нужен рандом в данных. Компромисс между скоростью выполнения запросов в базу и уникальностью выборки помогло нам ускорить SQL-запросы в 20 раз.
Еще раз краткий список действий:
- Если указываем несколько условий для выборки данных в поле JSONB, то их нужно перечислить в одном операторе ‘@@’.
- Если создаем тестовые данные, то обязательно их удаляем. Даже если будет казаться, что их наличие не влияет на функционал приложения.
- Если нужны рандомные данные для каждого прогона, то находим компромисс между уникальностью выборки и скоростью выполнения.
Мы в три раза ускорили прохождение регрессии благодаря простым (а для кого-то, наверное, даже очевидным) модификациям. Теперь наши 25К тестов проходят за 10 минут. И это не предел — на очереди у нас оптимизация кода. Неизвестно, сколько неожиданных открытий нас еще ждет там.
Комментарии (41)
qwez
16.08.2018 09:46+2На сколько понял — самое слабое место, с точки зрения производительности, это получение тестовых данных из БД. Так может вообще отказаться от этого? Тем более рандомом нельзя гарантировать проверку всех кейсов. Лучше написать качественные дата-провайдеры, которые будут генерить необходимые данные для всех кейсов.
zharikovpro
16.08.2018 11:36Можно отказаться от медленной БД. Для своего небольшого приложения на Rails с простой структурой БД в production использую PostgreSQL, а для тестов — in-memory sqlite, все просто летает.
rraderio
16.08.2018 12:53Почему не PostgreSQL in-memory?
zharikovpro
16.08.2018 14:13Потому что его не существует.
rraderio
16.08.2018 14:42zharikovpro
16.08.2018 15:41Не вижу там ничего про in-memory. Если покажете, буду благодарен.
rraderio
16.08.2018 16:13В Linux можно монтировать папку в память, ну вот там где у нас PostgreSQL и монтируем.
zharikovpro
16.08.2018 16:18О таком варианте не думал, спасибо, это действительно очень многообещающе!
PlatinumThinker
16.08.2018 18:20Стоит так же наверное отметить что и в Windows такой функционал вполне реализуем с помощью RamDisk (если что решение стороннее, не от Microsoft)
Takumi Автор
16.08.2018 12:50+1Мы тоже так думаем, что лучше написать такие дата-провайдеры. Даже проводили пару испытаний. Эта задача оказалась гораздо сложнее чем кажется, потому что собрать объект, у которого есть несколько зависимостей необходимых для работы приложения не простая задача. Мы не отказываемся от их написания в будущем, просто быстрее оказалось сделать выше описанные изменения и получить огромный профит.
poxvuibr
16.08.2018 10:47Вот насчёт регулярной очистки БД. Приложение с маленьким количеством данных будет вести себя не так, как приложение с большим. По идее нужно отдельное тестирование для проверки как приложение ведёт себя на нормальном объёме данных. Делаете вы такое?
Takumi Автор
16.08.2018 13:04+1Если вы про нагрузочное тестирование, то да, такой вид тестирования у нас есть. Оно поставлено у нас на поток и при каждом релизе сравниваем нагрузку текущего кода с его прошлой версией.
«Почему не поймали описанную проблему выше на этапе нагрузочного тестирования» — спросите Вы. Не поймали мы ее из-за особенностей нагрузочного контура — БД там синкатеся по другому.
VolCh
16.08.2018 13:38В большинстве случае различия в поведении на разных объёмах данных покрываются не функциональными тестами. Обычно нет в коде условий вроде «если в таблице больше 100500 записей, то вести себя по другому».
poxvuibr
16.08.2018 15:07Обычно нет в коде условий вроде «если в таблице больше 100500 записей, то вести себя по другому
В коде нет, а в движке БД что-то в этом духе вполне может быть. И то, что нормально работало для небольшого количества данных — при увеличении их количества просто упадёт. Это вроде действительно кейсы для нагрузочного тестирования, но под нагрузкой как правило имеют в виду не большое количество данных, а большое количество запросов.
qwez
16.08.2018 15:12Строго говоря, это вообще не относится к тестированию приложения (если бизнес-логика не размазана и на слой хранимок). Тут тестирование БД. А ее и не нужно тестировать — просто ищешь результаты таких тестов и помнишь про ограничения.
poxvuibr
16.08.2018 15:50Строго говоря, это вообще не относится к тестированию приложения
Ну после накопления какого-то количества данных прод начинает падать, а функциональные тесты показывают, что всё норм.
А ее и не нужно тестировать — просто ищешь результаты таких тестов и помнишь про ограничения.
Не совсем понял. Тут же приложение в связке с БД тестируется.
Melorian
16.08.2018 10:50+2А не пробовали создать докер-образы БД, куда просто копировать раз в день минимально-необходимый набор данных для тестов? Так же решается проблема с удалением тестовых данных — после прогона тестов образ просто удаляется до следующей загрузки.
Все это прекрасно работает через teamcity, к примеру.Takumi Автор
16.08.2018 13:43+1Нет, мы так не пробовали. Тут не понятен механизм определения минимального набора данных. Можно конечно, ночью прогонять тесты на полном объеме данных, получать id объектов из базы, сохранять их и потом копировать эти объекты в отдельную базу, чтобы остальные прогоны этого дня шли быстро. Мы обязательно подумаем над этим способом более пристально. Спасибо, за совет!
bbidox
16.08.2018 15:18Рандом в тестах почти всегда зло. Хотя бы потому что повторить такой тест невозможно (если у вас нет ключика, который фиксирует состояние… но тогда это другой тест).
Покапитаню: Классы эквивалентности пробовали применять?
Покапитаню2: С выборкой "случайной" фирмы вы тестировали несуществующий кейз. Клиент может запросить "случайную фирму" (а-ля "фирма дня"?) Если нет, то и тесты (массово) такой подход не должны использовать.qwez
16.08.2018 15:25Тут рандом не в тестах, а в подборе тестовых данных. Например, есть кейс: проверить адреса офисов, которые возвращает API по фирме. По какой фирме делать запрос? Можно по одной и той же каждый раз, а можно выбирать каждый раз рандомную фирму. Разумеется, тест с рандомной фирмой можно повторить, так как мы пишем лог теста.
Второй вариант получше, все же будет.
На у совсем хороший подход — самим генерить фирму с нужными параметрами, используя классы эквивалентности. Но это может оказаться весьма трудоемко.bbidox
16.08.2018 15:43Рандом как раз в тестах, просто во входных данных. Получается, что чистая функция "тест" обёрнута в обычную функцию "тест + входные данные" + рандомное время для извлечения тестовых данных.
Конечно, всегда есть некоторая рандомизация (да хотя бы время, в которое скрипт запустили — вдруг фирма уже прекратила существование и уже отправилась в архив), но и от неё надо стараться избавиться.
А создание ненастоящих фирм можно делать отдельным процессом, который будет контролировать наполненность тестовой фермы тестовыми сущностями. Так нагрузка будет не на тесты.qwez
17.08.2018 09:10+1Ну так генерация тестовых данных, это же не тест. Тест говорит — мне нужна фирма в таком-то городе, с таким-то параметром — и ему ее предоставляют. В тесте нет никакого рандома. Единственный минус такого подхода, что долго получать данные из базы и есть небольшой риск, что данных вообще может не оказаться.
Я не защищаю такой подход, но он вполне может существовать на временной основе, пока не сделают генерилку данных. В нем нет каких-то фундаментально-неверных подходов.bbidox
17.08.2018 13:02Ещё один недостаток: ошибка может быть в генерации тестовых данных.
Я не защищаю такой подход, но он вполне может существовать на временной основе, пока не сделают генерилку данных. В нем нет каких-то фундаментально-неверных подходов.
Я согласен с вами, что такой подход рабочий. Но нет ничего более постоянного, чем что-то временное :)
Некоторая ошибочность подхода (не фатальная) в том, что вы тестируете гораздо больше, чем вам надо. Просто "а почему бы и нет?" Раз вас пока это устраивает — исползуйте на здоровье :)
port443
16.08.2018 20:32+1 (не могу добавить)
Зачем играть в рулетку с рандомом на настоящей базе, когда требования к данным для теста (граничные случаи) известны? Какое-то усложнение во всём: медленнее; нет гарантии, что тест проходит со смыслом (попали в требуемые по форме данные); не повторить разработчику для отладки, если будет нужно.Takumi Автор
17.08.2018 07:06Чуть выше я отвечал, что мы тоже думаем о предзаполняемых данных перед тестом, но это очень трудоемкая задача.
crocodile2u
16.08.2018 17:32https://www.phparch.com/magazine/2018-2/april/
PHPUnit Worst Practices — моя статья (наглая реклама, да!). Очень знакомо выглядит то, что вы описываете, несмотря на то, что моя статья больше о качестве тестового кода, чем о производительности тестов.
И отдельно насчет рандома. Рандом в тестах — зло. Тест должен быть предсказуемым, а когда используется рандом — вы рискуете получить рандомные же фэйлы. Покрывайте edge cases, а случайные данные оставьте для fuzzing tests.
poxvuibr
16.08.2018 18:00Рандом в тестах — зло.
И генерация рандомных идентификаторов тоже?
bbidox
16.08.2018 19:06Если используете один и тот же seed или можете восстановить цепочку, то не зло.
poxvuibr
16.08.2018 22:29Ну вот есть у нас БД, в ней колонка со строкой, которая естественный ключ. Какими нехорошими последствиями может обернуться рандомная генерация этих ключей? При условии, что каждый запуск ключ будет новый.
crocodile2u
17.08.2018 09:12Ну например, генератор использует 100 возможных символов для этой строки, а в валидатор зашито только 99. Один из Х тестов падает, повторный запуск — скорее всего проходит.
poxvuibr
17.08.2018 10:30Ну например, генератор использует 100 возможных символов для этой строки, а в валидатор зашито только 99.
В логах же можно будет посмотреть, почему упал тест? И по результатам поправить или валидатор, или генератор.
bbidox
17.08.2018 12:55Ваш случай граничный. Сложно придумать контраргумент. Возможно, даже и не нужно, ведь есть "допустимое зло" :)
Из тех условий, что вы приводите, рандомная генерация ключей единственное что не позволит воспроизвести какую-нибудь ошибку в БД или хранимых процедурах в БД.
Но это уже домыслы.
crocodile2u
17.08.2018 09:09Очень сложно выглядит. Я не вижу, какими преимуществами оправдывается это возрастание сложности.
Зачем какие-то идентификаторы генерить, когда можно сделать для теста необходимую фикстуру.poxvuibr
17.08.2018 10:28Очень сложно выглядит.
Передача сгенерированного значения, вместо передачи подготовленного значения что-то усложняет?
Зачем какие-то идентификаторы генерить, когда можно сделать для теста необходимую фикстуру.
Если что-то подготавливать, то после теста надо будет почистить всё, что попало в БД. Это увеличивает время прогона тестов, плюс добавляет сложности в виде чистилки, которую написать сложнее, чем сделать генератор случайных id.
bbidox
17.08.2018 12:58Нсли чистить не в рамках теста, а в рамках другой задачи (да хоть бы и автоматическим заданием в планировщике очередей), то скорость прохождения тестов не вырастет.
Чистилка не особо нужна, если каждый раз использовать эталонную базу (восстанавливать из докера / хранить слепок / всё что угодно).
Понятное дело, что вы сейчас говорите о каком-то реальном проекте, где это ну оооочень сложно реализовать уже. А вам тут о каком-то академическом примере талдычут. Ну так вот придёте как-нибудь на новый проект, где ещё код весь костылями не оброс и успеете туда прикрутить "чуть лучший" подход к тестированию.
b1rdex
$id = random_int(1, 150000);
…where id < :id limit 1
Takumi Автор
По вашему алгоритму, скажем выпало $id=100, мы берем эти 100 фирм и из них может ни одна не подходить для наших условий, например, необходимо наличие телефона. Также это не работает и в обратном случае, если сначала выбрать фирмы с телефоном, а потом сравнивать с $id=100.
b1rdex
Верно. В таком случае, можно заморочиться и сделать сначала получение min и max id из результатов и вторым запросом получить реальные данные, используя случайный id из (mix, max)
Takumi Автор
Да, только по сути это тот же подход, только другая реализация.