В этой статье мы рассмотрим возможность управлять данными домена для тестов. Тестовые фабрики в 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 и быть первым, кто узнает, когда мы их выпустим!