Другой способ тестирования запросов формы Laravel

Многие разработчики борются с эффективностью тестирования запросов форм. В большинстве случаев вам придется писать отдельный модульный тест для каждого правила, определенного в вашем запросе формы. Это приводит к появлению множества тестов, таких как test_request_without_title и test_request_without_content. Все эти методы реализованы точно так же, только вызов вашей конечной точки с некоторыми разными данными. Это приведет к дублированию кода. В этом руководстве я покажу вам другой способ тестирования запроса формы, который, на мой взгляд, более понятен и улучшает ремонтопригодность ваших тестов.

Создание запроса формы

В этом примере я буду делать запрос формы для сохранения продукта.

php artisan make:request SaveProductRequest

Созданный класс файла будет помещен в App / Http / Requests.

Мы объявим набор правил проверки для этого запроса формы:

  1. Параметр title должен быть строкой длиной не более 50 символов.
  2. Параметр price должен быть числовым.

На данный момент это единственные два правила проверки.

Вот как выглядит класс SaveProductRequest :

<?php

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 вы можете проверить, есть ли у пользователя разрешение на выполнение этого запроса. Например, вы можете проверить, является ли пользователь администратором, но на данный момент любой может выполнить этот запрос.

Настройка модели

Давайте создадим модель продукта:

php artisan make:model Models/Product -m

Файл миграции выглядит так:

<?php

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

И дайте ему очень простую реализацию:

<?php

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.

Типичный набор тестов для этого контроллера может выглядеть так:

<?php

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. Поставщик данных позволяет один раз структурировать тесты и запускать их несколько раз с разными наборами данных.

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

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

Вот как будет выглядеть код:

<?php

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();
    }
}

И тесты все еще проходят

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

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