
На написание этой статьи меня подтолкнуло обсуждение докладов с Heisenbug 2021 в нашем корпоративном чате. Причиной является тот факт, что достаточно много внимания уделяется «правильному» написанию тестов. В кавычках — потому что на бумаге все действительно логично и аргументированно, однако на практике такие тесты получаются достаточно медленными.
Эта статья ориентирована скорее на новичков в программировании, но возможно кто-то сможет вдохновиться одним из описанных далее подходов.
Я думаю, все знают принципы хороших тестов:
Тест должен быть атомарным, т.е. проверять единицу логики (например, один HTTP-метод или один метод класса)
Тест должен быть изолированным, т.е. прохождение тестов не должно зависеть от порядка их выполнения
Тест должен быть повторяемым, т.е. выполнение теста локально и в CI должно приводить к одному результату
Проблема возникает тогда, когда вы начинаете пытаться реализовывать подобные тесты и сталкиваетесь с реальностью: pipeline всего из 3000 функциональных тестов проходит около двух часов!
Методы, которые я хочу описать, были выработаны непосредственно в ходе эволюции текущего приложения компании, т.е. сильно связаны с нашим контекстом. Поэтому далее я хочу кратко описать структуру наших тестов.
Любой запуск функциональных тестов для API можно разделить на следующие этапы:
Применить миграции (Структура БД)
Применить фикстуры (Тестовые данные в БД)
Выполнить HTTP-запрос
Выполнить необходимые проверки
Как вы знаете, атомарность значит, что мы должны проверять только одну функциональную единицу. В нашем случае мы имеем дело с 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. Использовать транзакции БД при применении фикстур
Одним из возможных подходов для оптимизации может быть использование транзакции для накатывания фикстур. Идея заключается в том, чтобы обернуть весь тест в транзакцию, а после его выполнения просто ее откатить. Получается следующий алгоритм:
Применить фикстуры
В цикле для каждого теста:
Начать транзакцию
Выполнить тест
Проверить результат
Откатить транзакцию
При локальном запуске 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 минут. Я, конечно, сомневаюсь, что описанные выше приемы в их изначальном виде окажутся полезны, но надеюсь, что они вдохновили и натолкнули на собственные идеи!
А какой подход к написанию тестов используете вы? Приходится ли их оптимизировать, и какие способы использовали вы?
