Test form requests in Laravel with Request Factories by Worksome

Packages

May 24th, 2022

factory-laravel-news.jpg

A month or two ago, I was busy writing tests for a Laravel app. More specifically, I was testing a Laravel post request...

  • it allows a user to sign up
  • it requires a valid email
  • it does not allow a user to sign up twice
  • it allows a user to fill in address details

You get the picture. The more tests for this sign up flow I created, the more frustrated I became. Why? Because I had to keep repeating myself over and over and over again. See, any request worth its salt is going to have validation. Because of that, even if our test only deals with a small subset of fields in the request, we have to include everything!

Let's take the case of 'it allows a user to fill in address details'. I'm actually interested in perhaps 4 fields: address_line_1, address_line_2, city, and postcode. Buuuuuut, because of validation I have to pass email, first_name, last_name, telephone, accepts_terms and every other required fields.

1it('allows a user to fill in address details', function () {
2 $this->post('/signup', [
3 'first_name' => 'Luke', // Don't care
4 'last_name' => 'Downing', // Not what I'm testing
5 'email' => 'foo@bar.com', // Sigh
6 'telephone' => '01234567890', // Okay, I'm getting annoyed now
7 'accepts_terms' => true, // Are we there yet?
8 'address_line_1' => '1 Test Street', // Finally!
9 'address_line_2' => 'Test Area',
10 'city' => 'Testerfield',
11 'postcode' => 'T35T 1NG',
12 ]);
13 
14 expect(User::latest()->first())
15 ->address_line_1->toBe('1 Test Street')
16 ->address_line_2->toBe('Test Area')
17 ->city->toBe('Testerfield')
18 ->postcode->toBe('T35T 1NG');
19});

This problem only gets more frustrating as we add more tests. And that's a real issue, because it can actually lead to not writing tests at all! Further, imagine adding a new required field to this form 6 months down the line. We're going to have to update all of these tests and add more data that doesn't add value to the test just to make them pass.

This got me thinking. See, we had a similar problem with eloquent models a while back. But we solved that with model factories: classes that would do all of the laborious work for you so that you could just write User::factory()->create() in your test. Why not apply that same approach to requests?

1it('allows a user to fill in address details', function () {
2 SignupRequest::fake();
3 
4 $this->post('/signup', [
5 'address_line_1' => '1 Test Street',
6 'address_line_2' => 'Test Area',
7 'city' => 'Testerfield',
8 'postcode' => 'T35T 1NG',
9 ]);
10 
11 expect(User::latest()->first())
12 ->address_line_1->toBe('1 Test Street')
13 ->address_line_2->toBe('Test Area')
14 ->city->toBe('Testerfield')
15 ->postcode->toBe('T35T 1NG');
16});

Oh, that's so much nicer. It's shorter, easier to write, simpler to maintain and conveys the purpose of the test without uncecessary detail. Perfection!

So, without further ado, I'm pleased to present Request Factories by Worksome!

Setting up our request factory

The package ships with an artisan command to easily generate new factories for your project. You can pass the desired name of your factory, or the Fully Qualified Class Name (FQCN) of a form request from your app.

It's important to note that form requests are completely optional. Request factories work just fine with standard requests too.

1# Using a custom name
2php artisan make:request-factory SignupRequestFactory
3 
4# Using a form request FQCN
5php artisan make:request-factory "App\Http\Requests\SignupRequest"

By default, your factory will be placed under tests/RequestFactories. When you open your new request factory, you'll notice a definition method that returns an array. Much like model factories, this is where we define any attributes we want to be present when faking a request.

My recommendation is that you only include the minimum number of attributes to complete a valid request in the definition method, and use methods to decorate your factory as desired.

1class SignupRequestFactory extends RequestFactory
2{
3 public function definition(): array
4 {
5 return [
6 'email' => $this->faker->safeEmail,
7 'first_name' => $this->faker->firstName,
8 'last_name' => $this->faker->lastName,
9 'telephone' => '01234567890',
10 'accepts_terms' => true,
11 ];
12 }
13}

Note that request factories have a $faker property we can access to create randomised data rather than hardcoding values. With our required fields defined, we can now fake the data in our request.

1it('allows a user to fill in address details', function () {
2 SignupRequestFactory::new()->fake();
3 
4 $this->post('/signup', [
5 'address_line_1' => '1 Test Street',
6 'address_line_2' => 'Test Area',
7 'city' => 'Testerfield',
8 'postcode' => 'T35T 1NG',
9 ]);
10 
11 expect(User::latest()->first())
12 ->address_line_1->toBe('1 Test Street')
13 ->address_line_2->toBe('Test Area')
14 ->city->toBe('Testerfield')
15 ->postcode->toBe('T35T 1NG');
16});

One cool (although perhaps divisive) feature of eloquent model factories is the ability to create a new factory instance directly from a model: User::factory(). I wanted to bring a little bit of that magic to request factories too, so there is a Worksome\RequestFactories\Concerns\HasFactory trait available that you may add to your form requests if you'd like. Once added, you fake directly from the form request:

1it('allows a user to fill in address details', function () {
2 SignupRequest::fake();
3 
4 $this->post('/signup', [
5 'address_line_1' => '1 Test Street',
6 'address_line_2' => 'Test Area',
7 'city' => 'Testerfield',
8 'postcode' => 'T35T 1NG',
9 ]);
10 
11 expect(User::latest()->first())
12 ->address_line_1->toBe('1 Test Street')
13 ->address_line_2->toBe('Test Area')
14 ->city->toBe('Testerfield')
15 ->postcode->toBe('T35T 1NG');
16});

Of course, this is completely optional, and only really makes sense if you make use of form requests in your application. But it's a nice-to-have if that's your style.

Adding methods to our factory

One of the great things about factories is the ability to add methods that transform factory state. For example, let's say we have a test for postcode formatting. We need to provided other address fields to create a valid request, so why not add a withAddress() method to our factory?

1class SignupRequestFactory extends RequestFactory
2{
3 public function definition(): array
4 {
5 return [
6 'email' => $this->faker->safeEmail,
7 'first_name' => $this->faker->firstName,
8 'last_name' => $this->faker->lastName,
9 'telephone' => '01234567890',
10 'accepts_terms' => true,
11 ];
12 }
13 
14 public function withAddress(): self
15 {
16 return $this->state([
17 'address_line_1' => '1 Test Street',
18 'address_line_2' => 'Test Area',
19 'city' => 'Testerfield',
20 'postcode' => 'T35T 1NG',
21 ]);
22 }
23}

You'll note the state method, similar to Laravel's model factories. We've tried to keep the APIs very similar, so it will feel immediately familiar. Now, let's go write our test:

1it('correctly formats the postcode', function () {
2 SignupRequestFactory::new()->withAddress()->fake();
3 
4 $this->post('/signup', ['postcode' => 'T3 5T1N G']);
5 
6 expect(User::latest()->first()->postcode)->toBe('T35T 1NG')
7});

How nice is that? Our test maintains its focus; we only have to provide the field we're actually testing. Also, note that any data you pass to post (or any of the Laravel request testing methods for that matter), will override data defined in the factory.

Other goodies

We're really just scratching the surface of what's possible with request factories in this post. Here's a quick showcase of some other goodies available.

Omitting fields from a request

1it('requires an email', function () {
2 SignupRequestFactory::new()->without(['email'])->fake();
3 
4 $this->post('/signup')->assertInvalid(['email']);
5});

Using nested factories for shared fields

1class MailingRequestFactory extends RequestFactory
2{
3 public function definition(): array
4 {
5 return [
6 'email' => $this->faker->safeEmail,
7 'address' => AddressRequestFactory::new(),
8 ];
9 }
10}

Using closures for lazily defined attributes

1class MailingRequestFactory extends RequestFactory
2{
3 public function definition(): array
4 {
5 return [
6 'first_name' => $this->faker->firstName,
7 'last_name' => $this->faker->lastName,
8 'email' => fn ($attrs) => "{$attrs['first_name']}.{$attr['last_name']}@foo.com",
9 ];
10 }
11}

Using create on a factory

If you don't want to fake a request globally or prefer a syntax closer to model factories, you can call create on a request factory to return an array of input that can then be passed to post or other request methods.

1it('requires an email', function () {
2 $data = SignupRequestFactory::new()->create();
3 
4 $this->post('/signup', $data)->assertInvalid(['email']);
5});

Using the fakeRequest helper with Pest PHP

If you're using Pest PHP for testing, we provide a groovy fakeRequest helper that can be chained onto the end of your test if you prefer.

1it('correctly formats the postcode', function () {
2 $this->post('/signup', ['postcode' => 'T3 5T1N G']);
3 
4 expect(User::latest()->first()->postcode)->toBe('T35T 1NG')
5})
6 ->fakeRequest(SignupRequestFactory::class)
7 ->withAddress();

Wrapping up

For more details on all the things you can do with request factories, be sure to check out the detailed documentation!

I think request factories have huge potential in making testing requests simpler to write, easier to maintain and providing greater clarity on the purpose of each test.

I look forward to seeing how you use them in your projects! Make sure to reach out on Twitter to let me know what you think.

Take care!

Filed in:

Luke Downing

Full stack developer at Worksome. CEO of Downing Tech. Big fan of TDD, and maintainer of the Pest PHP testing framework.

Laravel News Partners