Фабрики тестирования. Предметно-ориентированный Laravel

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

Все статьи серии

Предметно-ориентированный Laravel

Возьмем пример фабричных состояний, мощного паттерна, но плохо реализованного в Laravel.

$factory->state(Invoice::class, 'pending', [
    'status' => PaidInvoiceState::class,
]);

Прежде всего: ваша IDE понятия не имеет, что это за объект $factory на самом деле. Он волшебным образом существует в фабричных файлах, потому в нем нет автодополнения. Быстрое решение состоит в том, чтобы добавить этот докблок, хотя это и громоздко.

/** @var \Illuminate\Database\Eloquent\Factory $factory */
$factory->state(/* … */);

Во-вторых, состояния определяются как строки, что делает их черным ящиком при фактическом использовании фабрики в тестах.

public function test_case()
{
    $invoice = factory(Invoice::class)
        ->states(/* what states are actually available here? */)
        ->create();
}

В-третьих, нет никакого типа, намекающего на результат фабрики, ваша IDE не знает, что $invoice на самом деле является Invoice моделью; опять же: черный ящик.

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

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

Обратите внимание, что я говорю “интеграционные тесты”, а не “модульные тесты”: когда мы тестируем наш доменный код, мы тестируем основную бизнес-логику. Чаще всего тестирование этой бизнес-логики означает, что мы будем тестировать не изолированную часть класса, а скорее сложное и запутанное бизнес-правило, которое требует наличия некоторых (или большого количества) данных в базе данных.

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

Базовая фабрика

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

Вот как выглядит такой класс, упрощенный:

class InvoiceFactory
{
    public static function new(): self
    {
        return new self();
    }
    
    public function create(array $extra = []): Invoice
    {
        return Invoice::create(array_merge(
            [
                'number' => 'I-1',
                'status' => PendingInvoiceState::class,
                // …
            ],
            $extra
        ));   
    }
}

Давайте обсудим несколько дизайнерских решений.

Прежде всего, статический конструктор new. Вы можете быть сбиты с толку относительно того, зачем нам это нужно, поскольку мы могли бы просто сделать create метод статичным. Я подробно отвечу на этот вопрос позже в этой главе, но сейчас вы должны знать, что мы хотим, чтобы эта фабрика была легко настраиваемой, прежде чем фактически создавать счет-фактуру. Так что будьте уверены, скоро все прояснится.

Во-вторых, почему название new для статического конструктора? Ответ практический: в контексте фабрик make и create часто ассоциируется с фабрикой, фактически производящей результат. new помогает нам избежать ненужной путаницы.

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

С помощью нашего простого примера теперь мы можем создавать счета-фактуры следующим образом:

public function test_case()
{
    $invoice = InvoiceFactory::new()->create();
}

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

class InvoiceFactory
{
    private static int $number = 0;

    public function create(array $extra = []): Invoice
    {
        self::$number += 1;

        return Invoice::create(array_merge(
            [
                'number' => 'I-' . self::$number,
                // …
            ],
            $extra
        ));   
    }
}

Фабрики в фабриках

В исходном примере я показал, что мы можем захотеть создать оплаченный счет. Раньше я был немного наивен, когда предполагал, что это просто означает изменение поля статуса в модели счета-фактуры. Нам также нужно, чтобы фактический платеж был сохранен в базе данных! Фабрики Laravel по умолчанию могут справиться с этим с помощью замыканий, которые запускаются после создания модели; хотя представьте себе, что происходит, если вы управляете несколькими, может быть, даже десятками состояний, каждое из которых имеет свои собственные побочные эффекты. Простой хук $factory->afterCreating просто недостаточно надежен, чтобы управлять всем этим в здравом уме.

Итак, давайте все перевернем. Давайте правильно настроим нашу фабрику счетов-фактур, прежде чем создавать фактический счет-фактуру.

class InvoiceFactory
{
    private string $status = null;

    public function create(array $extra = []): Invoice
    {
        $invoice = Invoice::create(array_merge(
            [
                'status' => $this->status ?? PendingInvoiceState::class
            ],
            $extra
        ));
        
        if ($invoice->status->isPaid()) {
            PaymentFactory::new()->forInvoice($invoice)->create();
        }
        
        return $invoice;
    }

    public function paid(): self
    {
        $clone = clone $this;
        
        $clone->status = PaidInvoiceState::class;
        
        return $clone;
    }
}

Мы сделали настраиваемым статус счета-фактуры, точно так же, как фабрики состояний в Laravel, но в нашем случае есть преимущество, что наша IDE действительно знает, с чем мы имеем дело:

public function test_case()
{
    $invoice = InvoiceFactory::new()
        ->paid()
        ->create();
}

Тем не менее, есть место для улучшения. Вы видели тот чек, который мы делаем после создания счета-фактуры?

if ($invoice->status->isPaid()) {
    PaymentFactory::new()->forInvoice($invoice)->create();
}

Это можно сделать еще более гибким. Мы используем PaymentFactory. Но что, если мы хотим более тонкого контроля над тем, как была произведена эта оплата? Вы можете себе представить, что существуют некоторые бизнес-правила, касающиеся оплаченных счетов, которые ведут себя по-разному в зависимости от типа платежа, например.

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

Вот ответ: мы позволяем разработчику опционально передать PaymentFactoryInvoiceFactory, чтобы эта фабрика могла быть настроена так, как хочет разработчик. Вот как это выглядит:

public function paid(PaymentFactory $paymentFactory = null): self
{
    $clone = clone $this;
    
    $clone->status = PaidInvoiceState::class;
    $clone->paymentFactory = $paymentFactory ?? PaymentFactory::new();
    
    return $clone;
}

И вот как это используется в createметоде:

if ($this->paymentFactory) {
    $this->paymentFactory->forInvoice($invoice)->create();
}

При этом возникает множество возможностей. В этом примере мы делаем счет, который оплачивается, в частности, с помощью платежа Bancontact.

public function test_case()
{
    $invoice = InvoiceFactory::new()
        ->paid(
            PaymentFactory::new()->type(BancontactPaymentType::class)
        )
        ->create();
}

Другой пример: мы хотим проверить, как обрабатывается счет, когда он был оплачен, но только после истечения срока действия счета:

public function test_case()
{
    $invoice = InvoiceFactory::new()
        ->expiresAt('2020-01-01')
        ->paid(
            PaymentFactory::new()->at('2020-01-20')
        )
        ->create();
}

С помощью всего лишь нескольких строк кода мы получаем гораздо большую гибкость.

Неизменяемые фабрики

А как насчет того клонирования, которое было выше? Почему так важно сделать фабрики неизменными? Видите ли, иногда вам нужно сделать несколько моделей, но с небольшими различиями. Вместо того чтобы создавать новый объект фабрики для каждой модели, вы можете повторно использовать исходную фабрику и изменять только то, что вам нужно.

Однако если вы не используете неизменяемые фабрики, есть вероятность, что вы получите данные, которые на самом деле вам не нужны. Возьмем пример оплаты счетов: скажем, нам нужны два счета на одну и ту же дату, один оплаченный и один ожидающий.

$invoiceFactory = InvoiceFactory::new()
    ->expiresAt(Carbon::make('2020-01-01'));

$invoiceA = $invoiceFactory->paid()->create();
$invoiceB = $invoiceFactory->create();

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

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

С тех пор, как я использовал этот шаблон, я никогда не оглядывался на встроенные фабрики Laravel. Слишком много можно извлечь из этого подхода.

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

abstract class Factory
{
    // Concrete factory classes should add a return type 
    abstract public function create(array $extra = []);

    public function times(int $times, array $extra = []): Collection
    {
        return collect()
            ->times($times)
            ->map(fn() => $this->create($extra));
    }
}

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

Я бы предложил поиграть с ними в следующий раз, когда вам понадобятся тестовые фабрики. Уверяю вас, они вас не разочаруют!

Оригинал


Прошел почти год с тех пор, как был опубликован первый пост в серии под названием “Laravel beyond CRUD”. Его цель-рассказать о том, как мы строили большое приложение со сложными бизнес-правилами, в Laravel.

Ясно, что многим из вас понравилась эта серия: она получила более 150 000 просмотров и я все еще получаю несколько сообщений в неделю с вопросами.

Мы — мои коллеги и я-решили сделать еще один шаг вперед: я переделываю серию блогов в книгу, а также делаю из нее видеокурс.

Есть примерный проект, построенный с нуля на этих принципах , есть прекрасно оформленная Электронная книга моего коллеги Seb, есть совершенно новые главы, никогда ранее не выпускавшиеся, и видеокурс, организованный мной, чтобы воплотить все это на практике. Мои коллеги Freek и Willem позаботились о веб-сайте, а также помогли разработать и отредактировать курс от начала до конца.

Итак, что же дальше? Мы все еще заканчиваем курс, но вы уже можете подписаться на рассылку новостей, чтобы оставаться в курсе событий! Если вам не нравятся информационные рассылки, вы можете следить за мной в Twitter и быть первым, кто узнает, когда мы их выпустим!

Рейтинг
( Пока оценок нет )
Maxyc Webber/ автор статьи
Мне 35 лет. Опыт профессиональной разработки 15 лет. Занимаюсь разработкой и поддержкой корпоративных систем автоматизации бизнеса, а также высоконагруженными проектами. Мне нравится решать нестандартные проблемы бизнеса. Имею опыт формирования команд под проект, налаживания процесса разработки, коммуникации программистов и заказчиков. Есть опыт работы с зарубежными заказчиками (ОАЭ, Польша, Германия, Швейцария).
Понравилась статья? Поделиться с друзьями:
Добавить комментарий

;-) :| :x :twisted: :smile: :shock: :sad: :roll: :razz: :oops: :o :mrgreen: :lol: :idea: :grin: :evil: :cry: :cool: :arrow: :???: :?: :!:

Этот сайт использует Akismet для борьбы со спамом. Узнайте, как обрабатываются ваши данные комментариев.