На написание этой статьи меня подтолкнуло обсуждение докладов с 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
¦ L-- store
¦ ¦ L-- item.yml
+-- Request
¦ L-- 001_patch_not_exist.json
+-- Response
¦ L-- 001_patch_not_exist.json
¦ Tables
¦ L-- 001_patch_not_exist
¦ L-- store
¦ L-- item.yml
L-- 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 минут. Я, конечно, сомневаюсь, что описанные выше приемы в их изначальном виде окажутся полезны, но надеюсь, что они вдохновили и натолкнули на собственные идеи!
А какой подход к написанию тестов используете вы? Приходится ли их оптимизировать, и какие способы использовали вы?
dimuska139
Спасибо за статью! Подскажите, пожалуйста, как быть в случае варианта «Использовать транзакции БД при применении фикстур», когда я тестирую эндпоинт, внутри которого есть свои транзакции? Ну, скажем, у меня есть эндпоинт регистрации пользователя. Там я открываю транзакцию, внутри которой делаю запись в несколько таблиц. Я так понимаю, что в таком случае вариант использования транзакций при применении фикстур уже не прокатит?
aspirin4k Автор
Да, это может быть проблемно, т.к., насколько я знаю, практически никакая СУБД не поддерживает вложенные транзакции.
У нас используется ORM Doctrine, который реализует вложенность на своей стороне, используя механизм SAVEPOINT.
dimuska139
Спасибо. Тоже использую Doctrine — не знал, что там savepoint под капотом (доку надо лучше читать и код смотреть). Огромное спасибо!