DEV Community 👩‍💻👨‍💻

Dainius
Dainius

Posted on

Testing Laravel form requests

I was struggling to decide how I should test form requests in Laravel. Specifically, I wanted to test validation. I did a quick google search and it looks like there are multiple ways to do it. One blog post stood out of all, which is A guide to unit testing Laravel Form Requests in a different way by @daaaan. He suggests instead of writing integration tests, and having multiple methods such as it_fails_validation_without_title we should use unit tests with the help of PHPUnit's @dataProviders.

This was definitely something I wanted to try, because in the past I had form requests with tens of test methods, each testing individual request attribute, and there was a lot of code duplication.

Daan suggests the following:

<?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();
    }
}
Enter fullscreen mode Exit fullscreen mode

How does this work?

Looks like we just provide an array with a test method, whether we expect it to pass, and some request attributes. We can have as many test methods as we want this way, and PHPunit will take care of the rest. We add @dataProviderannotation to validation_results_as_expected method, which is where the assertion actually happens. Looping through each item, it then calls validate method in our test case. This method, in turn calls validate on $this->validator which we define in setUp.

The problem

Now, I really like this approach, but I felt like I am not actually testing my form request. In the setUp method, we instantiate the form request, but we only get the validation rules from it and pass it down to Laravel's validator. This would probably work most of the time with simple data, but in my case, I was doing some data manipulation in prepareForValidation form request method. I was also doing some other checks in withValidator method. With this testing approach, those methods are not called, and I don't really know whether my form request works as expected.

The solution?

I thought instead of setting up the validator, we should setup the actual form request, so I quickly changed the validate method in our test case to something like this:

protected function validate($mockedRequestData)
{
    return (new CreateRedirectRequest())->...;
}
Enter fullscreen mode Exit fullscreen mode

Hang on a minute... What method do I call? How do I pass the data to it? I then inspected the Illuminate\Foundation\Http\FormRequest and it looks like it extends Illuminate\Http\Request, which in turn extends Symfony Symfony\Component\HttpFoundation\Request. Okay, it looks like the form requests are actually extending the request, and, we do not want to be setting it up ourselves.

So I thought I would resolve it out of the container. I changed the validate method to something like this:

protected function validate($mockedRequestData)
{
    app(CreateRedirectRequest::class);
}
Enter fullscreen mode Exit fullscreen mode

I run the test and I was immediately greeted with an error. In my withValidator method I was doing something like this:

public function withValidator($validator)
{
    $validator->after(function ($validator) {
        if (! $this->parse($this->source)) {
            $validator->errors()->add('source', 'Invalid source');
        }
    });
}
Enter fullscreen mode Exit fullscreen mode

The error was Argument 1 passed to parse() must be of the type string, null given. How does this work, I thought. We are not calling the validation yet, but withValidator method is called, and I didn't even pass data to it yet.

I inspected the FormRequest class again, and it looks like it is using ValidatesWhenResolved interface and ValidatesWhenResolvedTrait trait. Which, well, does what it says. When the object using this trait is resolved out of the container, it will call prepareForValidation, it will check authorization, and finally will validate the request properly.

In the FormRequestServiceProvider we can see this code:

 /**
 * Bootstrap the application services.
 *
 * @return void
 */
public function boot()
{
    $this->app->afterResolving(ValidatesWhenResolved::class, function ($resolved) {
        $resolved->validateResolved();
    });

    $this->app->resolving(FormRequest::class, function ($request, $app) {
        $request = FormRequest::createFrom($app['request'], $request);

        $request->setContainer($app)->setRedirector($app->make(Redirector::class));
    });
}
Enter fullscreen mode Exit fullscreen mode

Okay, so this happens afterResolving, I know laravel also has a resolving callback, so could we pass our data before it is resolved? I changed validate method once again to something like this:

protected function validate($mockedRequestData)
{
    $this->app->resolving(CreateRedirectRequest::class, function ($resolved) use ($mockedRequestData){
        $resolved->merge($mockedRequestData);
    });

    try {
        app(CreateRedirectRequest::class);

        return true;
    } catch (ValidationException $e) {
        return false;
    }
}

Enter fullscreen mode Exit fullscreen mode

I run the tests and they all pass. Now when I want to add a new test to this test case, I only need to add a new array item with the data and whether it should pass.

So why does it work?

It works because when Laravel's container is resolving the form request
object, it will merge the $mockedRequestData array, which holds the data from our test array, to the request object.

If it resolves successfully, we know all the data is valid, otherwise it will throw Illuminate\Validation\ValidationException, which we try to catch. We return either true or false, because our validation_results_as_expected method then compares it to whether the test case should pass or not. We also do not need 'setUp' anymore.

Final words

If you are writing multiple test methods testing each request attribute individually, I would say give this a try. Maybe you will like it. I certainly prefer this approach.

P.S

Although I am not new to web development, this is my first blog post. If you would like to read more blog posts like this in the future, feel free to follow me or leave a comment, I welcome any feedback or questions.

Top comments (1)

Collapse
 
4unkur profile image
Daiyrbek Artelov

Ah, what a gem. Thanks for sharing

Join us at DEV Find what you were looking for? Sign up so you can:
 
🌚 Enable dark mode
🔠 Change your default font
📚 Adjust your experience level to see more relevant content