DEV Community

Cover image for Testing Form Requests with PHPUnit
Geni Jaho
Geni Jaho

Posted on • Originally published at genijaho.dev

6

Testing Form Requests with PHPUnit

Countless are the times I've had to test for validation errors, and countless are the times I've repeated the same code for this purpose. Duplicating testing code is not always a bad thing though, but for validation errors, I don't know, it doesn't feel right. If it is acceptable to test one FormRequest with duplicated code, it's a sin to do that for a whole project. And I've been guilty. 🤣

What I'm talking about looks like this:

<?php
namespace Tests\Feature\Api;
use App\Models\User\User;
use Tests\TestCase;
class AddCustomTagsTest extends TestCase
{
public function test_it_requires_tags_to_be_distinct()
{
$user = User::factory()->create();
$this->actingAs($user, 'api')->post('/api/photos/submit', $this->photo());
$photo = $user->fresh()->photos->last();
$response = $this->postJson('/api/add-tags', [
'photo_id' => $photo->id,
'custom_tags' => ['tag1', 'Tag1']
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['custom_tags.0', 'custom_tags.1']);
$this->assertCount(0, $photo->fresh()->customTags);
}
public function test_it_requires_tags_to_have_a_min_length_of_3()
{
$user = User::factory()->create();
$this->actingAs($user, 'api')->post('/api/photos/submit', $this->photo());
$photo = $user->fresh()->photos->last();
$response = $this->postJson('/api/add-tags', [
'photo_id' => $photo->id,
'custom_tags' => ['ta']
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['custom_tags.0']);
$this->assertCount(0, $photo->fresh()->customTags);
}
public function test_it_requires_tags_to_have_a_max_length_of_100()
{
$user = User::factory()->create();
$this->actingAs($user, 'api')->post('/api/photos/submit', $this->photo());
$photo = $user->fresh()->photos->last();
$response = $this->postJson('/api/add-tags', [
'photo_id' => $photo->id,
'custom_tags' => [str_repeat('a', 101)]
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['custom_tags.0']);
$this->assertCount(0, $photo->fresh()->customTags);
}
public function test_it_requires_tags_to_be_at_most_3()
{
$user = User::factory()->create();
$this->actingAs($user, 'api')->post('/api/photos/submit', $this->photo());
$photo = $user->fresh()->photos->last();
$response = $this->postJson('/api/add-tags', [
'photo_id' => $photo->id,
'custom_tags' => ['tag1', 'tag2', 'tag3', 'tag4']
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['custom_tags']);
$this->assertCount(0, $photo->fresh()->customTags);
}
}

Every method has the same setup phase, the same action, and the same assertions. The only difference is the data being provided with the request, and the errors that are expected to be returned. I don't usually like to check for specific error messages, as it makes the tests more fragile. The error key should suffice most of the time.

Refactor into a common method

The obvious solution here is to refactor into a method that encapsulates the common logic. Let's do that and see what we get:

<?php
...
class AddCustomTagsTest extends TestCase
{
private function validateCustomTags($tags, $errors)
{
$user = User::factory()->create();
$this->actingAs($user, 'api')->post('/api/photos/submit', $this->photo());
$photo = $user->fresh()->photos->last();
$response = $this->postJson('/api/add-tags', [
'photo_id' => $photo->id,
'custom_tags' => $tags
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors($errors);
$this->assertCount(0, $photo->fresh()->customTags);
}
public function test_it_requires_tags_to_be_distinct()
{
$this->validateCustomTags(['tag1', 'Tag1'], ['custom_tags.0', 'custom_tags.1']);
}
public function test_it_requires_tags_to_have_a_min_length_of_3()
{
$this->validateCustomTags(['ta'], ['custom_tags.0']);
}
public function test_it_requires_tags_to_have_a_max_length_of_100()
{
$this->validateCustomTags([str_repeat('a', 101)], ['custom_tags.0']);
}
public function test_it_requires_tags_to_be_at_most_3()
{
$this->validateCustomTags(['tag1', 'tag2', 'tag3', 'tag4'], ['custom_tags']);
}
}

This looks a lot better and cleaner. However, we can go a step further, as PHPUnit offers some helper methods called Data Providers. Essentially, a data provider is a method that returns data that other tests depend on. This allows you to execute a single test multiple times, but with different input, which is perfect for our example. The data provider must be a public method, and return a list of input values. The test that uses this provider will run for each element in the list.

Refactor into a Data Provider

To utilize data providers, we simply annotate the method on the tests we want it to run with. Our finished example looks like this:

<?php
...
class AddCustomTagsTest extends TestCase
{
public function validationDataProvider(): array
{
return [
// uniqueness
['tags' => ['tag1', 'Tag1'], 'errors' => ['custom_tags.0', 'custom_tags.1']],
// min length 3
['tags' => ['ta'], 'errors' => ['custom_tags.0']],
// max length 100
['tags' => [str_repeat('a', 101)], 'errors' => ['custom_tags.0']],
// max 3 tags
['tags' => ['tag1', 'tag2', 'tag3', 'tag4'], 'errors' => ['custom_tags']],
];
}
/**
* @dataProvider validationDataProvider
*/
public function test_it_validates_the_custom_tags($tags, $errors)
{
$user = User::factory()->create();
$this->actingAs($user, 'api')->post('/api/photos/submit', $this->photo());
$photo = $user->fresh()->photos->last();
$response = $this->postJson('/api/add-tags', [
'photo_id' => $photo->id,
'custom_tags' => $tags
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors($errors);
$this->assertCount(0, $photo->fresh()->customTags);
}
}

Pretty neat, right? When you run the test it shows that it's run 4 times, each with its own data, like this:

Test run results

Also, when one of the tests fails, you'll get a message indicating which dataset is making the test fail. So helpful. You can even provide keys, and you should, to the data provider array; those keys will be shown on the test results instead of with data set #0.

Form validation testing is not the only use-case for data providers though. They're very helpful in any case where you need to test an object's implementation with different input values, or testing switch statements, etc.

The code used for illustration is taken from the OpenLitterMap project. They're doing a great job creating the world's most advanced open database on litter, brands & plastic pollution. The project is open-sourced and would love your contributions, both as users and developers.


Originally published at https://genijaho.dev.

Billboard image

The Next Generation Developer Platform

Coherence is the first Platform-as-a-Service you can control. Unlike "black-box" platforms that are opinionated about the infra you can deploy, Coherence is powered by CNC, the open-source IaC framework, which offers limitless customization.

Learn more

Top comments (0)

Cloudinary image

Optimize, customize, deliver, manage and analyze your images.

Remove background in all your web images at the same time, use outpainting to expand images with matching content, remove objects via open-set object detection and fill, recolor, crop, resize... Discover these and hundreds more ways to manage your web images and videos on a scale.

Learn more

👋 Kindness is contagious

Please leave a ❤️ or a friendly comment on this post if you found it helpful!

Okay