Введение
Давайте представим, что мы разрабатываем небольшое веб-приложение на Laravel версии выше 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.
marvin255
У меня возникает проблема с уникальными полями. Например, в проекте имеется словарь статусов у каждого из которых есть свой собственный код, который должен быть уникальным и не более трех символов длиной. Пользователь может создавать новые статусы самостоятельно.
В итоге для того, чтобы можно было воспользоваться фабрикой для создания коллекции статусов и при этом гарантировать выполнение теста без ошибок, приходится делать нечто вроде:
Возможно у вас есть лучшее решение?
tatu
Попробуйте запись:
marvin255
Пробовали. Есть стандартные статусы, которые всегда создаются с помощью seeder'а. В итоге, очень-очень редко по велению великого рандома тесты падали. После очередного расследования мы решили проверять данные в базе :)
svntmr Автор
А в системе обязательно должен находиться список этих статусов?
Если они не так важны, то можно удалять их перед запуском каждого теста с помощью запроса внутри функции setUp().
Ещё есть удобные трейты — DatabaseTransactions и RefreshDatabase. Первый открывает транзакцию и не закрывает её, поэтому состояние базы не изменяется, а второй — делает artisan migrate:refresh перед каждым тестом и откатывает-накатывает состояние БД, но это не всегда применимо.
webdevium
Возьми одно случайное значение из уже сохраненных в базу.