Так как же не страдать от функциональных тестов?

На написание этой статьи меня подтолкнуло обсуждение докладов с Heisenbug 2021 в нашем корпоративном чате. Причиной является тот факт, что достаточно много внимания уделяется «правильному» написанию тестов. В кавычках — потому что на бумаге все действительно логично и аргументированно, однако на практике такие тесты получаются достаточно медленными.

Эта статья ориентирована скорее на новичков в программировании, но возможно кто-то сможет вдохновиться одним из описанных далее подходов.

Я думаю, все знают принципы хороших тестов:

  • Тест должен быть атомарным, т.е. проверять единицу логики (например, один HTTP-метод или один метод класса)

  • Тест должен быть изолированным, т.е. прохождение тестов не должно зависеть от порядка их выполнения

  • Тест должен быть повторяемым, т.е. выполнение теста локально и в CI должно приводить к одному результату

Проблема возникает тогда, когда вы начинаете пытаться реализовывать подобные тесты и сталкиваетесь с реальностью: pipeline всего из 3000 функциональных тестов проходит около двух часов!


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

Любой запуск функциональных тестов для API можно разделить на следующие этапы:

  1. Применить миграции (Структура БД)

  2. Применить фикстуры (Тестовые данные в БД)

  3. Выполнить HTTP-запрос

  4. Выполнить необходимые проверки

Как вы знаете, атомарность значит, что мы должны проверять только одну функциональную единицу. В нашем случае мы имеем дело с HTTP API, поэтому такой единицей будет вызов метода API.

Для обеспечения требования повторяемости (а также, чтобы тесты в принципе были близки к реальности) в ходе тестирования используется настоящая СУБД той же версии, которая используется на промышленной среде. Для каналов передачи данных, по возможности, тоже не делаются заглушки: поднимаются и другие зависимости вроде Redis/RabbitMQ и отдельное HTTP приложение, содержащее моки для имитации вызовов сторонних сервисов.

Требование изолированности отчасти обеспечивается отдельными фикстурами для тестов, а также отдельным процессом сборки DI-контейнера приложения между тестами.

В итоге получается примерно следующий интерфейс:

Пример описания запроса
{
  "method": "patch",
  "uri": "/v2/project/17558/admin/items/physical_good/sku/not_existing_sku",
  "headers": {
    "Authorization": "Basic MTc1NTg6MTIzNDVxd2VydA=="
  },
  "data": {
    "name": {
      "en-US": "Updated name",
      "ru-RU": "Обновленное название"
    }
  }
}
Пример описания запроса
{
  "status": 404,
  "data": {
    "errorCode": 4001,
    "errorMessage": "[0401-4001]: Can not find item with urlSku = not_existing_sku and project_id = 17558",
    "statusCode": 404,
    "transactionId": "x-x-x-x-transactionId-mock-x-x-x"
  }
}
Пример описания самого теста
<?php declare(strict_types=1);

namespace Tests\Functional\Controller\Version2\PhysicalGood\AdminPhysicalGoodPatchController;

use Tests\Functional\Controller\ControllerTestCase;

class AdminPhysicalGoodPatchControllerTest extends ControllerTestCase
{
    public function dataTestMethod(): array
    {
              return [
                // Negative cases
                'Patch -- item doesn\'t exist' => [
                        '001_patch_not_exist'
                ],
            ];
    }
}

Структура директории с тестами:

TestFolder
├── Fixtures
│   └── store
│   │   └── item.yml
├── Request
│   └── 001_patch_not_exist.json
├── Response
│   └── 001_patch_not_exist.json
│   Tables
│   └── 001_patch_not_exist
│       └── store
│           └── item.yml
└── AdminPhysicalGoodPatchControllerTest.php

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

...

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

1. Применять миграции единожды

Миграция — это скрипт, который позволяет перевести текущую структуру БД из одного консистентного состояния в другое.

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

Кроме миграций в приложении используются также фикстуры — набор тестовых данных для инициализации БД.

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

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

2. Кэшировать миграции

Чем дольше живет приложение, тем больше миграций он несет вместе с собой. Усугубляться все может сценарием, в котором несколько приложений работают с одной БД (соответственно и миграции общие).

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

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

Пример скрипта для сборки миграций
#!/usr/bin/env bash

if [[ ! -f "dump-cache.sql" ]]; then
    echo 'Generating dump'
    # Загрузка миграций из удаленного репозитория
    migrations_dir="./migrations" sh ./scripts/helpers/fetch_migrations.sh
    # Применение миграций к БД
    migrations_dir="./migrations" host="percona" sh ./scripts/helpers/migrate.sh

    # Генерируется дамп только для интересующих нас схем (store, delivery)
    mysqldump --host=percona --user=root --password=root \
      --databases store delivery \
      --single-transaction \
      --no-data --routines > dump.sql

    cp dump.sql dump-cache.sql
else
    echo 'Extracting dump from cache'
    cp dump-cache.sql dump.sql
fi

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

Пример CI-job (gitlab)
build migrations:
  stage: build
  image: php72:1.4
  services:
    - name: percona:5.7
  cache:
    key:
      files:
        - scripts/helpers/fetch_migrations.sh
    paths:
      - dump-cache.sql
  script:
    - bash ./scripts/ci/prepare_ci_db.sh
  artifacts:
    name: "$CI_PROJECT_NAME-$CI_COMMIT_REF_NAME"
    paths:
      - dump.sql
    when: on_success
    expire_in: 30min

3. Использовать транзакции БД при применении фикстур

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

  1. Применить фикстуры

  2. В цикле для каждого теста:

    1. Начать транзакцию

    2. Выполнить тест

    3. Проверить результат

    4. Откатить транзакцию

При локальном запуске 19 тестов (каждый из которых заполняет 27 таблиц) по 10 раз были получены результаты (в среднем): 10 секунд при использовании данного подхода и 18 секунд без него.

Что необходимо учесть:

  • У вас должно использоваться одно соединение внутри приложения, а также для инициации транзакции внутри теста. Соответственно, необходимо достать инстанс соединения из DI-контейнера.

  • При откате транзакции счетчики AUTO INCREAMENT не будут сбрасываться, как это происходит при применении TRUNCATE. Могут возникнуть проблемы в тестах, в которых в ответе возвращается идентификатор созданной сущности.

Пример кода
public static function setUpBeforeClass(): void
{
        parent::setUpBeforeClass();
        foreach (self::$onSetUpCommandArray as $command) {
            self::getClient()->$command(self::getFixtures());
        }
}

...

/**
 * @dataProvider dataTestMethod
 */
public function testMethod(string $caseName): void
{
        /** @var Connection $connection */
        $connection = self::$app->getContainer()->get('doctrine.dbal.prodConnection');
        $connection->beginTransaction();
        
        $this->traitTestMethod($caseName);
        $this->assertTables(\glob($this->getCurrentDirectory() . '/Tables/' . $caseName . '/**/*.yml'));
        
        $connection->rollBack();
}

4. Разделить тесты по типу операции

В любой API всегда есть ряд методов, которые не изменяют состояние базы, т.е. только читают и возвращают данные. Имеет смысл выделить тесты для таких методов в отдельные классы/методы и так же, как и в предыдущем варианте, накатывать фикстуры единожды (отличие в том, что не требуются доработки с транзакцией).

Что необходимо учесть:

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

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

Пример кода
public function tearDown(): void
{
        parent::tearDown();
        // После первого выполненного теста массив команд DB-клиента будет обнулен
        // Поэтому в последующие разы фикстуры не будут применяться
        self::$onSetUpCommandArray = [];
}

public static function tearDownAfterClass(): void
{
        parent::tearDownAfterClass();
        self::$onSetUpCommandArray = [
            Client::COMMAND_TRUNCATE,
            Client::COMMAND_INSERT
        ];
}

5. Распараллелить выполнение тестов

Тесты — это операция, которая явно напрашивается на распараллеливание. И да, в действительности распараллеливание потенциально дает всегда наибольший прирост к производительности. Однако, здесь есть ряд проблем.

Распараллелить выполнение тестов можно как в рамках целого pipeline’а, так и в рамках конкретной джобы.

При распараллеливании в рамках pipeline’а необходимо просто создать отдельные джобы для каждого набора тестов (Используя testsuite у phpunit). У нас тесты разделены по версии контроллера.

Пример кода
<testsuite name="functional-v2">
        <directory>./../../tests/Functional/Controller/Version2</directory>
</testsuite>
functional-v2:
  extends: .template_test
  services:
    - name: percona:5.7
  script:
    - sh ./scripts/ci/migrations_dump_load.sh
    - ./vendor/phpunit/phpunit/phpunit --testsuite functional-v2 --configuration config/test/phpunit.ci.v2.xml --verbose

Альтернативно можно распараллелить тесты в пределах одного джоба, используя, например, paratest. Библиотека позволяет запускать несколько процессов для выполнения тестов.

Однако в нашем случае такой подход будет неприменим, т.к. фикстуры разных процессов будут перетирать друг друга. Если же все процессы будут использовать один и тот же набор фикстур, а тесты будут выполняться в рамках транзакции (чтобы не видеть изменения в БД от других тестов), это не даст никакого прироста, т.к. из-за транзакционности операции в БД будут банально выполняться последовательно.

Если подвести итог:

  • Сделать несколько отдельных джоб на уровне CI — самый простой способ ускорить прохождение тестов

  • Распараллелить функциональные тесты в рамках одной джобы сложно, но подобный подход может быть приемлем для юнит-тестов

  • Стоит учесть, что из-за накладных расходов (порождение нового процесса, поднятие нового контейнера для тестов) выполнение тестов параллельно может быть медленнее. Если у вас недостаточно свободных раннеров в CI, то такое деление может не иметь смысла.

...

6. Не пересоздавать экземпляр приложения

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

Решением может быть использовать всегда один и тот же инстанс приложения (сохранять его в статическое свойство класса тестов). В этом случае, опять же, тесты перестанут быть изолированными друг от друга, т.к. множество служб из DI-контейнера могут сохранять локальный стейт (например, какое-то кэширование для быстродействия, открытые соединения с БД и т.п.).

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

Пример кода
interface StateResetInterface
{
    public function resetState();
}
$container = self::$app->getContainer();
foreach ($container->getKnownEntryNames() as $dependency) {
        $service = $container->get($dependency);
        if ($service instanceof StateResetInterface) {
                $service->resetState();
        }
}

Написание тестов — это всегда такой же компромисс, как и написание собственно самого приложения. Необходимо исходить из того, что для вас приоритетнее, а чем можно пожертвовать. Зачастую нам однобоко рассказывают об «идеальных» тестах, в действительности реализация которых может быть сложна, работа медленна или поддержка трудозатратна.

После всех оптимизаций время прохождения в CI для функциональных тестов уменьшилось до 12-15 минут. Я, конечно, сомневаюсь, что описанные выше приемы в их изначальном виде окажутся полезны, но надеюсь, что они вдохновили и натолкнули на собственные идеи!

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

Реклама
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее

Комментарии 17

    0
    Спасибо за статью! Подскажите, пожалуйста, как быть в случае варианта «Использовать транзакции БД при применении фикстур», когда я тестирую эндпоинт, внутри которого есть свои транзакции? Ну, скажем, у меня есть эндпоинт регистрации пользователя. Там я открываю транзакцию, внутри которой делаю запись в несколько таблиц. Я так понимаю, что в таком случае вариант использования транзакций при применении фикстур уже не прокатит?
      0
      Да, это может быть проблемно, т.к., насколько я знаю, практически никакая СУБД не поддерживает вложенные транзакции.
      У нас используется ORM Doctrine, который реализует вложенность на своей стороне, используя механизм SAVEPOINT.
        0
        Спасибо. Тоже использую Doctrine — не знал, что там savepoint под капотом (доку надо лучше читать и код смотреть). Огромное спасибо!
      +1
      ИМХО лучшая оптимизация по соотношению затраченных усилий к полученному ускорению — смонтировать рабочую папку сервера БД на RAM-disk. Например, если тестовая БД (допустим, это MySQL) в контейнере, это сводится к добавлению --tmpfs /var/lib/mysql к docker run. Ты-дыщ! — и тесты побежали раза в 2-3 бодрее.
        0

        Это сработает, если окружение linux. Для macos и windows не уверен, что профит будет существенным

          0

          На Винде есть wsl, в нём все работает. Мак страдает, да. Но это страдания разработчика, в CI все будет ок. А разработчики могут запускать отдельные сьюты у себя.

        0

        Ещё можно писать не изолированные, а реалистичные тесты, когда нужное состояние создаётся не фикстурами, а пользовательскими действиями прямо на грязной базе. Тут, правда, добавляется требование запуска тестов в правильном порядке. Я рассказывал об этом подходе тут: https://m.habr.com/ru/post/510824/

          0
          Не совсем понял, ваш подход предлагает тестировать компонент начиная от дочерних к компонентам более высокого уровня в строгом порядке?
            0

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

          0

          Мне больше всего помогла сборка контейнеров БД с данными внутри. Дропнуть и поднять контейнер- это очень быстро. Есть рецепты для MariaDB и PostgreSQL.
          Но на редактирующих методах были грабли с 1С. Её я не допинал до докера. И в планах было писать моки на такие интеграции.

            0
            Предлагаете переподнимать контейнер с СУБД между тестами?
              0
              Это уж как контейнер собран. Если все фикстуры и миграции уже там, то можно и между тестами.

              А если что-то нужно ещё докатывать, то всё равно может появиться желание оптимизировать.
            0
            Хорошая статья, достаточно объемлющая. Подтвержу полезность следующих (и многизх других методов):
            -Несколько джоб с тестами в CI
            -Фикстуры
            -Контейнеризация

            Хочу заметить, что в статье перечислены методы стабилизации и оптимизации сетапа и самих тестов, но не сказано о том, что в первую очередь надо понять, что именно больше всего затягивает выполнение тестов. Может замена создания тестового пользователя на инсерт фикстуры в бд и сократит время процента на 2-3%, а работу с какой-либо внешней монструозной системой оставили без внимания, хотя ее оптимизация (или изоляция) позволила бы сократить время вдвое.
              0
              Спасибо за комментарий )
              В целом, да, согласен )
              Замечу, что «замена создания тестового пользователя на инсерт фикстуры в бд» сделано не из-за увеличения производительности, но для того, чтобы тестировать функционал более изолировано (например, чтобы легче было локализовать ошибку: в функции создания или в функции чтения она происходит; также если тестов на чтение достаточно много, то соответственно изменение логики в создании ресурса может потребовать изменений во всех этих тестах)
              Нужно также понимать, что приемы описанные в статье являются результатом нашего опыта, поэтому не могут быть универсально применены для любого приложения. Интеграция с какой-либо системой уже скорее является частью предметной области конкретной компании и как оптимизировать работу с ней могут знать только ее работники (со своей стороны я мог бы предложить только, например, переопределить компонент в DI-контейнере/сервис-локаторе (мок))
              0
              Получается, что у вас идет только проверка ответа, но то, что записалось в БД, например, никак не проверить? Раз все только в json и yml.
              Я не считаю, что у меня очень сложный проект (CRM для школ), но и то, отправляя запрос на создание записи человека на обученике, мне нужно проверить не только, что запись создалась, но и что деньги списались, баланс у ученика обновился, уведомления ученику и менеджеру отправились и т.д.

              Вообще, я вынашиваю идею не накатывать и не откатывать изменения после каждого теста/блоков тестов, а работать с одной базой, просто подготавливать каждый раз нужные данные и считать относительные значения. Например, есть у нас созданный юзер с id 1, мы тестируем ему добавление денег на баланс. Перед выполнением теста мы взяли из БД, сколько у него сейчас на балансе, сделали свой запрос (например, внесли 100 рублей), и после этого проверили, что баланс увеличился на эти 100 рублей. В итоге нам не интересно, что там сделали с этим юзеры другие тесты, главное, чтобы не удалили.
              А если нам нужен особенный объект (удаленный юзер, или, наоборот, юзер для удаления), то мы создаем его внутри самого теста в БД.
              Такой вариант рабты тестов не рассматривали?
                0
                Состояние БД после тестов у нас также проверяется. Для этого в папке с классом теста есть отдельная директория, в которой можно описать yml с ожидаемыми значениями для интересующей таблицы.

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

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

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