В этом уроке я покажу вам еще один способ проверки запроса формы, гораздо более чистый и повышающий удобство сопровождения ваших тестов. Многие разработчики пытаются эффективно протестировать запросы форм (form requests). Обычно большая часть времени тратится на написание отдельного модульного теста для каждого правила в запросе. Что ведете к множеству тестов, типа test_request_without_title и test_request_without_content. Причем все эти методы будет реализованы абсолютно одинаково, отличаясь только вызовами конечной точки с различными данными. В результате имеем кучу дублированного кода.
Создаем запрос формы
Для примера я создам form request сохранения товара.
php artisan make:request SaveProductRequest
Файл сгенерированного класса будет размещен в App/Http/Requests.
Объявим набор проверочных правил для этого запроса:
- Параметр title должен представлять собой строку длиной не более 50 символов.
- Параметр price должен быть числовым.
На данный момент всего два правила.
Класс SaveProductRequest должен выглядеть вот так:
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class SaveProductRequest extends FormRequest
{
public function authorize()
{
return true;
}
public function rules()
{
return [
'title' => 'required|string|max:50',
'price' => 'required|numeric',
];
}
}
В методе authorize вы можете проверить, есть ли у пользователя права на выполнение этого запроса. Например, проверить, является ли пользователь администратором. А пока разрешим выполнять этот запрос любому.
Настраиваем Модель
Создадим модель Product:
php artisan make:model Models/Product -m
Файл миграции:
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateProductsTable extends Migration
{
public function up()
{
Schema::create('products', function (Blueprint $table) {
$table->bigIncrements('id');
$table->string('title');
$table->double('price');
$table->timestamps();
});
}
public function down()
{
Schema::dropIfExists('products');
}
}
Настраиваем Контроллер и Маршрут
Создадим ProductController:
php artisan make:controller ProductController
с очень простой реализацией:
namespace App\Http\Controllers;
use App\Http\Requests\SaveProductRequest;
use App\Http\Resources\Product as ProductResource;
use App\Models\Product;
class ProductController extends Controller
{
public function store(SaveProductRequest $request)
{
$product = Product::create($request->validated());
return ProductResource::make($product);
}
}
Примечание: ProductResource — это ресурс, который вы можете создать командой:
php artisan make:resource Product
И, наконец, добавим маршрут в файл routes/api.php:
Route::post('/products', 'ProductController@store')->name('products.store');
Создание тестов
Прежде, чем мы сможем начать писать наши тесты, нужно создать тестовый файл:
php artisan make:test App/Http/Requests/SaveProductRequestTest
Примечание: Я предпочитаю такую структуру тестов, но вы можете убрать App/Http/Requests.
Типичный набор тестов этого контроллера может выглядеть следующим образом:
namespace Tests\Feature\App\Http\Requests;
use App\User;
use Illuminate\Http\Response;
use Tests\TestCase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Foundation\Testing\RefreshDatabase;
class SaveProductRequestTest extends TestCase
{
use RefreshDatabase, WithFaker;
protected function setUp(): void
{
parent::setUp();
$this->user = factory(User::class)->create();
}
/** @test */
public function request_should_fail_when_no_title_is_provided()
{
$response = $this->actingAs($this->user)
->postJson(route('products.store'), [
'price' => $this->faker->numberBetween(1, 50)
]);
$response>assertStatus(
Response::HTTP_UNPROCESSABLE_ENTITY
);
$response->assertJsonValidationErrors('title');
}
/** @test */
public function request_should_fail_when_no_price_is_provided()
{
$response = $this->actingAs($this->user)
->postJson(route('products.store'), [
'title' => $this->faker->word()
]);
$response->assertStatus(
Response::HTTP_UNPROCESSABLE_ENTITY
);
$response->assertJsonValidationErrors('price');
}
/** @test */
public function request_should_fail_when_title_has_more_than_50_characters()
{
$response = $this->actingAs($this->user)
->postJson(route('products.store'), [
'title' => $this->faker->paragraph()
]);
$response->assertStatus(
Response::HTTP_UNPROCESSABLE_ENTITY
);
$response->assertJsonValidationErrors('price');
}
/** @test */
public function request_should_pass_when_data_is_provided()
{
$response = $this->actingAs($this->user)
->postJson(route('products.store'), [
'title' => $this->faker->word(),
'price' => $this->faker->numberBetween(1, 50)
]);
$response->assertStatus(Response::HTTP_CREATED);
$response->assertJsonMissingValidationErrors([
'title',
'price'
]);
}
}

Именно так большинство разработчиков будут тестировать запрос формы. Это работает, все тесты проходятся, но много дублированного кода. Единственное, чем различаются тесты, это данные, отправляемые в конечную точку. Можно сделать это более эффективно.
Знакомимся с провайдером данных PHPUnit
Провайдер данных PHPUnit предоставляет элегантный способ написания тестов для запросов форм Laravel. Он позволяет структурировать тесты один раз и затем запускать их с разными наборами данных.
Метод провайдера должен быть public и он должен возвращать массив или объект, реализующий интерфейс Iterator. Вы можете указать провайдера с помощью аннотации @dataProvider.
Самый простой пример провайдера:
/**
* @dataProvider provider
*/
public function testAdd($a, $b, $c)
{
$this->assertEquals($c, $a + $b);
}
public function provider()
{
return [
[0, 0, 0],
[0, 1, 1],
[1, 0, 1],
[1, 1, 3]
];
}
Для каждого массива в методе provider будет вызван метод testAdd. Аргументы, которые передаются методу testAdd, указываются в массиве от провайдера. Таким образом, первый вызов будет testAdd(0, 0, 0), а второй — testAdd(0, 1, 1).
Как это можно использовать для тестирования нашего запроса форм
Так же, как мы указали числа для метода testAdd в провайдере, также можем указать данные для вызова конечной точки. Затем мы запускаем все это через класс Validator, чтобы проверить, проходят ли данные валидацию.
Самое важное здесь — это структура провайдера. В ключе массива данных указывается название теста. В самом массиве два атрибута: passed и data. Атрибут passed является булевым с ожидаемым результатом валидатора. Атрибут data содержит данные, которые мы хотим отправить в конечную точку.
Вот как будет выглядеть код:
namespace Tests\Feature\App\Http\Requests;
use App\Http\Requests\SaveProductRequest;
use Faker\Factory;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
class SaveProductRequestTest extends TestCase
{
use RefreshDatabase;
/** @var \App\Http\Requests\SaveProductRequest */
private $rules;
/** @var \Illuminate\Validation\Validator */
private $validator;
public function setUp(): void
{
parent::setUp();
$this->validator = app()->get('validator');
$this->rules = (new SaveProductRequest())->rules();
}
public function validationProvider()
{
/* WithFaker trait doesn't work in the dataProvider */
$faker = Factory::create( Factory::DEFAULT_LOCALE);
return [
'request_should_fail_when_no_title_is_provided' => [
'passed' => false,
'data' => [
'price' => $faker->numberBetween(1, 50)
]
],
'request_should_fail_when_no_price_is_provided' => [
'passed' => false,
'data' => [
'title' => $faker->word()
]
],
'request_should_fail_when_title_has_more_than_50_characters' => [
'passed' => false,
'data' => [
'title' => $faker->paragraph()
]
],
'request_should_pass_when_data_is_provided' => [
'passed' => true,
'data' => [
'title' => $faker->word(),
'price' => $faker->numberBetween(1, 50)
]
]
];
}
/**
* @test
* @dataProvider validationProvider
* @param bool $shouldPass
* @param array $mockedRequestData
*/
public function validation_results_as_expected($shouldPass, $mockedRequestData)
{
$this->assertEquals(
$shouldPass,
$this->validate($mockedRequestData)
);
}
protected function validate($mockedRequestData)
{
return $this->validator
->make($mockedRequestData, $this->rules)
->passes();
}
}
И снова тесты пройдены

Результат тот же, все тесты по-прежнему проходятся, но дублирование кода мы сократили, а удобство сопровождения повысили.
Автор: Daan
Перевод: Алексей Широков
Наш Телеграм-канал — следите за новостями о Laravel.
