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();
}
}
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 @dataProvider
annotation 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())->...;
}
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);
}
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');
}
});
}
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));
});
}
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;
}
}
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)
Ah, what a gem. Thanks for sharing