Test form requests in Laravel with Request Factories by Worksome

Published on by

Test form requests in Laravel with Request Factories by Worksome image

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.

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

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?

it('allows a user to fill in address details', function () {
SignupRequest::fake();
 
$this->post('/signup', [
'address_line_1' => '1 Test Street',
'address_line_2' => 'Test Area',
'city' => 'Testerfield',
'postcode' => 'T35T 1NG',
]);
 
expect(User::latest()->first())
->address_line_1->toBe('1 Test Street')
->address_line_2->toBe('Test Area')
->city->toBe('Testerfield')
->postcode->toBe('T35T 1NG');
});

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.

# Using a custom name
php artisan make:request-factory SignupRequestFactory
 
# Using a form request FQCN
php 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.

class SignupRequestFactory extends RequestFactory
{
public function definition(): array
{
return [
'email' => $this->faker->safeEmail,
'first_name' => $this->faker->firstName,
'last_name' => $this->faker->lastName,
'telephone' => '01234567890',
'accepts_terms' => true,
];
}
}

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.

it('allows a user to fill in address details', function () {
SignupRequestFactory::new()->fake();
 
$this->post('/signup', [
'address_line_1' => '1 Test Street',
'address_line_2' => 'Test Area',
'city' => 'Testerfield',
'postcode' => 'T35T 1NG',
]);
 
expect(User::latest()->first())
->address_line_1->toBe('1 Test Street')
->address_line_2->toBe('Test Area')
->city->toBe('Testerfield')
->postcode->toBe('T35T 1NG');
});

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:

it('allows a user to fill in address details', function () {
SignupRequest::fake();
 
$this->post('/signup', [
'address_line_1' => '1 Test Street',
'address_line_2' => 'Test Area',
'city' => 'Testerfield',
'postcode' => 'T35T 1NG',
]);
 
expect(User::latest()->first())
->address_line_1->toBe('1 Test Street')
->address_line_2->toBe('Test Area')
->city->toBe('Testerfield')
->postcode->toBe('T35T 1NG');
});

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?

class SignupRequestFactory extends RequestFactory
{
public function definition(): array
{
return [
'email' => $this->faker->safeEmail,
'first_name' => $this->faker->firstName,
'last_name' => $this->faker->lastName,
'telephone' => '01234567890',
'accepts_terms' => true,
];
}
 
public function withAddress(): self
{
return $this->state([
'address_line_1' => '1 Test Street',
'address_line_2' => 'Test Area',
'city' => 'Testerfield',
'postcode' => 'T35T 1NG',
]);
}
}

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:

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

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

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

Using nested factories for shared fields

class MailingRequestFactory extends RequestFactory
{
public function definition(): array
{
return [
'email' => $this->faker->safeEmail,
'address' => AddressRequestFactory::new(),
];
}
}

Using closures for lazily defined attributes

class MailingRequestFactory extends RequestFactory
{
public function definition(): array
{
return [
'first_name' => $this->faker->firstName,
'last_name' => $this->faker->lastName,
'email' => fn ($attrs) => "{$attrs['first_name']}.{$attr['last_name']}@foo.com",
];
}
}

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.

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

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.

it('correctly formats the postcode', function () {
$this->post('/signup', ['postcode' => 'T3 5T1N G']);
 
expect(User::latest()->first()->postcode)->toBe('T35T 1NG')
})
->fakeRequest(SignupRequestFactory::class)
->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!

Luke Downing photo

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

Cube

Laravel Newsletter

Join 40k+ other developers and never miss out on new tips, tutorials, and more.

Laravel Forge logo

Laravel Forge

Easily create and manage your servers and deploy your Laravel applications in seconds.

Laravel Forge
Tinkerwell logo

Tinkerwell

The must-have code runner for Laravel developers. Tinker with AI, autocompletion and instant feedback on local and production environments.

Tinkerwell
No Compromises logo

No Compromises

Joel and Aaron, the two seasoned devs from the No Compromises podcast, are now available to hire for your Laravel project. ⬧ Flat rate of $7500/mo. ⬧ No lengthy sales process. ⬧ No contracts. ⬧ 100% money back guarantee.

No Compromises
Kirschbaum logo

Kirschbaum

Providing innovation and stability to ensure your web application succeeds.

Kirschbaum
Shift logo

Shift

Running an old Laravel version? Instant, automated Laravel upgrades and code modernization to keep your applications fresh.

Shift
Bacancy logo

Bacancy

Supercharge your project with a seasoned Laravel developer with 4-6 years of experience for just $2500/month. Get 160 hours of dedicated expertise & a risk-free 15-day trial. Schedule a call now!

Bacancy
Lucky Media logo

Lucky Media

Get Lucky Now - the ideal choice for Laravel Development, with over a decade of experience!

Lucky Media
Lunar: Laravel E-Commerce logo

Lunar: Laravel E-Commerce

E-Commerce for Laravel. An open-source package that brings the power of modern headless e-commerce functionality to Laravel.

Lunar: Laravel E-Commerce
LaraJobs logo

LaraJobs

The official Laravel job board

LaraJobs
SaaSykit: Laravel SaaS Starter Kit logo

SaaSykit: Laravel SaaS Starter Kit

SaaSykit is a Multi-tenant Laravel SaaS Starter Kit that comes with all features required to run a modern SaaS. Payments, Beautiful Checkout, Admin Panel, User dashboard, Auth, Ready Components, Stats, Blog, Docs and more.

SaaSykit: Laravel SaaS Starter Kit
Supercharge Your SaaS Development with FilamentFlow: The Ultimate Laravel Filament Boilerplate logo

Supercharge Your SaaS Development with FilamentFlow: The Ultimate Laravel Filament Boilerplate

Build your SaaS application in hours. Out-of-the-box multi-tenancy and seamless Stripe integration. Supports subscriptions and one-time purchases, allowing you to focus on building and creating without repetitive setup tasks.

Supercharge Your SaaS Development with FilamentFlow: The Ultimate Laravel Filament Boilerplate
Rector logo

Rector

Your partner for seamless Laravel upgrades, cutting costs, and accelerating innovation for successful companies

Rector
MongoDB logo

MongoDB

Enhance your PHP applications with the powerful integration of MongoDB and Laravel, empowering developers to build applications with ease and efficiency. Support transactional, search, analytics and mobile use cases while using the familiar Eloquent APIs. Discover how MongoDB's flexible, modern database can transform your Laravel applications.

MongoDB

The latest

View all →
Asymmetric Property Visibility in PHP 8.4 image

Asymmetric Property Visibility in PHP 8.4

Read article
Access Laravel Pulse Data as a JSON API image

Access Laravel Pulse Data as a JSON API

Read article
Laravel Forge adds Statamic Integration image

Laravel Forge adds Statamic Integration

Read article
Transform Data into Type-safe DTOs with this PHP Package image

Transform Data into Type-safe DTOs with this PHP Package

Read article
PHPxWorld - The resurgence of PHP meet-ups with Chris Morrell image

PHPxWorld - The resurgence of PHP meet-ups with Chris Morrell

Read article
Herd Executable Support and Pest 3 Mutation Testing in PhpStorm 2024.3 image

Herd Executable Support and Pest 3 Mutation Testing in PhpStorm 2024.3

Read article