PHPUnit @dataProvider: Tutorial with example
PHPUnit dataProvider is a feature of PHPUnit you can use when the same test should be performed multiple times but with variations of the input data.
The most common use case for it is to test validation rules, but it is also useful in a lot of other situations too.
Let’s look at an example.
Let’s imagine that we have a form in our app to create a new customer, the fields the endpoint receives are the following (just a few to keep things simple).
The validation rules applied to these field could be the following (again, just a few to keep things simple).
$request->validate([
'first_name' => 'required|string',
'last_name' => 'required|string',
'email' => 'required|email',
'display_currency' => 'required|size:3',
'mobile_phone' => 'required|size:11',
]);
Since we are using TDD to develop our app we must test the validation rules that are applied to the endpoint used to create a new customer.
Because our endpoint received five fields, ideally we should hit the endpoint many times each with different data sets to test all the validation rules for each field individually.
An initial implementation could look something like this.
<?php
namespace Tests\Feature;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use App\Models\User;
class CustomersTest extends TestCase
{
use RefreshDatabase;
public function test_first_name_field_is_required()
{
// Arrange
/** @var \App\Models\User $user */
$user = User::factory()->create();
// Act
$this->actingAs($user);
$this->get(route('customers.create'));
$response = $this->post('/customers', [
'first_name' => null,
'last_name' => 'Souza',
'email' => 'me@isaacsouza.dev',
'display_currency' => 'BRL',
'mobile_phone' => '98983448902',
]);
// Assert
$response->assertRedirect(route('customers.create'));
$this->assertDatabaseCount('customers', 0);
}
public function test_last_name_field_is_required()
{
// Arrange
/** @var \App\Models\User $user */
$user = User::factory()->create();
// Act
$this->actingAs($user);
$this->get(route('customers.create'));
$response = $this->post('/customers', [
'first_name' => 'Isaac',
'last_name' => null,
'email' => 'me@isaacsouza.dev',
'display_currency' => 'BRL',
'mobile_phone' => '98983448902',
]);
// Assert
$response->assertRedirect(route('customers.create'));
$this->assertDatabaseCount('customers', 0);
}
public function test_email_field_is_required()
{
// Arrange
/** @var \App\Models\User $user */
$user = User::factory()->create();
// Act
$this->actingAs($user);
$this->get(route('customers.create'));
$response = $this->post('/customers', [
'first_name' => 'Isaac',
'last_name' => 'Souza',
'email' => null,
'display_currency' => 'BRL',
'mobile_phone' => '98983448902',
]);
// Assert
$response->assertRedirect(route('customers.create'));
$this->assertDatabaseCount('customers', 0);
}
public function test_email_field_must_be_a_valid_email()
{
// Arrange
/** @var \App\Models\User $user */
$user = User::factory()->create();
// Act
$this->actingAs($user);
$this->get(route('customers.create'));
$response = $this->post('/customers', [
'first_name' => 'Isaac',
'last_name' => 'Souza',
'email' => 'isaacsouza.dev',
'display_currency' => 'BRL',
'mobile_phone' => '98983448902',
]);
// Assert
$response->assertRedirect(route('customers.create'));
$this->assertDatabaseCount('customers', 0);
}
public function test_display_currency_field_is_required()
{
// Arrange
/** @var \App\Models\User $user */
$user = User::factory()->create();
// Act
$this->actingAs($user);
$this->get(route('customers.create'));
$response = $this->post('/customers', [
'first_name' => 'Isaac',
'last_name' => 'Souza',
'email' => 'me@isaacsouza.dev',
'display_currency' => null,
'mobile_phone' => '98983448902',
]);
// Assert
$response->assertRedirect(route('customers.create'));
$this->assertDatabaseCount('customers', 0);
}
public function test_display_currency_field_must_have_3_characters()
{
// Arrange
/** @var \App\Models\User $user */
$user = User::factory()->create();
// Act
$this->actingAs($user);
$this->get(route('customers.create'));
$response = $this->post('/customers', [
'first_name' => 'Isaac',
'last_name' => 'Souza',
'email' => 'me@isaacsouza.dev',
'display_currency' => 'BR',
'mobile_phone' => '98983448902',
]);
// Assert
$response->assertRedirect(route('customers.create'));
$this->assertDatabaseCount('customers', 0);
}
public function test_mobile_phone_field_is_required()
{
// Arrange
/** @var \App\Models\User $user */
$user = User::factory()->create();
// Act
$this->actingAs($user);
$this->get(route('customers.create'));
$response = $this->post('/customers', [
'first_name' => 'Isaac',
'last_name' => 'Souza',
'email' => 'me@isaacsouza.dev',
'display_currency' => 'BRL',
'mobile_phone' => null,
]);
// Assert
$response->assertRedirect(route('customers.create'));
$this->assertDatabaseCount('customers', 0);
}
public function test_mobile_phone_field_must_have_11_characters()
{
// Arrange
/** @var \App\Models\User $user */
$user = User::factory()->create();
// Act
$this->actingAs($user);
$this->get(route('customers.create'));
$response = $this->post('/customers', [
'first_name' => 'Isaac',
'last_name' => 'Souza',
'email' => 'me@isaacsouza.dev',
'display_currency' => 'BRL',
'mobile_phone' => '0',
]);
// Assert
$response->assertRedirect(route('customers.create'));
$this->assertDatabaseCount('customers', 0);
}
}
And if we ran all the tests in that file, they all pass.
Awesome, right? Not quite.
What’s the problem?
Look how many lines of code that single file has just to test a few validation rules for a form with just 5 fields.
Ok, 200 lines may not seem that bad, you may argue.
But the real problem is that, in the real world, forms may have way more than just 5 fields and may also require more validation rules for each field than just 2.
In the real world, it is almost always more complicated.
So, if we had a form with 20 field, and each field having 4 or 5 validation rules applied to it, and assuming we follow the same approach we just did to write the tests, which was one test for each validation rule, we could easily end up with a test file with hundreds and hundreds of lines of code just to test validation rules, and that is not ideal.
Another problem is the amount of repetition there is, it is true that this problem could be alleviated creating a single big test instead of multiple smaller ones, that way we wouldn’t need to create an user and log it in for every single test, but we would still have code duplication in multiple places, the assertions for example.
Let’s see how to solve that problem.
PHPUnit’s @dataProvider to the rescue
Let’s now implement the same suite of tests, but now leveraging PHPUnit’s @dataProvider feature.
The first thing you need to do is to add an annotation (@dataProvider) to your test method (lines 1 – 3).
/**
* @dataProvider customerFormData
*/
public function test_customers_validation_rules()
{
That annotation tells PHPUnit that the data to that specific test will be provided by a method called customerFormData.
Next step is to define that method somewhere inside that same test file, in my case it is just below the test method (lines 21 – 24).
<?php
namespace Tests\Feature;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use App\Models\User;
class CustomersTest extends TestCase
{
use RefreshDatabase;
/**
* @dataProvider customerFormData
*/
public function test_customers_validation_rules()
{
// ...
}
public static function customerFormData()
{
return [
// ...
];
}
}
Then you define the data that your test needs (in order to properly work) as parameters to the test method.
Recommended by LinkedIn
public function test_customers_validation_rules($first_name, $last_name, $email, $display_currency, $mobile_phone)
Next you define these same parameters inside the data provider method as an array.
Pay close attention that the data provider method must return an array of arrays.
The test method will be executed for each inner array you define, and it will be passed to the test method as multiple parameters.
public static function customerFormData()
{
return [
[
'first_name' => null,
'last_name' => 'Souza',
'email' => 'me@isaacsouza.dev',
'display_currency' => 'BRL',
'mobile_phone' => '98983448902',
],
];
}
Putting the theory into practice
The implementation for our test case could look something like this.
<?php
namespace Tests\Feature;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use App\Models\User;
class CustomersTest extends TestCase
{
use RefreshDatabase;
/**
* @dataProvider customerFormData
*/
public function test_customers_validation_rules($first_name, $last_name, $email, $display_currency, $mobile_phone)
{
/** @var \App\Models\User $user */
$user = User::factory()->create();
$this->actingAs($user);
$this->get(route('customers.create'));
$response = $this->post('/customers', [
'first_name' => $first_name,
'last_name' => $last_name,
'email' => $email,
'display_currency' => $display_currency,
'mobile_phone' => $mobile_phone,
]);
$response->assertRedirect(route('customers.create'));
$this->assertDatabaseCount('customers', 0);
}
public static function customerFormData()
{
return [
'first name field is required' => [
'first_name' => null,
'last_name' => 'Souza',
'email' => 'me@isaacsouza.dev',
'display_currency' => 'BRL',
'mobile_phone' => '98983448902',
],
'last name field is required' => [
'first_name' => 'Isaac',
'last_name' => null,
'email' => 'me@isaacsouza.dev',
'display_currency' => 'BRL',
'mobile_phone' => '98983448902',
],
'test email field is required' => [
'first_name' => 'Isaac',
'last_name' => 'Souza',
'email' => null,
'display_currency' => 'BRL',
'mobile_phone' => '98983448902',
],
'email field must be a valid email' => [
'first_name' => 'Isaac',
'last_name' => 'Souza',
'email' => 'isaacsouza.dev',
'display_currency' => 'BRL',
'mobile_phone' => '98983448902',
],
'display currency field is required' => [
'first_name' => 'Isaac',
'last_name' => 'Souza',
'email' => 'me@isaacsouza.dev',
'display_currency' => null,
'mobile_phone' => '98983448902',
],
'display currency field is must have 3 exactly characters' => [
'first_name' => 'Isaac',
'last_name' => 'Souza',
'email' => 'me@isaacsouza.dev',
'display_currency' => 'BR',
'mobile_phone' => '98983448902',
],
'mobile phone field is required' => [
'first_name' => 'Isaac',
'last_name' => 'Souza',
'email' => 'me@isaacsouza.dev',
'display_currency' => 'BRL',
'mobile_phone' => null,
],
'mobile phone field must have 11 characters' => [
'first_name' => 'Isaac',
'last_name' => 'Souza',
'email' => 'me@isaacsouza.dev',
'display_currency' => 'BRL',
'mobile_phone' => '0',
]
];
}
}
If we ran the whole test file again.
The result is basically identical to what we had before.
But look how many lines of code we have now, it’s HALF our original implementation!
Besides the number of lines of code being 50% smaller, we basically removed code duplication from our tests, which is really sweet.
And that’s how PHPUnit’s data providers can help you write less code, avoid duplication and keep you code cleaner.
A few things to keep in mind when using data providers in PHPUnit tests
/**
* @dataProvider customerFormData
*/
public function test_customers_validation_rules($first_name, $last_name, $email, $display_currency, $mobile_phone)
{
...
public static function customerFormData()
{
return [
...
public static function customerFormData()
{
return [
'first name field is required' => [
// ...
],
'last name field is required' => [
// ...
],
'test email field is required' => [
// ...
],
'email field must be a valid email' => [
// ...
],
'display currency field is required' => [
// ...
],
'display currency field is must have 3 exactly characters' => [
// ...
],
'mobile phone field is required' => [
// ...
],
'mobile phone field must have 11 characters' => [
// ...
]
];
}
/**
* @dataProvider customerFormData
*/
public function test_customers_validation_rules($first_name, $last_name, $email, $display_currency, $mobile_phone)
{
// ...
public static function customerFormData()
{
return [
'first name field is required' => [
'first_name' => null,
'last_name' => 'Souza',
'email' => 'me@isaacsouza.dev',
'display_currency' => 'BRL',
'mobile_phone' => '98983448902',
],
// ...
];
}
public static function customerFormData()
{
return [
'first name field is required' => [
'first_name' => 'Isaac',
'last_name' => 'Souza',
'email' => 'me@isaacsouza.dev',
'display_currency' => 'BRL',
'mobile_phone' => '98983448902',
],
// ...
];
}
If you do so, when you get an error, PHPUnit will use that name to tell you exactly which data set is breaking your tests (trust me, this is really helpful).
Conclusion
As you saw, PHPUnit’s data providers helped us reduce by half the amount of lines of code to test our validation rules.
And also helped up avoid code duplication.
The source code is available in this repo, in case you want to check it out.