My favourite way of testing validation rules

Published 28/01/2023 | 6957 views

Testing validation rules can be real drag. Let's look at a few small tips to improve that experience and make it a breeze to write and maintain validation tests.

If you're more of a visual learner, be sure to watch my video on this topic from Laracon Online Summer '22

Validation can be a real pain to test. Forms usually have many different fields, and each field may have many different rules to validate it. Not only that, but certain fields might interact with each other, for example requiring a phone number only if the email address is empty. It's no wonder then that devs often loathe or even skip this part of testing a form submission.

But what if I told you that there is a world in which testing validation is not only easy, but enjoyable? If you've followed me for some time, you'll likely have heard of Pest Datasets and Request Factories. These two items can be combined to make testing validation a breeze! Let's take a look 👀

The typical way of testing validation

So, what might a traditional way of testing rules look like? Perhaps you've seen or even written something like this:

test('the email address is required', function () {
$this->store('/users', ['email' => null])
->assertInvalid(['email' => 'required']);
});
 
test('the email address must be an email', function () {
$this->store('/users', ['email' => 'foo'])
->assertInvalid(['email' => 'valid email address']);
});
 
test('the email address cannot already exist', function () {
$existingUser = User::factory()->create(['email' => 'foo@bar.com']);
 
$this->store('/users', ['email' => 'foo@bar.com'])
->assertInvalid(['email' => 'exist']);
});
 
// And on and on and on...

What a drag! Not only is this incredibly tiresome to write, but it's also annoying to have a huge file full of these tests to maintain. Additionally, we here risk getting some false positives, because other fields that are invalid in the request (because we aren't passing those other fields) will also be failing their relevant validation rules.

So what is to be done? Well, we can make this so much easier on the fingers and eyes by making use of Datasets.

Refactoring to datasets

A dataset is an array that instructs Pest to repeat a test for each piece of data in that array. This allows us to repeat similar tests (say, for example, testing different sets of data in a form request), without having to write out each test separately. If we refactor the 3 tests above to a dataset, it might look something like this:

test('requires valid data', function ($data, $errors) {
$existingUser = User::factory()->create(['email' => 'foo@bar.com']);
 
$this->store('/users', $data)
->assertInvalid($errors);
})->with([
'email missing' => [['email' => null], ['email' => 'required']],
'email not an email' => [['email' => 'foo'], ['email' => 'valid email address']],
'email already exists' => [['email' => 'foo@bar.com'], ['email' => 'exist']],
]);

Well isn't that purty? So much cleaner than separate tests for every rule, and it's now incredibly easy to add new rules and properties: just pass a new item to our dataset!

Whilst this vastly improves the experience of testing validation, there is still something missing. We still risk false positives because we're only passing one field at a time, so our response will be full of validation errors rather than just returning the one validation error we're testing for. Brace yourselves, it's time to introduce a request factory.

Introducing a Request Factory

I'm not going to go into the basics of Request Factories, I've already done that in another post. However, here is a sample Request Factory we might build for this endpoint:

class StoreUserRequestFactory extends RequestFactory
{
public function definition()
{
return [
'email' => $this->faker->unique->safeEmail,
'phone_number' => null,
'first_name' => $this->faker->firstName,
'last_name' => $this->faker->lastName,
];
}
 
public function withPhoneNumberInsteadOfEmail()
{
return $this->state([
'email' => null,
'phone_number' => $this->faker->phoneNumber,
]);
}
}

One tip with Request Factories is to provide the most basic form of a "valid" request in your definition. This will make your tests more predictable.

Let's alter our validation test to use our Request Factory:

test('requires valid data', function ($data, $errors) {
$existingUser = User::factory()->create(['email' => 'foo@bar.com']);
 
- $this->store('/users', $data)
+ $this->store('/users', StoreUserRequestFactory::new($data)->create())
->assertInvalid($errors);
})->with([
'email missing' => [['email' => null], ['email' => 'required']],
'email not an email' => [['email' => 'foo'], ['email' => 'valid email address']],
'email already exists' => [['email' => 'foo@bar.com'], ['email' => 'exist']],
]);

A small change, but behind the scenes, a big difference. All of a sudden, the only field that is invalid is that one that we're testing. This helps us avoid any false positives, and gives us more confidence that everything is working as intended.

Now here is a really neat tip: you can pass a Request Factory instance to the new method of a Request Factory. That lets us mix arrays and Request Factories depending on the use case: for example, to completely remove an item from the request payload rather than just setting it to null:

test('requires valid data', function ($data, $errors) {
$existingUser = User::factory()->create(['email' => 'foo@bar.com']);
 
$this->store('/users', StoreUserRequestFactory::new($data)->create())
->assertInvalid($errors);
})->with([
- 'email missing' => [['email' => null], ['email' => 'required']],
+ 'email missing' => [StoreUserRequestFactory::new()->without('email'), ['email' => 'required']
'email not an email' => [['email' => 'foo'], ['email' => 'valid email address']],
'email already exists' => [['email' => 'foo@bar.com'], ['email' => 'exist']],
]);

Wrapping up

And there we have it. By mixing datasets and request factories, not only is it simple to create and maintain validation tests in your Laravel application, but you might even find it enjoyable!

Be sure to check out the documentation on Datasets to see everything they can do. With the upcoming release of Pest v2, they're getting even more powerful, and they're definitely a tool to have in your testing belt! Also be sure to read through the README on the Request Factories package so that you can see everything they're capable of.

Happy testing!

Luke

Like what you see?

If you enjoy reading my content, please consider sponsoring me. I don't spend it on cups of coffee; it all goes towards freeing up more of my time to work on open source, tutorials and more posts like this one.