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

Предлагаю исследовать эту проблему и вывести единственно правильную реализацию создания фабрик и сидов в такой ситуации.

Спойлер: фабрики не должны зависеть от сидов.

Подготовка проекта

Возьмем очень простой пример проекта. На сайте есть список статей, где отображается название статьи и ее автор. Авторы — это пользователи сайта, каждая статья обязательно должна иметь автора. При этом мы хотим сделать так, чтобы при выполнении сидеров создавался только один пользователь и все статьи были привязаны к нему.

Создание и настройка проекта

Подготовим этот проект (готовую реализацию уже можно посмотреть в этом репозитории). Создаем модель, миграцию, фабрику и сид для статей. Таблица с пользователями достаточна той версии, что идет в Laravel из коробки.

Создадим новую установка Laravel, используя установщик, и ставим проект с помощью консольной команды.

laravel new relation_factories

Теперь настроим файл .env, чтобы не создавать отдельную БД, для ускорения можно использовать БД sqlite.

// Параметры файла .env

DB_CONNECTION=sqlite
DB_DATABASE=/path...to...project/database/database.sqlite

Создать файл с БД можно также простой консольной командой.

touch database/database.sqlite

Создание модели статьи

Пока мы находимся недалеко от консоли, создадим модель с миграцией и фабрикой, используя консольную команду.

php artisan make:model -f -m Article

На следующем шаге необходимо описать миграцию статьи. Здесь все стандартно — добавим поле для хранения заголовка и обязательную ссылку на пользователя.

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreateArticlesTable extends Migration
{
   public function up()
   {
       Schema::create('articles', function (Blueprint $table) {
           $table->id();
           $table->string('title');
           $table->foreignId('author_id')
               ->references('id')
               ->on('users')
               ->cascadeOnDelete()
           ;
           $table->timestamps();
       });
   }

   public function down()
   {
       Schema::dropIfExists('articles');
   }
}

После создания миграции, добавим описание метода для связи с автором в классе статьи. 

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Article extends Model
{
   use HasFactory;

   public function author(): BelongsTo
   {
       return $this->belongsTo(User::class, 'author_id');
   }
}

В завершении первой части подготовки остается только выполнить миграцию. Используем знакомую консольную команду.

php artisan migrate

Создание фабрики и сидов

Базовая часть подготовки проекта готова, мы можем перейти к описанию фабрики и сида.

Начнем с фабрики. У статьи есть только одно поле с данными — это заголовок, заполним его случайным предложением.

<?php

namespace Database\Factories;

use App\Models\Article;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;

class ArticleFactory extends Factory
{
   protected $model = Article::class;

   public function definition(): array
   {
       return [
           'title' => $this->faker->sentence,
       ];
   }
}

Опытный читатель уже заметил здесь ошибку, но мы вернемся к ней чуть позже, а пока опишем код сида. Будем писать код сразу в классе DatabaseSeeder, для упрощения без создания отдельных сидов.

Создадим, как того требует задание, одного пользователя и пять статей.

<?php

namespace Database\Seeders;

use App\Models\Article;
use App\Models\User;
use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{

   public function run()
   {
        User::factory()->create();
        Article::factory()->count(5)->create();
   }
}

А теперь попробуем выполнить сидер с помощью консольной команды.

php artisan db:seed

При попытке ее выполнить мы получили фатальную ошибку.

Illuminate\Database\QueryException 

SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: articles.author_id ...

Как создать фабрику неправильно, вариант №1

Мы не можем создать статью, не описав обязательное поле для связи с автором. Исправим это.

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

Это можно сделать разными неправильными ???? способами.

Например, просто указав магический 1 в поле.

<?php
/* заголовки файла */

class ArticleFactory extends Factory
{
   /* остальная часть класса */

   public function definition(): array
   {
       return [
           'title' => $this->faker->sentence,
           'author_id' => 1,
       ];
   }
}

Работать будет, но что означает эта волшебная единица? Я тоже не знаю. Так код писать нельзя, поэтому отбрасываем этот вариант.

Второй вариант неправильной реализации — это привязка к id пользователя. Выберем первого пользователя из БД и возьмем его id.

<?php
/* заголовки файла */

class ArticleFactory extends Factory
{
   /* остальная часть класса */

   public function definition(): array
   {
       return [
           'title' => $this->faker->sentence,
           'author_id' => User::first()->id,
       ];
   }
}

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

Следующий вариант. Выбрать случайного пользователя из БД и его id, как показано на скрине ниже

<?php
/* заголовки файла */

class ArticleFactory extends Factory
{
   /* остальная часть класса */

   public function definition(): array
   {
       return [
           'title' => $this->faker->sentence,
           'author_id' => User::all()->random()->id,
       ];
   }
}

Однако, и этот путь ошибочный. В БД могут быть миллионы пользователей. Зачем тянуть их всех, загружать в память php и потом силами php перемешивать? Нужно уже на уровне запроса к БД, взять одного случайного пользователя.

Исправляем и получаем такой промежуточный вариант фабрики.

<?php
/* заголовки файла */

class ArticleFactory extends Factory
{
   /* остальная часть класса */

   public function definition(): array
   {
       return [
           'title' => $this->faker->sentence,
           'author_id' => User::inRandomOrder()->first()->id,
       ];
   }
}

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

php artisan migrate:fresh --seed

Такой вариант реализации можно встретить очень часто. Однако, это неверное решение. Будем разбираться почему и для этого доработаем проект.

Создание страницы со списком статей и теста к ней

Создадим страницу, на которой будет выводиться список статей и имена авторов.

Для этого будем использовать главную страницу. Для упрощения реализуем обработчик маршрута в виде callback функции. 

Отредактируем файл web.php

<?php

use App\Models\Article;
use Illuminate\Support\Facades\Route;

Route::get('/', function () {
   $articles = Article::with('author')->get();
   return view('welcome', compact('articles'));
})->name('home');

Теперь отредактируем файл welcome.blade.php. Сделаем его максимально простым.

@foreach ($articles as $article)
   <div>{{ $article->title }} - {{ $article->author->name }}</div>
@endforeach

Запустим Laravel приложение с помощью консольной команды.

php artisan serve

Откроем запущенный сайт и увидим на нем примерно такой контент.

Modi eum aliquam beatae ab ut commodi dignissimos est. - Karley Nicolas
Quia nostrum id quos et inventore tenetur. - Karley Nicolas
Perferendis earum ipsam ex rerum nihil dicta. - Karley Nicolas
Amet eos rem adipisci dolorem. - Karley Nicolas
Enim debitis itaque et illo occaecati non. - Karley Nicolas

Создаем автотест

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

Класс теста создадим с помощью генератора, выполнив консольную команду.

php artisan make:test ArticlesTest

Теперь напишем этот тест. Для корректной работы теста его правильнее запускать на чистой БД, поэтому обязательно используем трейт RefreshDatabase.

<?php

namespace Tests\Feature;

use App\Models\Article;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Tests\TestCase;

class ArticlesTest extends TestCase
{
   use RefreshDatabase;

   public function test_main_page_has_articles()
   {
       /**
        * Если на сайте создана статья
        */
       $article = Article::factory()
           ->create(['title' => 'example'])
       ;

       /**
        * То, когда пользователь заходит на главную страницу
        */
       $response = $this->get(route('home'));

       /**
        * Страница ДОЛЖНА открыться,
        * и контент страницы ДОЛЖЕН содержать название этой статьи
        */
       $response
           ->assertStatus(200)
           ->assertSee($article->title)
       ;
   }
}

В Laravel функция выполнения теста доступна "из коробки", поэтому просто выполним консольную команду.

php ./vendor/bin/phpunit

Однако, такой простой тест не прошел. Он вывел ошибку

1) Tests\Feature\ArticlesTest::test_main_page_has_articles
ErrorException: Attempt to read property "id" on null

Неподготовленному разработчику Laravel может показаться, что определить источник ошибки не так-то просто. На самом деле проблема в фабрике.

Как создать фабрику неправильно, вариант №2

Проблема в следующем. При выполнении теста на чистую БД накатываются миграции, но сидеры не выполняются. Поэтому в БД нет пользователей, а значит попытка выбрать пользователей в фабрике вернет null.

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

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

Воспользуемся фабрикой внутри фабрики. Сделать это очень просто. Однако, как уже заведено в этой статье, сначала я покажу вариант реализации с ошибкой. Кстати, поле id можно не указывать, Laravel достаточно умный, чтобы взять id модели за вас.

<?php
/* заголовки файла */

class ArticleFactory extends Factory
{
   /* остальная часть класса */
   public function definition(): array
   {
       return [
           'title' => $this->faker->sentence,
           'author_id' => User::factory()->create(),
       ];
   }
}

Снова запустим тест, выполнив консольную команду.

php ./vendor/bin/phpunit

Тест прошел, но на сайте возникла другая ошибка, даже две.

Для демонстрации первой из них перевыполним все миграции и сиды, а затем посмотрим, что отобразиться на сайте.

php artisan migrate:fresh --seed

На сайте видим, что теперь каждая статья привязана к своему отдельному пользователю, что не подходит нам по условию задачи.

Ad id sit distinctio perspiciatis accusantium numquam rerum quia. - Isai Rohan
Fugit ipsum odio iure. - Gretchen Prosacco
Qui est quae asperiores sed. - Abby Leannon
Placeat nobis est sed aperiam. - Dahlia McKenzie
Quod iste unde assumenda molestias eaque quia dignissimos earum. - Dr. Alysha Gutkowski Jr.

Исправить это очень просто, для этого подкорректируем код сида, передав пользователя в качестве параметра методу create.

<?php

/* заголовки файла */

class DatabaseSeeder extends Seeder
{
   public function run()
   {
        $user = User::factory()->create();
        Article::factory()
           ->count(5)
           ->create(['author_id' => $user])
       ;
   }
}

Перевыполним миграции и сиды. На сайте все отображается, тесты выполняются.

"Теперь все работает так, как надо" - подумали вы. Но не тут-то было, у нас появилась новая скрытая проблема.

Как создать фабрику неправильно, вариант №3

Использование метода фабрики create() внутри другой фабрики мгновенно приводит к созданию новой модели связи. Таким образом, у нас сейчас в БД не один пользователь, как мы думаем, а шесть. Одного мы создали через сидер, еще пять созданы фабрикой статьи.

Это можно проверить с помощью тинкера, посчитав количество пользователей в БД.

php artisan tinker

Psy Shell v0.10.8 (PHP 8.0.10 -- cli) by Justin Hileman
>>> App\Models\User::count()
=> 6

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

Правильное решение

Верное решение очень простое. Внутри фабрики необходимо использовать другую фабрику без вызова метода make() или create().

<?php
/* заголовки файла */

class ArticleFactory extends Factory
{
   /* остальная часть класса */

   public function definition(): array
   {
       return [
           'title' => $this->faker->sentence,
           'author_id' => User::factory(),
       ];
   }
}

Только теперь все заработало корректно. Тесты успешно выполняются, а сиды создают только одного пользователя.

Laravel настолько умен, что при создании модели заметит, что в поле указана фабрика и, отложит ее выполнение на самый последний момент. Если в методе create() или make() поле author_id не будет переопределено, только в этом случае эта фабрика выполнится и создаст нового автора для статьи.

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

С вами был руководитель QSOFT Академии по направлению “Разработка” - Волков Михаил (@mvsvolkov), всем классных фабрик, вкусных сидов и тестов без ошибок.

Комментарии (5)


  1. AmdY
    11.01.2022 18:21
    +3

    Если сходить в документацию, то можно найти ещё несколько правильных вариантов. А это всего-то один абзац из доки https://laravel.com/docs/8.x/database-testing#defining-relationships-within-factories


    1. qsoft Автор
      11.01.2022 18:45

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


  1. LeMaX
    12.01.2022 08:01

    А как быть при таком подходе в системах разделенных на модули? Когда есть отдельно модуль "пользователи" и отдельно модуль "новости", а тестирование по кейсу подразумевает первоначальное заполнение базы сидами. Особенно если у пользователей заранее есть роли, которые следует использовать, которые хранятся в БД и на роли так же созданы фабрики используемые в сидах?


    1. qsoft Автор
      12.01.2022 11:31
      +1

      Не до конца понятно, что вы подразумеваете под "модулями". Но подход в создании фабрики остается таким же. Каждая фабрика должна быть независима. Фабрика новости будет использовать фабрику пользователя. Фабрика пользователя будет использовать фабрику ролей. Если без этих связей модель нельзя создать.

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

      Я бы даже рекомендовал сделать дополнительные классы/методы для генерации данных в тестах. Которые бы создавали пользователя с нужной ролью (и авторизовывались под ним при необходимости).

      Что-то в таком духе:

      $admin = $this->actingAsAdmin();

      Создаем роль (если ее нет) к ней создаем пользователя и затем уже остальная логика теста. Вы также можете вызывать конкретные сидеры непосредственно из тестов:
      https://laravel.com/docs/8.x/database-testing#running-seeders


  1. MaryRabinovich
    13.01.2022 20:41

    Думала пару дней, стоит ли ввязываться в дискуссию, поскольку при нынешней моей карме я могу только в один комментарий в сутки. Но вдруг тут больше понадобится? Штош, договоримся, что завтрашний мой комментарий я мало ли ещё где оставлю, если вообще вообще попаду на хабр. Если из этого обсуждения я исчезну, короче, это ни разу не безразличие к теме.

    Во-первых, мне кажется, что тестирование через .env - это зло. Есть такая же sqlite inmemory, надо только расскомментировать две нужных строчки в phpunit.xml . В новых версиях ларавель это - готовые строчки, именно про sqlite & inmemory.

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

    Да, порой туда хочется заглянуть физически, через привычный какой-нибудь phpMyAdmin, тогда нужен .env. Но всё таки это, по моему опыту, дурной тон - особенно если это настраивается начальством. Часто сопровождается левой архитектурой. Вынужденной работой на сервере вместо домашней машинки. Так (на домашней) я могу запускать тесты раз в пять минут, на удалённой - раз в час. Кроме того, на сервере кто-то ещё тестирует, вклинивается между моими тестами. База растёт как на дрожжах - её временами чистят, из этого возникают ошибки, где вообще не ждали... Бррр.

    Далее.

    Что означает эта волшебная единица? Я тоже не знаю. Так код писать нельзя, поэтому отбрасываем этот вариант

    Стоп. Это код теста. Сидеры юзают-то обычно в тестах. В тестах обычно именно ТРЕБУЕТСЯ подконтрольность. Именно что волшебная единица может быть очень кстати. Хотя дальше возможно развитие от единицы шире. Но для начала это - самое как раз то.

    Чисто имхо - я не готова спорить.

    Спойлер: фабрики не должны зависеть от сидов.

    Давайте-ка разберёмся, кто вообще такие "фабрики" с "сидерами". Чтобы сама постановка вопроса из этого спойлера ("А зависят ли фабрики от сидеров? А да или нет?") стала бы чисто на слух радикально абсурдной.

    "Фабрика" в ларавель - это местное применение паттерна "фабрика", как я понимаю. В паттернах проектирования "фабрика" - это класс, который штампует объекты сходной природы, но с небольшими возможными контролируемыми различиями. В данном случае (на ларавель) нам для тестирования нужны типовые записи в очередную таблицу БД. Скажем, у юзера должны быть имя и мейл, у разных юзеров разные. У статьи должны быть автор, заголовок и текст, и тоже желательно разные для разных статей. Так вот, фабрика юзеров штампует данные для очередной записи "юзер" в таблице "users". Фабрика статей штампует данные для очередной записи "статья" в таблице "articles". В самом банальном виде "штампует" - как ассоциативный массив.

    Ну то есть да, это - массивы с доп.навыками. Они, например, умеют себя сами вписывать в базу. Но это уже детали.

    Фабрика может в фейкер. Фейкер - от слова "фейк" - это отдельный проект, есть на гитхабе. Он-то нам и обеспечивает контролируемые различия в выдаче. Те самые, ради которых мы обращаемся к фабрикам. У фейкера есть свои слабости - скажем, работа с кириллицей. Я уже точно не помню, в чём было дело, но помню, что как-то какую-то кириллическую задачу пыталась с ним разрешить, в итоге сделала, но неприятно хитро. Под ту же задачу с латиницей у него была функция, готова функция.

    Теперь о сидерах. Сидер - это сеятель. От слова seed - семечка. Сидер засеивает БД нашими записями. И если записи требуются ну совсем стандартные (скажем, пять записей вида "статья с названием СтатьяЭнная - от "СтатьяПервая" до "СтатьяПятая", с текстом Лорем100 - просто с одним и тем же, автором ЮзерЕдинственныйТоЕстьСАйди1", проще всего такое вот создавать на месте, в сидере прямо, без всяких фабрик). Просто тупо через Article::create([тут отдаём ассоциативный массив для очередной записи]).

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

    Только теперь все заработало корректно

    Строчка 'author_id' => User::factory() с create далее или без - она не то, чтобы неправильная... она просто лишняя. Закомментируйте её и убедитесь, что всё работает так же. Потому что фабрика, повторим, это только станок для штамповки базовой формы записи в таблицу статей. И если часть данных вы передаёте отдельно как ассоциативный массив, то внутри фабрики эти же данные можно вообще не трогать.

    Именно ассоциативный массив с айдишником автора вы передали в хвостике
    "->create(['author_id' => $user])" в классе DatabaseSeeder.

    Так что в вашем "правильном" способе написания фабрики вы строчку с вызовом User::factory() внутри класса ArticleFactory сделали безобидной, но ценности это ей не прибавило - она как была лишняя, так и осталась. Конечно, лишняя вредоносная строчка хуже лишней и безобидной, но всё таки:)

    В целом это что-то вроде array_merge. Что-то приходит от фабрики, а что-то - внутри create(). Ну и потом перед вписыванием в БД оно мержится. А неприятности появляются именно при вписывании в БД - там изнутри не пускают без идентификатора автора. Если к моменту вписывания в БД идентификатор есть - базе уже безразлично, откуда он взялся. Изнутри фабрики или уже из массива в скобках.