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