Testing Laravel Form Requests in a different way

Testing Laravel Form Requests in a different way

In this guide I will be showing a different way to test Laravel’s form requests which reduces duplicate code and improves maintainability.

In this guide I will be showing a different way to test Laravel’s form requests which reduces duplicate code and improves maintainability.

Many developers are struggling with testing form requests in an efficient way. Most of the time you’ll end up with writing a seperate unit test for each rule that is defined in your form request. This results in a lot of tests like test_request_without_title and test_request_without_content. All of these methods are implemented in exactly the same way, only calling your endpoint with some different data. This will result in a lot of duplicate code. In this guide I will show you a different way to test your form request, which I think is more clean and improves the maintainability of your tests.

Creating a form request

For this example I will be making a form request to save a product.

php artisan make:request SaveProductRequest

The generated file class will be placed in App/Http/Requests.

We will declare a set of validation rules for this form request:

  1. The *title *parameter should be a string with a maximum of 50 characters.
  2. The *price *parameter should be numeric.

Those are the only two validation rules for now.

This is what the SaveProductRequest *class *looks like:

<?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',
        ];
    }
}

Within the authorize method you can check if the user has permission to perform this request. For example, you could check if the user is an admin, but for now anybody is allowed to perform this request.

Setting up the model

Let’s create a Product model:

php artisan make:model Models/Product -m

The migration file looks like this:

<?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');
    }
}

Setting up the controller and route

Let’s set up the ProductController:

php artisan make:controller ProductController

And give it a very simple implementation:

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

Note:

The ProductResource is a resource that you can make with:

php artisan make:resource Product

And finally, add a route to your routes/api.php:

Route::post('/products', '[email protected]')->name('products.store');

Writing the tests

Before we can start making our tests we have to create a testfile:

php artisan make:test App/Http/Requests/SaveProductRequestTest

Note:

I prefer to structure my tests in this way, but you could choose to leave out the App/Http/Requests folder.

A typical test suite for this controller could look like this:

<?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'
        ]);
    }
}

This is how most of the developers would test a form request. This works and all the tests are passing, but there is a lot of duplicate code. The only thing that differs between the tests is the data that gets send to the endpoint. This can be done more efficiently.

Meet PHPUnit’s data provider

PHPUnit’s data provider provides an elegant way to write tests for Laravel’s form requests. A data provider allows you to structure tests once and run them multiple times with different datasets.

A data provider method must be public and return an array or an object that implements the Iterator interface. You can specify the data provider by using the *@dataProvider *annotation.

The most basic example of a data provider looks like this:

/**
 * @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]
    ];
}

For every array in the provider method the testAdd method will be called. The arguments that are being passed to the testAdd method are specified in the array from the provider. So the first call would be testAdd(0, 0, 0) and the second call would be testAdd(0, 1, 1).

How can we use this to test our form request?

Just like we specified the numbers for the testAdd method in a data provider, we could also specify the data which our endpoint gets called with. Then we run each of those data arrays through Laravel’s Validator class to check if the validation rules pass.

What is most important here is the structure of the data provider. In the key of the data provider array we specify the name of the test. Within this array we have two attributes: passed and data*. The *passed attribute is a boolean with the expected outcome of the validator. The data attribute contains the data that we want to send to the endpoint.

This is what the code will look like:

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

And the tests still pass

The result is the same, all tests still pass, but duplication is reduced and maintainability improved. What do you think about this way of testing your form requests? Do you test your form requests in a different way? Please let me know in the comments.

If you enjoyed this post or if it has helped you testing your code make sure to check out my other posts aswell. Please feel free to leave a comment if you have any feedback, questions or want me to write about another Laravel related topic.

laravel php testing

Bootstrap 5 Complete Course with Examples

Bootstrap 5 Tutorial - Bootstrap 5 Crash Course for Beginners

Nest.JS Tutorial for Beginners

Hello Vue 3: A First Look at Vue 3 and the Composition API

Building a simple Applications with Vue 3

Deno Crash Course: Explore Deno and Create a full REST API with Deno

How to Build a Real-time Chat App with Deno and WebSockets

Convert HTML to Markdown Online

HTML entity encoder decoder Online

Php how to delete multiple rows through checkbox using ajax in laravel

In this article i will let you know to delete multiple rows through checkbox using ajax in laravel and before delete we will give a confirmation message.

5 Laravel’s Hidden Gems

Spread the love1. Stop on first validation error By default, Laravel will check for all validation rules and return a list of errors. But if you want to stop this process after first validation failure, that’s how you can achieve…Read More→

Some of the most frequent how tos in Laravel

Spread the loveHow to get relationship from relationship using With() in Laravel Some times there are cases where you want to get relationship from relationship in Laravel, that can be achieved via following: How to create multiple where clauses in…Read More→

Keeping Tests Simple in Laravel

How can we keep tests in a Laravel application simple and quick to write?

10 Laravel Quick Tips

Spread the loveTip 1. Controllers Having Single Action In some situations you need a single action in a controller, if this is the case in Laravel you can achieve it by __invoke() method. Routes: Artisan command to generate this controller:…Read More→