На написание этой статьи меня подтолкнуло обсуждение докладов с 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 минут. Я, конечно, сомневаюсь, что описанные выше приемы в их изначальном виде окажутся полезны, но надеюсь, что они вдохновили и натолкнули на собственные идеи!

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