Testing validation in Laravel by asserting a Form Request
Main Thread • 4 min read
Laravel's HTTP Tests make sending requests and performing assertions on the response super easy. Yet one pain point is testing validation.
Consider testing a simple controller action which accepts a name
and email
, validates this data, and uses it to subscribe to a newsletter.
1public function store(Request $request) 2{ 3 $request->validate([ 4 'name' => 'required', 5 'email' => 'required|email' 6 ]); 7 8 \Newsletter::subscribe( 9 $request->input('email'),10 ['name' => $request->input('name')]11 );12 13 return response()->noContent();14}
To test the happy path of this action I create some valid data with Faker, send the POST
request, and assert the 204
response. I also spy on the Newsletter
facade to assert it was called correctly.
1/** @test */ 2public function store_subscribes_to_newsletter() 3{ 4 $newsletter = \Newsletter::spy(); 5 6 $name = $this->faker->name; 7 $email = $this->faker->safeEmail; 8 9 $response = $this->post('/newsletter-subscription', [10 'name' => $name,11 'email' => $email,12 ]);13 14 $response->assertStatus(204);15 16 $newsletter->shouldHaveReceived('subscribe', [$email, ['name' => $name]]);17}
Now this only tests the happy path. Unfortunately I can't write just one test for the failure path. For complete coverage, I have to write a test for every validation rule.
This means to fully test the validation for this action would require 4 tests:
- Testing validation passes when all rules are met.
- Testing
name
is required. - Testing
email
is required. - Testing
email
is a valid email format.
That's a lot of tests to write for just 2 fields. And the number of tests required increases proportionally as the number of fields and rules increases. So 5 validation rules requires 5 test case, 10 requires 10, and so on.
Now even though these tests are easy to write in Laravel, they take time. Time is one of testings biggest adversaries. It's one reason why most developers don't write test.
To balance this, I want the most confidence in the least amount of tests. Although writing N + 1 tests gives me full coverage, it's a lot of tests. Moreover, the tests don't provide much value. Sure they confirm validation fails, but relative to other parts of the application that's not very important. All the more reason I don't want to spend a lot of time writing these tests.
Form Request Validation
Our current implementation uses request validation within the controller. Laravel offers another form of validation using Form Requests. Now Form Requests have many benefits. The one I care about is separation.
Separating the validation from the controller affords me alternative approaches for testing validation. Particularly around testing the failure paths. Ideally this approach means instead of writing tests for each of the rules, I only need to write two tests - ever!
The first test case asserts the action uses the appropriate form request. In doing so, it ensures everything is wired together as required by Laravel to perform the validation.
1/** @test */2public function store_validates_using_a_form_request()3{4 $this->assertActionUsesFormRequest(5 NewsletterSubscriptionController::class,6 'store',7 StoreNewsletterSubscription::class8 );9}
The second test is for the StoreNewsletterSubscription
class to ensure all validation rules are set appropriately.
1class StoreNewsletterSubscriptionTest extends TestCase 2{ 3 /** @var StoreNewsletterSubscription */ 4 private $subject; 5 6 protected function setUp(): void 7 { 8 parent::setUp(); 9 10 $this->subject = new StoreNewsletterSubscription();11 }12 13 public function testRules()14 {15 $this->assertEquals([16 'name' => 'required',17 'email' => 'required|email'18 ],19 $this->subject->rules()20 );21 }22 23 public function testAuthorize()24 {25 $this->assertTrue($this->subject->authorize());26 }27}
Notice this is a unit test. It simply calls the predefined methods on the Form Request and verifies they return the expected values. When a new rule is added, I don't have to setup another test which sends a request with bad data and assert the response. I just update the array in testRules
. It's dead simple.
Complex Validation Rules
This test is admittedly simple given the simplicity of the controller action. For more complex validation rules, additional tests might be required.
A common example is a ruleset which changes based on logic. This would require testing each logical path. Even still, this is fewer tests than before and easier to write.
Another example might be custom validation rules. This would require more specific assertions than using assertEquals
on the returned array. A solution would be to assert the rules for individual fields. This allows the registration of the custom validation rule to be verified which could then be captured and tested separately.
It's important to point out that these testing approaches are not mutually exclusive. Again, this is about gaining the most confidence in the least amount of tests. As such, if writing a unit test for a complex validation rule took more time, then write an HTTP test for it instead.
Tradeoffs
Some developers will be quick to point out the loss of flexibility or isolation of these tests. They would be right. This testing approach comes with tradeoffs. First, the requirement of using a Form Request. Second, when the implementation is changed, the test must be changed.
This latter one often concerns developers. However, it's important to remember in the context of validation changing rules or fields will always require changing the tests. Both testing approaches have rigidity. So this concern becomes which is easier to change.
The real tradeoff is using a Form Request. The choice between validation within controllers versus using a Form Request seems to be polarized within the Laravel community. I have offered the code and testing benefits of using a Form Request, but this may not align with your philosophy.
In the end, the takeaway is not whether to use Form Requests or not. It's about writing tests which immediately provide confidence the code behaves as expected. If I can do that with a single test, I accept these tradeoffs. When it's no longer acceptable, I take the opportunity to refactor.
Want to try this testing approach? I packaged up a trait containing the assertActionUsesFormRequest
so you can easily add it to your Laravel tests.