PHPUnit @dataProvider: Tutorial with example

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).

  • First name (required)
  • Last name (required)
  • Email (required and must be a valid email)
  • Mobile phone (required and length equal to 10 characters)
  • Display currency (required and length equal to 3 characters)

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.

No alt text provided for this image

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.

     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.

No alt text provided for this image

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

  • The same name of the method providing the data has to be defined in the @dataProvider annotation of the test method (lines 3 and 10).

 
    /**
     * @dataProvider customerFormData
     */
    public function test_customers_validation_rules($first_name, $last_name, $email, $display_currency, $mobile_phone)
    {
         ...
 
 
    public static function customerFormData()
    {
        return [
         ...        

  • The data provider method need to return an array of arrays.

    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' => [
                // ...
            ]
        ];
    }        

  • Each individual entry of the inner array returned by the data provider method is passed to the test method as a parameter, in the same order (lines 4 and 12-16).

    /**
     * @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',
            ],
            // ...
        ];
    }        

  • You can create and associative array to give each test data set a more meaningful name (line 4).

    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).

No alt text provided for this image

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.

To view or add a comment, sign in

More articles by Isaac Souza

Others also viewed

Explore content categories