Введение


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


Содержание статьи приведено ниже:


  1. Описание предметной области
  2. Создание приложения
  3. Создание сущностей
  4. Написание тестов
  5. Проблема
  6. Решение

Описание предметной области


Мы будем разрабатывать интернет-магазин, в котором некие пользователи могут сделать некий заказ. Из вышеперечисленного получаем, что основными сущностями предметной области будут пользователь, заказ и товары. Между пользователем и заказом связь один-ко-многим, т. е. у пользователя может быть много заказов, а у заказа — только один пользователь (для заказа наличие пользователя обязательно). Между заказом и товарами связь многие-ко-многим, т. к. товар может быть в разных заказах и заказ может состоять из многих товаров. Для упрощения опустим товары и сосредоточимся только на пользователях и заказах.


Создание приложения


Приложения на Laravel очень просто создавать, используя пакет-создатель приложений. После его установки создание нового приложения умещается в одну команду:


laravel new shop

Создание сущностей


Как было сказано выше, нам нужно создать две сущности — пользователя и заказ. Так как Laravel поставляется с готовой сущностью пользователя, то перейдём к процессу создания модели заказа. Нам понадобится модель заказа, миграция для БД и фабрику для создания экземпляров. Команда для создания всего этого:


php artisan make:model Order -m -f

После выполнения команды мы получим файл модели в App/, файл миграции в папке database/migrations/ и фабрику в database/factories/.


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


<?php

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

class CreateOrdersTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('orders', function (Blueprint $table) {
            $table->id();
            $table->unsignedBigInteger('user_id');
            $table->timestamps();

            $table->foreign('user_id')->references('id')->on('users')
                ->onDelete('cascade');
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('orders');
    }
}

Теперь к модели. Заполним свойство fillable и сделаем релейшен к пользователю:


<?php

namespace App;

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

class Order extends Model
{
    protected $fillable = ['user_id'];

    /**
     * Relation to user
     * @return BelongsTo
     */
    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }
}

Переходим к фабрике. Помним, что связь с пользователем является обязательной, поэтому при запуске фабрики будем запускать фабрику пользователя и брать её id.


<?php

/** @var \Illuminate\Database\Eloquent\Factory $factory */

use App\Order;
use App\User;
use Faker\Generator as Faker;

$factory->define(Order::class, function (Faker $faker) {
    return [
        'user_id' => factory(User::class)->create()->id
    ];
});

Сущности готовы, переходим к написанию тестов.


Написание тестов


По стандарту, Laravel использует PHPUnit для тестирования. Создать тесты для заказа:


php artisan make:test OrderTest

Файл теста можно найти в tests/Feature/. Для обновления состояния БД перед запуском тестов будем использовать трейт RefreshDatabase.


Тест №1. Проверим работу фабрики


<?php

namespace Tests\Feature;

use App\Order;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Tests\TestCase;

class OrderTest extends TestCase
{
    use RefreshDatabase;

    /** @test */
    public function order_factory_can_create_order()
    {
        // When we use Order factory
        $order = factory(Order::class)->create();
        // Then we should have new Order::class instance
        $this->assertInstanceOf(Order::class, $order);
    }
}

Тест прошел!


Тест №2. Проверим наличие пользователя у заказа и работу релейшена


/** @test */
public function order_should_have_user_relation()
{
    // When we use Order factory
    $order = factory(Order::class)->create();
    // Then we should have new Order::class instance with user relation
    $this->assertNotEmpty($order->user_id);
    $this->assertInstanceOf(User::class, $order->user);
}

Тест прошел!


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


/** @test */
public function we_can_provide_user_id_to_order_factory()
{
    // Given user
    $user = factory(User::class)->create();
    // When we use Order factory and provide user_id parameter
    $order = factory(Order::class)->create(['user_id' => $user->id]);
    // Then we should have new Order::class instance with provided user_id
    $this->assertEquals($user->id, $order->user_id);
}

Тест прошёл!


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


Тест №4. Проверим, что в системе при создании одного заказа создаётся один пользователь


/** @test */
public function when_we_create_one_order_one_user_should_be_created()
{
    // Given user
    $user = factory(User::class)->create();
    // When we use Order factory and provide user_id parameter
    $order = factory(Order::class)->create(['user_id' => $user->id]);
    // Then we should have new Order::class instance with provided user_id
    $this->assertEquals($user->id, $order->user_id);
    // Let's check that system has one user in DB
    $this->assertEquals(1, User::count());
}

Тест проваливается! Оказывается, в базе данных на момент запуска проверки уже было два пользователя. Как так? Разберёмся в следующем шаге.


Проблема


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


Решение


Опишу решение, которым пользуюсь сам. В PHP есть функция, с помощью которой можно получить n-ый аргумент функции — func_get_arg(), ей мы воспользуемся для изменения поведения фабрики заказов. По стандарту, в фабрику первым (нулевым) аргументом передаётся Faker, а вторым аргументом — массив значений, переданных в метод create() фабрики заказа. Соответственно, чтобы получить список переданных значений, нужно взять второй (первый) аргумент функции. В назначения значений важных ключей фабрики передадим анонимную функцию, которая будет проверять, было ли передано значение по ключу или нет. Что имеем в итоге:


$factory->define(Order::class, function (Faker $faker) {
    // Получаем массив переданных значений
    $passedArguments = func_get_arg(1);
    return [
        'user_id' => function () use ($passedArguments) {
            // Если не передали user_id, то создаём своего
            if (! array_key_exists('user_id', $passedArguments)) {
                return factory(User::class)->create()->id;
            }
        }
    ];
});

Запускаем тест №4 ещё раз — он проходит!


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


Буду рад слышать ваши хитрости, которыми вы пользуетесь при разработке на Laravel или PHP.