How To Use Dataproviders When Testing Request Validators In Laravel

Arie Visser • September 12, 2022

laravel php testing

When writing request validators in Laravel, it is useful to know which incoming data will lead to a validation error, and which data will pass.

Data providers

A convenient way to test this, is to write unit tests that make use of data providers. Data providers can provide the arguments for your tests, in this case the request data. In this way you can test multiple scenario's that need to fail or pass.

To demonstrate this, we will create the request validation for a shop where you can order only pizza or beer, that can be delivered after at least one day.

When creating request validation for storing the order, we will first create the StoreOrderRequestValidationTest. In this test we will add two providers, one for the data that should fail validation, and one for the data that should pass:

class StoreOrderRequestValidationTest extends TestCase
{
    /**
     * @dataProvider dataThatShouldFail
     */
    public function testValidationWillFail(array $requestData): void
    {
        $response = $this->json('POST', 'api/order', $requestData);
        $response->assertStatus(422);
    }

    /**
     * @dataProvider dataThatShouldPass
     */
    public function testValidationWillPass(array $requestData): void
    {
        $response = $this->json('POST', 'api/order', $requestData);
        $response->assertOk();
    }

    public function dataThatShouldFail(): array
    {
        $productName = 'pizza';
        $amount = 2;
        $deliveryDate = CarbonImmutable::tomorrow()->toDateString();

        return [
            'no_product_name' => [
                [
                    'amount' => $amount,
                    'delivery_date' => $deliveryDate,
                ],
            ],
            'no_amount' => [
                [
                    'product_name' => $productName,
                    'delivery_date' => $deliveryDate,
                ],
            ],
            'no_delivery_date' => [
                [
                    'product_name' => $productName,
                    'amount' => $amount,
                ],
            ],
            'invalid_product_name' => [
                [
                    'product_name' => 'hamburger',
                    'amount' => $amount,
                    'delivery_date' => $deliveryDate,
                ],
            ],
            'invalid_amount' => [
                [
                    'product_name' => $productName,
                    'amount' => 26,
                    'delivery_date' => $deliveryDate,
                ],
            ],
            'invalid_delivery_date' => [
                [
                    'product_name' => $productName,
                    'amount' => $amount,
                    'delivery_date' => CarbonImmutable::today(),
                ],
            ],
        ];
    }

    public function dataThatShouldPass(): array
    {
        return [
            'pizza' => [
                [
                    'product_name' => 'pizza',
                    'amount' => 3,
                    'delivery_date' => CarbonImmutable::tomorrow(),
                ],
            ],
            'beer' => [
                [
                    'product_name' => 'beer',
                    'amount' => 10,
                    'delivery_date' => CarbonImmutable::now()->addDays(10),
                ],
            ],
        ];
    }
}

First let's take a look to the dataThatShouldFail method. This method returns an array with a selection of request bodies that should fail. For example because a field is missing or invalid. With the @dataProvider dataThatShouldFail annotation we can define that this method will be used for the testValidationWillFail. This test will be executed six times, because we have defined six scenarios that should fail. Therefore, we will expect a 422 status code every time we try to create the order by making a POST call to api/order.

The dataThatShouldPass provider will provide the data for the testValidationWillPass method. It contains request data for creating an order that should not result in a validation error.

Request validation rules

Now we have the tests, we can create the FormRequest with the validation rules:

final class StoreOrderRequest extends FormRequest
{
    /**
     * Get the validation rules that apply to the request.
     *
     * @return mixed[]
     */
    public function rules(): array
    {
        return [
            'product_name' => 'required|string|in:pizza,beer',
            'amount' => 'required|integer|between:1,25',
            'delivery_date' => 'required|date|after:today',
        ];
    }
}

The test implies we have added the StoreOrderRequest to a controller method that is the route action of POST api/order.

Running the tests

When running testValidationWillFail we will get the following result:

PHPUnit 9.4.3 by Sebastian Bergmann and contributors.


Time: 00:00.175, Memory: 22.00 MB

OK (6 tests, 6 assertions)

As you can see, it makes the assertion we get a 422 response six times.

Running the testValidationWillPass should make two assertions, since we defined two orders:

PHPUnit 9.4.3 by Sebastian Bergmann and contributors.


Time: 00:00.278, Memory: 20.00 MB

OK (2 tests, 2 assertions)

Data providers as documentation

Data providers give control over the input data for your tests. They also can provide documentation for your application. In this case they show what data can be sent to your API, and to which response codes they should lead. Of course, it is possible to add more scenario's to your data providers.

You can also move your data providers to separate classes. This can be achieved by the @dataProvider class::method annotation, for example @dataProvider \App\Tests\OrderDataProvider::shouldFail()

A note about unit tests or integration tests

For this demonstration, no authentication middleware is present and no connection to the database is needed.
Typically, there is some middleware on your routes that needs a connection to the database. In that case you could try to keep this a unit test by using the withoutMiddleware method. This will prevent authorization checks. You would also have to create test doubles for repositories or services that are called in the controller.

When using the exists rule or other rules that make a connection with the database you also would need to create test doubles for these rules in order to prevent calls to the database.

However, you can also decide to test request validation in your integration tests, and create a test case that allows database connections.
This depends on the size of your test suite and the time it will take to set up all the data that is needed to run an integration test.

I hope this blog post will give you a good starting point to use data providers when testing Laravel applications. Please let me know if you have any questions or feedback.