[Перевод] Меняем моки репозиториев на in-memory реализации
Одним из важнейших аспектов тестирования наряду с поиском ошибок в приложении является время, необходимое для его проведения. Если тестирование приложения занимает от нескольких минут до нескольких часов, то оно не подходит для разработки с использованием быстрого цикла обратной связи (fast feedback loop), и разработчики могут проводить его не так часто, как следовало бы.
У пирамиды тестирования много целей, и одна из них — создать быстрый набор тестов, чтобы разработчикам не приходилось долго ждать окончания тестирования. Для этого в ней представлены три различных вида тестов: UI, сервисное и модульное (или юнит) тестирование. Основная идея заключается в том, что модульные тесты выполняются быстрее всего, и поэтому большая часть тестирования должна быть реализована в виде модульных тестов.
Когда мы говорим о тестировании, мы обычно не тратим время на четкое определение всех связанных терминов, но я хотел бы пояснить, что в последнее время мне больше нравится использовать общительные (sociable) модульные тесты, чем одиночные (solitary). Они внушают куда больше уверенности в результатах тестирования, поскольку в качестве зависимостей модуля используются реальные реализации. Однако при неаккуратном использовании они могут быть очень медленными.
Одиночные модульные тесты всегда работают с моками зависимостей, что делает их быстрыми, так как все зависимости модульного теста заменяются мок-реализацией. Очень часто для этого используются специальные инструменты какой-либо библиотеки или фреймворка, например, тестовые дублеры из PHPUnit или даже отдельная мок-библиотека, например Prophecy или Mockery. Хотя они могут сделать тесты быстрыми за счет установки ожидаемого поведения и желаемого возвращаемого значения, особенно если используются для медленных частей, таких как код, подключающийся к базе данных, они имеют ряд серьезных проблем:
Моки могут запросто скрыть реальные ошибки, потому что они все еще возвращают «старые» значения, если поведение реализации по какой-то причине меняется.
Моки часто определяются одинаковым образом в нескольких тестах, что делает их неудобными в использовании по сравнению с «настоящей» реализацией.
Моки тесно связаны с реальной реализацией, что делает рефакторинг еще более сложным, поскольку изменение может привести к необходимости поддержки изменений заодно и в целой куче тестов.
Моки не очень просты в определении и делают тестовый код менее читабельным, хотя этот пункт можно отнести к разряду субъективных мнений.
Мок-библиотеки часто используют динамические классы, что делает их понимание и отладку довольно сложными. При переходе к вызову имитируемой функции в дебаг-сессии не будет понятного кода. Вместо этого вы можете попасть в файл с сотнями строк нетривиального кода или, что еще хуже, в динамически созданный файл, даже не существующий в файловой системе.
Когда я только начинал работать, мне не были известны эти нюансы, и я направо и налево использовал одиночные модульные тесты с кучей моков. Мы очень часто делали рефакторинг, который не приводил к сбою тестов, хотя код не работал в продакшене, и я в результате тратил немало часов на отладку стороннего кода.
К счастью, есть другой способ сделать тесты быстрыми и одновременно более надежными: определите один интерфейс, напишите абстрактный тест для этого интерфейса, и пусть одни и те же тесты выполняются на одной реализации для продакшена и на гораздо более быстрой реализации для тестирования. Это решит множество проблем, описанных выше:
Ошибки с меньшей вероятностью будут скрыты, так как обе реализации должны вести себя одинаково, поскольку они выполняют одни и те же тесты.
Реализацию теста можно повторно использовать в каждом тесте вместо того, чтобы каждый раз создавать моки.
Для тестов используется простой класс, а не сложная мок-библиотека.
Отладчики будут иметь доступ к реальному классу, который знают разработчики, а не к динамически генерируемому непойми чему.
В остальной части статьи я расскажу, как это все можно сделать в Symfony, но общие принципы должны быть применимы к любому фреймворку и языку программирования. Код примера также можно найти в виде рабочего приложения в репозитории на GitHub.
Определяем общий интерфейс
В примере будут реализованы два разных хранилища: одно с использованием Doctrine ORM для использования в продакшене и in-memory реализация, использующая массив для хранения объектов. Я буду использовать обобщенный класс Item
, чтобы сохранить общность:
id = Uuid::v4();
}
public function getId(): Uuid
{
return $this->id;
}
public function getTitle(): string
{
return $this->title;
}
public function getDescription(): string
{
return $this->description;
}
}
Хорошие доменные объекты будут содержать больше методов, чем просто геттеры, но в целях лаконичности я оставлю их в этой статье в таком виде.
Это более или менее простейшая сущность Doctrine, которую мы можем создать, она содержит только Uuid
в качестве идентификатора и поля для названия и описания. Кроме того, уровень домена предоставляет интерфейс для ItemRepository
, который занимается сохранением и извлечением объектов из хранилища данных:
Контракт, определенный в этом интерфейсе, позволяет приложению не заботиться о том, какой тип хранилища используется, и поэтому в большинстве тестов можно использовать гораздо более быструю реализацию, нежели полноценная реляционная база данных. Однако для надежной замены реализаций необходимо убедиться, что все они ведут себя одинаково. Именно здесь на помощь приходит абстрактный тестовый класс.
Реализуем абстрактный тестовый класс
Как уже говорилось выше, абстрактный тестовый класс отвечает за то, чтобы все реализации интерфейса ItemRepositoryInterface
вели себя одинаково. Одна из особенностей репозиториев заключается в том, что при добавлении одного и того же объекта дважды, в репозитории все-равно будет присутствовать всего одна его копия. Поэтому в качестве примера мы будем проверять это поведение, добавляя в хранилище два объекта, а также мы будем фильтровать их по названию. Поскольку в настоящее время интерфейс ItemRepository
имеет только три метода, это уже покрывает всю его функциональность.
createItemRepository();
$item = new Item('Test title', 'Test description');
$itemRepository->add($item);
$itemRepository->add($item);
$this->flush();
$items = $itemRepository->loadAll();
$this->assertCount(1, $items);
$this->assertContains($item, $items);
}
public function testLoadAllWithMultipleItems(): void
{
$itemRepository = $this->createItemRepository();
$item1 = new Item('Test title 1', 'Test description 1');
$item2 = new Item('Test title 2', 'Test description 2');
$itemRepository->add($item1);
$itemRepository->add($item2);
$this->flush();
$items = $itemRepository->loadAll();
$this->assertCount(2, $items);
$this->assertContains($item1, $items);
$this->assertContains($item2, $items);
}
public function testLoadFilteredByTitle(): void
{
$itemRepository = $this->createItemRepository();
$item1 = new Item('Test title 1', 'Test description 1');
$item2 = new Item('Title 2', 'Description 2');
$item3 = new Item('Test title 3', 'Test description 2');
$itemRepository->add($item1);
$itemRepository->add($item2);
$itemRepository->add($item3);
$this->flush();
$items = $itemRepository->loadFilteredByTitle('Test title');
$this->assertCount(2, $items);
$this->assertContains($item1, $items);
$this->assertContains($item3, $items);
}
}
Тестовый класс должен наследоваться от KernelTestCase
из Symfony, чтобы можно было получить ссылку на EntityManagerInterface
из Doctrine, что в дальнейшем позволит нам тестировать на реальной базе данных для репозитория Doctrine.
Кроме того, в тестах должны быть переопределены два абстрактных метода для конкретных приложений:
createItemRepository
— это шаблонный метод, позволяющий менять реализацию для тестов.flush
— используется для фактической отправки изменений в базу данных, что необходимо для репозитория Doctrine в дальнейшем, если только вы не хотите добавить вызовflush
в сам репозиторий (что я не рекомендую, так как один запрос должен либо фиксировать в базе данных все свои изменения, либо ни одного).
Имея такой абстрактный тестовый класс, можно реализовать конкретные реализации и тестировать их с помощью одного и того же набора тестов.
Пишем реализации для продакшена и для тестирования
Конкретные реализации этих тестов будут переопределять методы createMatchRequest
и flush
. Поэтому тест для Doctrine-реализации будет выглядеть следующим образом:
getContainer()->get(EntityManagerInterface::class));
}
protected function flush(): void
{
$this->getContainer()->get(EntityManagerInterface::class)->flush();
}
protected function setUp(): void
{
$this->getContainer()->get(EntityManagerInterface::class)->getConnection()->setNestTransactionsWithSavepoints(true);
$this->getContainer()->get(EntityManagerInterface::class)->getConnection()->beginTransaction();
}
protected function tearDown(): void
{
$this->getContainer()->get(EntityManagerInterface::class)->getConnection()->rollBack();
}
}
Здесь createItemRepository
возвращает экземпляр App\Repository\Doctrine\ItemRepository
, который также требует экземпляр EntityManagerInterface
для правильной работы, поскольку использует это класс для сохранения и получения данных из базы данных. Метод flush
вызовет flush
из EntityManagerInterface
, который и будут сохранять данные (это вызывается в абстрактном тестовом классе). Кроме того, методы setUp
и tearDown
гарантируют, что каждый тест будет заключен в транзакцию, вызывая beginTransaction
и rollBack
. Таким образом, во время тестов никакие данные не сохраняются в базе данных, что делает их очень быстрыми. Однако будьте осторожны, поскольку на этом этапе все еще могут быть проверки базы данных, которые могут не сработать. Последний, но не менее важный метод setNestTransactionWithSavepoints
необходим для того, чтобы сделать возможной вложенность транзакций.
Следующая реализация ItemRepository
будет использовать интерфейс EntityManagerInterface
и выполнять приведенные выше тесты:
entityManager->persist($item);
}
public function loadAll(): array
{
/** @var Item[] */
return $this->entityManager
->createQueryBuilder()
->from(Item::class, 'i')
->select('i')
->getQuery()
->getResult()
;
}
public function loadFilteredByTitle(string $titleFilter): array
{
/** @var Item[] */
return $this->entityManager
->createQueryBuilder()
->from(Item::class, 'i')
->select('i')
->where('i.title LIKE :titleFilter')
->setParameter('titleFilter', $titleFilter . '%')
->getQuery()
->getResult()
;
}
}
Тесты для реализации в памяти немного проще, поскольку там нет такой зависимости, как EntityManagerInterface
, и также нет необходимости вызывать методы типа flush
. Поэтому createItemRepository
просто вернет новый экземпляр, а метод flush
можно оставить пустым:
Реализация, выполняющая эти тесты, использует простой массив, содержащий объекты, которому нужно только проверить, содержит ли массив уже переданный Item
, чтобы не вставлять его несколько раз:
items)) {
return;
}
$this->items[] = $item;
}
public function loadAll(): array
{
return $this->items;
}
public function loadFilteredByTitle(string $titleFilter): array
{
return array_values(
array_filter(
$this->items,
fn (Item $item) => str_contains($item->getTitle(), $titleFilter),
),
);
}
}
Единственное, что здесь немного громоздко, — это метод loadFilteredByTitle
, поскольку этот метод будет реализован только для тестов, что было бы не нужно, если бы использовались моки. Но, следовательно, моки могут привести к неправильным результатам тестирования, если поведение этого метода по какой-то причине изменится. В данном примере был использован array_filter
для возврата элементов, соответствующих заданным критериям, но также можно использовать цикл foreach
или что-то другое, что подходит именно вам. Конечно, это все еще очень простой пример, и в зависимости от реальной логики его может быть сложнее реализовать, но я не считаю это напрасным трудом, поскольку это дает мне уверенность и быстрые тесты.
Эта реализация не может быть использована в продакшене, если только вы не хотите, чтобы каждый запрос изначально не содержал никаких данных. Однако в остальном эта реализация ведет себя точно так же, как и Doctrine-реализация, которая фактически сохраняет данные в базе данных. Это делает ее отличным кандидатом для использования в других тестах, многие из которых, вероятно, использовали бы моки.
Используем правильную реализацию в каждой среде
Теперь, когда у нас есть две реализации одного и того же интерфейса, мы можем выбирать, какую из них использовать, например, в REST-контроллере, как показано в следующем коде:
query->getString('titleFilter');
$items = $titleFilter ? $itemRepository->loadFilteredByTitle($titleFilter) : $itemRepository->loadAll();
return $this->json($items);
}
#[Route('/items', methods: ['POST'])]
public function create(Request $request, ItemRepositoryInterface $itemRepository): JsonResponse
{
/** @var \stdClass */
$data = json_decode($request->getContent());
$item = new Item($data->title, $data->description);
$itemRepository->add($item);
return $this->json($item);
}
}
Это довольно стандартный контроллер Symfony, использующий интерфейс ItemRepositoryInterface
для внедрения одной из вышеупомянутых реализаций. В наши дни Symfony поставляется с автоматическим разрешением зависимостей (autowiring), так что обычно не нужно ничего настраивать. Однако, поскольку у нас есть две реализации интерфейса ItemRepositoryInterface
, Symfony сама по себе не может знать, какую из них использовать. Поэтому мы должны добавить следующую строку в файл config/services.yaml
:
services:
# other stuff...
App\Domain\ItemRepositoryInterface: '@App\Repository\Doctrine\ItemRepository'
Таким образом, Symfony будет знать, что он должен внедрить ItemRepository
Doctrine каждый раз, когда используется ItemRepositoryInterface
.
Обратите внимание, что контроллер не вызывает метод EntityManagerInterface::flush
. Я предпочитаю избегать использования таких методов в контроллере, так как в зависимости от того, какой ItemRepositoryInterface
используется, это может и не понадобиться. Однако в случае с Doctrine-реализацией это все-таки необходимо делать, поэтому я сделал для этого слушатель:
['flush'],
];
}
public function flush(): void
{
$this->entityManager->flush();
}
}
Я не тестировал это, но предполагаю, что метод flush
не должен занимать много времени в случае, если ни одна сущность не была изменена.Альтернативным подходом может быть введение другого интерфейса FlushInterface
или чего-то подобного, что также может быть заменено в зависимости от используемой реализации хранилища.
Тест для этого контроллера можно реализовать примерно так:
getContainer()->get(ItemRepositoryInterface::class);
$itemRepository->add(new Item('Title 1', 'Description 1'));
$itemRepository->add(new Item('Title 2', 'Description 2'));
$client->request('GET', '/items');
$responseContent = $client->getResponse()->getContent();
$this->assertNotFalse($responseContent);
$responseData = json_decode($responseContent);
$this->assertIsArray($responseData);
$this->assertCount(2, $responseData);
$this->assertEquals('Title 1', $responseData[0]->title);
$this->assertEquals('Description 1', $responseData[0]->description);
$this->assertEquals('Title 2', $responseData[1]->title);
$this->assertEquals('Description 2', $responseData[1]->description);
}
public function testListWithTitleFilter(): void
{
$client = static::createClient();
/** @var ItemRepositoryInterface */
$itemRepository = $client->getContainer()->get(ItemRepositoryInterface::class);
$itemRepository->add(new Item('Test title 1', 'Description 1'));
$itemRepository->add(new Item('Title 2', 'Description 2'));
$itemRepository->add(new Item('Test title 3', 'Description 3'));
$client->request('GET', '/items?titleFilter=Test title');
$responseContent = $client->getResponse()->getContent();
$this->assertNotFalse($responseContent);
$responseData = json_decode($responseContent);
$this->assertIsArray($responseData);
$this->assertCount(2, $responseData);
$this->assertEquals('Test title 1', $responseData[0]->title);
$this->assertEquals('Description 1', $responseData[0]->description);
$this->assertEquals('Test title 3', $responseData[1]->title);
$this->assertEquals('Description 3', $responseData[1]->description);
}
public function testCreate(): void
{
$client = static::createClient();
/** @var ItemRepositoryInterface */
$itemRepository = $client->getContainer()->get(ItemRepositoryInterface::class);
$client->jsonRequest('POST', '/items', ['title' => 'Title', 'description' => 'Description']);
$items = $itemRepository->loadAll();
$this->assertCount(1, $items);
$this->assertEquals('Title', $items[0]->getTitle());
$this->assertEquals('Description', $items[0]->getDescription());
}
}
Я не буду вдаваться во все подробности тестирования в Symfony (документация по тестированию Symfony и так проделала приличную работу в этом направлении), вместо этого я расскажу только об основных моментах: Этот тест использует интерфейс ItemRepositoryInterface
вместо интерфейса Doctrine
. Он используется для установки некоторых данных в тестах testList
и testListWithTitleFilter
, а также для подтверждения того, что данные действительно были сохранены testCreate
. Если запускать тесты в таком виде, они не часто будут успешными, поскольку база данных не сбрасывается. Однако цель этой статьи не использовать базы данных для такого рода тестов. Поэтому вместо этого создается файл config/services_test.yaml
, который содержит следующие строки:
services:
App\Domain\ItemRepositoryInterface: '@App\Repository\Memory\ItemRepository'
Таким образом, для всех тестов ItemRepository
использует только массив, всякий раз, когда происходит обращение к интерфейсу ItemRepositoryInterface
, будет задействоваться in-memory реализация. Это означает, что при такой конфигурации в приведенном выше тесте для контроллера вообще не используется база данных, что делает тесты невероятно быстрыми. В то же время эти тесты вполне надежны, поскольку in-memory реализация ведет себя как Doctrine-реализация благодаря AbstractItemRepositoryTest
.
Единственный тест, реально работающий с базой данных, — это ItemRepositoryTest
для Doctrine-реализации, который внедряет только интерфейс EntityManagerInterface
, поэтому конфигурация в services_test.yaml
в данном случае не будет применяться.
Заключение
Подводя итог, могу сказать, что я никогда не был так доволен своими тестами. Они невероятно быстры, вселяют уверенность результаты, поскольку in-memory реализация должна вести себя очень похоже на Doctrine-реализацию, и нет необходимости переопределять множество ожиданий во многих тестах, как это было бы в случае с моками.
Единственный минус, который я могу придумать, — это то, что в случае с хранилищами данных сложные запросы может быть трудно реализовать, используя только массив, но, на мой взгляд, это не является серьезным препятствием. И довольно часто некоторые вызовы методов массивов, таких как array_filter
, уже сильно помогают в этом вопросе.
Я очень рекомендую вам попробовать этот вид тестирования в ваших проектах, и я уверен, что вы об этом не пожалеете!
Статья подготовлена в преддверии старта курса OTUS «Symfony Framework».