Ограничения, которые нужно нарушать или как мы ускорили функциональные тесты в три раза

    image

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

    Коротко о приложении


    Моя команда разрабатывает публичное 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 раз.

    Еще раз краткий список действий:


    1. Если указываем несколько условий для выборки данных в поле JSONB, то их нужно перечислить в одном операторе ‘@@’.
    2. Если создаем тестовые данные, то обязательно их удаляем. Даже если будет казаться, что их наличие не влияет на функционал приложения.
    3. Если нужны рандомные данные для каждого прогона, то находим компромисс между уникальностью выборки и скоростью выполнения.

    Мы в три раза ускорили прохождение регрессии благодаря простым (а для кого-то, наверное, даже очевидным) модификациям. Теперь наши 25К тестов проходят за 10 минут. И это не предел — на очереди у нас оптимизация кода. Неизвестно, сколько неожиданных открытий нас еще ждет там.

    2ГИС

    376,00

    Карта города и справочник предприятий

    Поделиться публикацией

    Похожие публикации

    Комментарии 54
      –1
      Мы решили сократить выборку до первых 100 фирм, подходящих под наши условия
      Зная примерное количество и, предположив, что id — int, можно сделать так:
      $id = random_int(1, 150000);
      …where id < :id limit 1
        +2
        По вашему алгоритму, скажем выпало $id=100, мы берем эти 100 фирм и из них может ни одна не подходить для наших условий, например, необходимо наличие телефона. Также это не работает и в обратном случае, если сначала выбрать фирмы с телефоном, а потом сравнивать с $id=100.
          0
          Верно. В таком случае, можно заморочиться и сделать сначала получение min и max id из результатов и вторым запросом получить реальные данные, используя случайный id из (mix, max)
            0
            Да, только по сути это тот же подход, только другая реализация.
        +3
        На сколько понял — самое слабое место, с точки зрения производительности, это получение тестовых данных из БД. Так может вообще отказаться от этого? Тем более рандомом нельзя гарантировать проверку всех кейсов. Лучше написать качественные дата-провайдеры, которые будут генерить необходимые данные для всех кейсов.
          0
          Можно отказаться от медленной БД. Для своего небольшого приложения на Rails с простой структурой БД в production использую PostgreSQL, а для тестов — in-memory sqlite, все просто летает.
            0
            Почему не PostgreSQL in-memory?
              0
              Потому что его не существует.
                  0
                  Не вижу там ничего про in-memory. Если покажете, буду благодарен.
                    0
                    В Linux можно монтировать папку в память, ну вот там где у нас PostgreSQL и монтируем.
                      0
                      О таком варианте не думал, спасибо, это действительно очень многообещающе!
                        0
                        Стоит так же наверное отметить что и в Windows такой функционал вполне реализуем с помощью RamDisk (если что решение стороннее, не от Microsoft)
              +1
              Мы тоже так думаем, что лучше написать такие дата-провайдеры. Даже проводили пару испытаний. Эта задача оказалась гораздо сложнее чем кажется, потому что собрать объект, у которого есть несколько зависимостей необходимых для работы приложения не простая задача. Мы не отказываемся от их написания в будущем, просто быстрее оказалось сделать выше описанные изменения и получить огромный профит.
              +1

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

                +1
                Если вы про нагрузочное тестирование, то да, такой вид тестирования у нас есть. Оно поставлено у нас на поток и при каждом релизе сравниваем нагрузку текущего кода с его прошлой версией.
                «Почему не поймали описанную проблему выше на этапе нагрузочного тестирования» — спросите Вы. Не поймали мы ее из-за особенностей нагрузочного контура — БД там синкатеся по другому.
                  0
                  В большинстве случае различия в поведении на разных объёмах данных покрываются не функциональными тестами. Обычно нет в коде условий вроде «если в таблице больше 100500 записей, то вести себя по другому».
                    0
                    Обычно нет в коде условий вроде «если в таблице больше 100500 записей, то вести себя по другому

                    В коде нет, а в движке БД что-то в этом духе вполне может быть. И то, что нормально работало для небольшого количества данных — при увеличении их количества просто упадёт. Это вроде действительно кейсы для нагрузочного тестирования, но под нагрузкой как правило имеют в виду не большое количество данных, а большое количество запросов.

                      0
                      Строго говоря, это вообще не относится к тестированию приложения (если бизнес-логика не размазана и на слой хранимок). Тут тестирование БД. А ее и не нужно тестировать — просто ищешь результаты таких тестов и помнишь про ограничения.
                        0
                        Строго говоря, это вообще не относится к тестированию приложения

                        Ну после накопления какого-то количества данных прод начинает падать, а функциональные тесты показывают, что всё норм.


                        А ее и не нужно тестировать — просто ищешь результаты таких тестов и помнишь про ограничения.

                        Не совсем понял. Тут же приложение в связке с БД тестируется.

                          0
                          Если написали такой тест связки приложения с БД, в котором база падает, то это баг базы. Тут багрепорт разработчикам базы слать, а у себя воркараунд придумывать типа проверки количества записей перед вставкой и отлуп до запроса к базе.
                            0
                            Если написали такой тест связки приложения с БД, в котором база падает, то это баг базы

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

                              0
                              Ну тут надо писать функциональный, а то и юнит-тест, который проверяет, что приложение и за минуту не упадёт :) Ну или что при таймауте оно не падает и выводит разумное сообщение юзеру. В обоих случаях набивать базу миллиардами записей не нужно.
                                0
                                Ну тут надо писать функциональный, а то и юнит-тест, который проверяет, что приложение и за минуту не упадёт :)

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


                                Потом оно, конечно, выводит сообщение юзеру, но вообще транзакции должны отрабатывать за 3 секунды или быстрее. Над этим надо работать. И чем больше данных, тем дольше работают транзакции. Поэтому проверять надо на БД с большим количеством записей.

                                  0
                                  Аккуратней со словом «падать» в отношении приложения. Думаю большинство его тут воспринимает куда катастрофичней :)

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

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


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

                        +1
                        Может они и не являются нагрузочными в узком смысле слова, но точно они относятся к нефункциональным, если от количества данных результат меняться не должен качественно. Другое дело, что если опытным путём установлено или в документации прочитано, что на 1 млрд записей база падает, а это не допустимо, то делается функциональность блокирующая добавление записи при наличии 999999999 и вот это уже тестом должгл покрываться. Или вводится ограничение, что если запрос больше 30 секунд длится, то он должен рубиться, а юзеру внятное сообщение.
                    +4
                    А не пробовали создать докер-образы БД, куда просто копировать раз в день минимально-необходимый набор данных для тестов? Так же решается проблема с удалением тестовых данных — после прогона тестов образ просто удаляется до следующей загрузки.
                    Все это прекрасно работает через teamcity, к примеру.
                      +1
                      Нет, мы так не пробовали. Тут не понятен механизм определения минимального набора данных. Можно конечно, ночью прогонять тесты на полном объеме данных, получать id объектов из базы, сохранять их и потом копировать эти объекты в отдельную базу, чтобы остальные прогоны этого дня шли быстро. Мы обязательно подумаем над этим способом более пристально. Спасибо, за совет!
                      +1

                      Рандом в тестах почти всегда зло. Хотя бы потому что повторить такой тест невозможно (если у вас нет ключика, который фиксирует состояние… но тогда это другой тест).
                      Покапитаню: Классы эквивалентности пробовали применять?
                      Покапитаню2: С выборкой "случайной" фирмы вы тестировали несуществующий кейз. Клиент может запросить "случайную фирму" (а-ля "фирма дня"?) Если нет, то и тесты (массово) такой подход не должны использовать.

                        +2
                        Тут рандом не в тестах, а в подборе тестовых данных. Например, есть кейс: проверить адреса офисов, которые возвращает API по фирме. По какой фирме делать запрос? Можно по одной и той же каждый раз, а можно выбирать каждый раз рандомную фирму. Разумеется, тест с рандомной фирмой можно повторить, так как мы пишем лог теста.
                        Второй вариант получше, все же будет.
                        На у совсем хороший подход — самим генерить фирму с нужными параметрами, используя классы эквивалентности. Но это может оказаться весьма трудоемко.
                          0

                          Рандом как раз в тестах, просто во входных данных. Получается, что чистая функция "тест" обёрнута в обычную функцию "тест + входные данные" + рандомное время для извлечения тестовых данных.
                          Конечно, всегда есть некоторая рандомизация (да хотя бы время, в которое скрипт запустили — вдруг фирма уже прекратила существование и уже отправилась в архив), но и от неё надо стараться избавиться.
                          А создание ненастоящих фирм можно делать отдельным процессом, который будет контролировать наполненность тестовой фермы тестовыми сущностями. Так нагрузка будет не на тесты.

                            +1
                            Ну так генерация тестовых данных, это же не тест. Тест говорит — мне нужна фирма в таком-то городе, с таким-то параметром — и ему ее предоставляют. В тесте нет никакого рандома. Единственный минус такого подхода, что долго получать данные из базы и есть небольшой риск, что данных вообще может не оказаться.
                            Я не защищаю такой подход, но он вполне может существовать на временной основе, пока не сделают генерилку данных. В нем нет каких-то фундаментально-неверных подходов.
                              0

                              Ещё один недостаток: ошибка может быть в генерации тестовых данных.


                              Я не защищаю такой подход, но он вполне может существовать на временной основе, пока не сделают генерилку данных. В нем нет каких-то фундаментально-неверных подходов.

                              Я согласен с вами, что такой подход рабочий. Но нет ничего более постоянного, чем что-то временное :)
                              Некоторая ошибочность подхода (не фатальная) в том, что вы тестируете гораздо больше, чем вам надо. Просто "а почему бы и нет?" Раз вас пока это устраивает — исползуйте на здоровье :)

                          +1
                          +1 (не могу добавить)
                          Зачем играть в рулетку с рандомом на настоящей базе, когда требования к данным для теста (граничные случаи) известны? Какое-то усложнение во всём: медленнее; нет гарантии, что тест проходит со смыслом (попали в требуемые по форме данные); не повторить разработчику для отладки, если будет нужно.
                            0
                            Чуть выше я отвечал, что мы тоже думаем о предзаполняемых данных перед тестом, но это очень трудоемкая задача.
                              0
                              В целом она трудоёмкая для уже существующей системы, полную историю которой помнят только репозиторий да продакшен база. Когда с нуля или почти с нуля начинаешь, или хотя бы есть большая вероятность, что изменения в базе необходимые для работы приложения (типа системных справочников) делались известными миграциями, то всё гораздо проще.
                          0

                          https://www.phparch.com/magazine/2018-2/april/


                          PHPUnit Worst Practices — моя статья (наглая реклама, да!). Очень знакомо выглядит то, что вы описываете, несмотря на то, что моя статья больше о качестве тестового кода, чем о производительности тестов.


                          И отдельно насчет рандома. Рандом в тестах — зло. Тест должен быть предсказуемым, а когда используется рандом — вы рискуете получить рандомные же фэйлы. Покрывайте edge cases, а случайные данные оставьте для fuzzing tests.

                            0
                            Рандом в тестах — зло.

                            И генерация рандомных идентификаторов тоже?

                              0

                              Если используете один и тот же seed или можете восстановить цепочку, то не зло.

                                +1

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

                                  0

                                  Ну например, генератор использует 100 возможных символов для этой строки, а в валидатор зашито только 99. Один из Х тестов падает, повторный запуск — скорее всего проходит.

                                    0
                                    Ну например, генератор использует 100 возможных символов для этой строки, а в валидатор зашито только 99.

                                    В логах же можно будет посмотреть, почему упал тест? И по результатам поправить или валидатор, или генератор.

                                    0

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

                                      0
                                      Два совпадения рандомных чисел.
                                        0

                                        На практике невероятно.

                                    0

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

                                      0
                                      Очень сложно выглядит.

                                      Передача сгенерированного значения, вместо передачи подготовленного значения что-то усложняет?


                                      Зачем какие-то идентификаторы генерить, когда можно сделать для теста необходимую фикстуру.

                                      Если что-то подготавливать, то после теста надо будет почистить всё, что попало в БД. Это увеличивает время прогона тестов, плюс добавляет сложности в виде чистилки, которую написать сложнее, чем сделать генератор случайных id.

                                        0

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


                                        Чистилка не особо нужна, если каждый раз использовать эталонную базу (восстанавливать из докера / хранить слепок / всё что угодно).


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

                                          0
                                          > Если что-то подготавливать, то после теста надо будет почистить всё, что попало в БД.

                                          В целом это общее правило для любых тестов. Влияние тестов друг на друга должно быть стремиться к нулю. Если куча всего пишется в базу во время тестов и это не чистится, то о независимости прогонов друг от друга сложно говорить.
                                    0
                                    Пардон, а что за кино в гифках?
                                      0
                                      Первые 2 — «Голый пистолет 3». Последняя — «Голый пистолет»
                                      0
                                      Сколько специалистов создают и поддерживают 25К тестов?
                                        0
                                        Сейчас тестировщиков на проекте пять. В основном они и создают и поддерживают, но разработка так или иначе тоже вносит свой ощутимый вклад в эти процессы.

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

                                      Самое читаемое