Меняем моки репозиториев на in-memory реализации

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

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

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

Когда мы говорим о тестировании, мы обычно не тратим время на четкое определение всех связанных терминов, но я хотел бы пояснить, что в последнее время мне больше нравится использовать общительные (sociable) модульные тесты, чем одиночные (solitary). Они внушают куда больше уверенности в результатах тестирования, поскольку в качестве зависимостей модуля используются реальные реализации. Однако при неаккуратном использовании они могут быть очень медленными.

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

  • Моки могут запросто скрыть реальные ошибки, потому что они все еще возвращают "старые" значения, если поведение реализации по какой-то причине меняется.

  • Моки часто определяются одинаковым образом в нескольких тестах, что делает их неудобными в использовании по сравнению с "настоящей" реализацией.

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

  • Моки не очень просты в определении и делают тестовый код менее читабельным, хотя этот пункт можно отнести к разряду субъективных мнений.

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

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

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

  • Ошибки с меньшей вероятностью будут скрыты, так как обе реализации должны вести себя одинаково, поскольку они выполняют одни и те же тесты.

  • Реализацию теста можно повторно использовать в каждом тесте вместо того, чтобы каждый раз создавать моки.

  • Для тестов используется простой класс, а не сложная мок-библиотека.

  • Отладчики будут иметь доступ к реальному классу, который знают разработчики, а не к динамически генерируемому непойми чему.

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

Определяем общий интерфейс

В примере будут реализованы два разных хранилища: одно с использованием Doctrine ORM для использования в продакшене и in-memory реализация, использующая массив для хранения объектов. Я буду использовать обобщенный класс Item, чтобы сохранить общность:

<?php

namespace App\Domain;

use Doctrine\ORM\Mapping\Column;
use Doctrine\ORM\Mapping\Entity;
use Doctrine\ORM\Mapping\Id;
use Symfony\Component\Uid\Uuid;

#[Entity]
class Item
{
    #[Id]
    #[Column(type: 'uuid')]
    private Uuid $id;
    public function __construct(
        #[Column] private string $title,
        #[Column] private string $description,
    ) {
        $this->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, который занимается сохранением и извлечением объектов из хранилища данных:

<?php

namespace App\Domain;

interface ItemRepositoryInterface
{
    public function add(Item $item): void;

    /**
     * @return Item[]
     */
    public function loadAll(): array;

    /**
     * @return Item[]
     */
    public function loadFilteredByTitle(string $titleFilter): array;
}

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

Реализуем абстрактный тестовый класс

Как уже говорилось выше, абстрактный тестовый класс отвечает за то, чтобы все реализации интерфейса ItemRepositoryInterface вели себя одинаково. Одна из особенностей репозиториев заключается в том, что при добавлении одного и того же объекта дважды, в репозитории все-равно будет присутствовать всего одна его копия. Поэтому в качестве примера мы будем проверять это поведение, добавляя в хранилище два объекта, а также мы будем фильтровать их по названию. Поскольку в настоящее время интерфейс ItemRepository имеет только три метода, это уже покрывает всю его функциональность.

<?php

namespace App\Tests\Repository;

use App\Domain\Item;
use App\Domain\ItemRepositoryInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

abstract class AbstractItemRepositoryTest extends KernelTestCase
{
    abstract protected function createItemRepository(): ItemRepositoryInterface;

    abstract protected function flush(): void;

    public function testMultipleAddOfItem(): void
    {
        $itemRepository = $this->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-реализации будет выглядеть следующим образом:

<?php

namespace App\Tests\Repository\Doctrine;

use App\Domain\ItemRepositoryInterface;
use App\Repository\Doctrine\ItemRepository;
use App\Tests\Repository\AbstractItemRepositoryTest;
use Doctrine\ORM\EntityManagerInterface;

class ItemRepositoryTest extends AbstractItemRepositoryTest
{
    protected function createItemRepository(): ItemRepositoryInterface
    {
        return new ItemRepository($this->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 и выполнять приведенные выше тесты:

<?php

namespace App\Repository\Doctrine;

use App\Domain\Item;
use App\Domain\ItemRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;

class ItemRepository implements ItemRepositoryInterface
{
    public function __construct(private EntityManagerInterface $entityManager)
    {
    }

    public function add(Item $item): void
    {
        $this->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 можно оставить пустым:

<?php

namespace App\Tests\Repository\Memory;

use App\Domain\ItemRepositoryInterface;
use App\Repository\Memory\ItemRepository;
use App\Tests\Repository\AbstractItemRepositoryTest;

class ItemRepositoryTest extends AbstractItemRepositoryTest
{
    protected function createItemRepository(): ItemRepositoryInterface
    {
        return new ItemRepository();
    }

    protected function flush(): void
    {
    }
}

Реализация, выполняющая эти тесты, использует простой массив, содержащий объекты, которому нужно только проверить, содержит ли массив уже переданный Item, чтобы не вставлять его несколько раз:

<?php

namespace App\Repository\Memory;

use App\Domain\Item;
use App\Domain\ItemRepositoryInterface;

class ItemRepository implements ItemRepositoryInterface
{
    /**
     * @var Item[]
     */
    private array $items = [];

    public function add(Item $item): void
    {
        if (in_array($item, $this->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-контроллере, как показано в следующем коде:

<?php

namespace App\Controller;

use App\Domain\Item;
use App\Domain\ItemRepositoryInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;

class ItemController extends AbstractController
{
    #[Route('/items', methods: ['GET'])]
    public function list(Request $request, ItemRepositoryInterface $itemRepository): JsonResponse
    {
        $titleFilter = $request->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-реализацией это все-таки необходимо делать, поэтому я сделал для этого слушатель:

<?php

namespace App\Repository\Doctrine;

use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\KernelEvents;

class FlushEventSubscriber implements EventSubscriberInterface
{
    public function __construct(private EntityManagerInterface $entityManager)
    {
    }

    public static function getSubscribedEvents(): array
    {
        return [
            KernelEvents::RESPONSE => ['flush'],
        ];
    }

    public function flush(): void
    {
        $this->entityManager->flush();
    }
}

Я не тестировал это, но предполагаю, что метод flush не должен занимать много времени в случае, если ни одна сущность не была изменена. Альтернативным подходом может быть введение другого интерфейса FlushInterface или чего-то подобного, что также может быть заменено в зависимости от используемой реализации хранилища.

Тест для этого контроллера можно реализовать примерно так:

<?php

namespace App\Tests\Controller;

use App\Domain\Item;
use App\Domain\ItemRepositoryInterface;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

class ItemControllerTest extends WebTestCase
{
    public function testList(): void
    {
        $client = static::createClient();

        /** @var ItemRepositoryInterface */
        $itemRepository = $client->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".

Источник: https://habr.com/ru/companies/otus/articles/792680/


Интересные статьи

Интересные статьи

На кластерах клиентов, которые мы обслуживаем, есть как «одноголовые» инсталляции Redis (обычно для кэшей, которые не страшно потерять), так и более отказоустойчивые решения — Redis Sentinel или Redis...
Привет! Я впервые на хабре, мне показалось, что будет уместно поднять здесь вопрос профессиональной самореализации. Прежде чем я его задам, хотелось бы описать свою историю:С 2016 года активно развива...
Атомики в Go - это один из методов синхронизации горутин. Они находятся в пакете стандартной библиотеки sync/atomic. Некоторые статьи сравнивают atomics с mutex, так как это примитивы синхронизации ни...
Привет, друзья! Представляю вашему вниманию адаптированный и дополненный перевод этой замечательной статьи. В данной статье я хочу рассказать вам о некоторых основных математических концепциях и...
В качестве дополнения к Этюд по реализация бизнес-логики на уровне хранимых функций PostgreSQL и в основном для развернутого ответа на комментарий. Теоретическая часть отлично опи...