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 минут. И это не предел — на очереди у нас оптимизация кода. Неизвестно, сколько неожиданных открытий нас еще ждет там.

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


  1. b1rdex
    16.08.2018 09:29

    Мы решили сократить выборку до первых 100 фирм, подходящих под наши условия
    Зная примерное количество и, предположив, что id — int, можно сделать так:
    $id = random_int(1, 150000);
    …where id < :id limit 1


    1. Takumi Автор
      16.08.2018 12:50
      +1

      По вашему алгоритму, скажем выпало $id=100, мы берем эти 100 фирм и из них может ни одна не подходить для наших условий, например, необходимо наличие телефона. Также это не работает и в обратном случае, если сначала выбрать фирмы с телефоном, а потом сравнивать с $id=100.


      1. b1rdex
        17.08.2018 09:47

        Верно. В таком случае, можно заморочиться и сделать сначала получение min и max id из результатов и вторым запросом получить реальные данные, используя случайный id из (mix, max)


        1. Takumi Автор
          17.08.2018 09:53

          Да, только по сути это тот же подход, только другая реализация.


  1. qwez
    16.08.2018 09:46
    +2

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


    1. zharikovpro
      16.08.2018 11:36

      Можно отказаться от медленной БД. Для своего небольшого приложения на Rails с простой структурой БД в production использую PostgreSQL, а для тестов — in-memory sqlite, все просто летает.


      1. rraderio
        16.08.2018 12:53

        Почему не PostgreSQL in-memory?


        1. zharikovpro
          16.08.2018 14:13

          Потому что его не существует.


          1. rraderio
            16.08.2018 14:42

            1. zharikovpro
              16.08.2018 15:41

              Не вижу там ничего про in-memory. Если покажете, буду благодарен.


              1. rraderio
                16.08.2018 16:13

                В Linux можно монтировать папку в память, ну вот там где у нас PostgreSQL и монтируем.


                1. zharikovpro
                  16.08.2018 16:18

                  О таком варианте не думал, спасибо, это действительно очень многообещающе!


                1. PlatinumThinker
                  16.08.2018 18:20

                  Стоит так же наверное отметить что и в Windows такой функционал вполне реализуем с помощью RamDisk (если что решение стороннее, не от Microsoft)


    1. Takumi Автор
      16.08.2018 12:50
      +1

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


  1. poxvuibr
    16.08.2018 10:47

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


    1. Takumi Автор
      16.08.2018 13:04
      +1

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


    1. VolCh
      16.08.2018 13:38

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


      1. poxvuibr
        16.08.2018 15:07

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

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


        1. qwez
          16.08.2018 15:12

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


          1. poxvuibr
            16.08.2018 15:50

            Строго говоря, это вообще не относится к тестированию приложения

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


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

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


  1. Melorian
    16.08.2018 10:50
    +2

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


    1. Takumi Автор
      16.08.2018 13:43
      +1

      Нет, мы так не пробовали. Тут не понятен механизм определения минимального набора данных. Можно конечно, ночью прогонять тесты на полном объеме данных, получать id объектов из базы, сохранять их и потом копировать эти объекты в отдельную базу, чтобы остальные прогоны этого дня шли быстро. Мы обязательно подумаем над этим способом более пристально. Спасибо, за совет!


  1. bbidox
    16.08.2018 15:18

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


    1. qwez
      16.08.2018 15:25

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


      1. bbidox
        16.08.2018 15:43

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


        1. qwez
          17.08.2018 09:10
          +1

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


          1. bbidox
            17.08.2018 13:02

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


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

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


    1. port443
      16.08.2018 20:32

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


      1. Takumi Автор
        17.08.2018 07:06

        Чуть выше я отвечал, что мы тоже думаем о предзаполняемых данных перед тестом, но это очень трудоемкая задача.


  1. crocodile2u
    16.08.2018 17:32

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


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


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


    1. poxvuibr
      16.08.2018 18:00

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

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


      1. bbidox
        16.08.2018 19:06

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


        1. poxvuibr
          16.08.2018 22:29

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


          1. crocodile2u
            17.08.2018 09:12

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


            1. poxvuibr
              17.08.2018 10:30

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

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


          1. bbidox
            17.08.2018 12:55

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


      1. crocodile2u
        17.08.2018 09:09

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


        1. poxvuibr
          17.08.2018 10:28

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

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


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

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


          1. bbidox
            17.08.2018 12:58

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


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


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


  1. frankmasonus
    17.08.2018 07:13

    Пардон, а что за кино в гифках?


    1. Takumi Автор
      17.08.2018 07:15

      Первые 2 — «Голый пистолет 3». Последняя — «Голый пистолет»