Software Design Enthusiast And Engineer

Data Providers: A Guide To Using Generators Within Pest Tests

4 min read
Status: Completed Read year: 2024

I won't go into the benefits of using data providers, or what data providers are for, there's many posts out there for that. Injecting arguments into a test, after PHPUnit's setup method has run though, that's not so common to see out there. Starting with the basics, we'll cover how to yield values from generators within Pest tests. We'll also cover how you can use "booted data providers", a term I'm using to imply that PHPUnit's setup method has run.

💡
Note:
If you're working with PHPUnit, you won't have access to some of the features mentioned in this [Pest] article. This article is extremely helpful:
https://blog.martinhujer.cz/how-to-use-data-providers-in-phpunit/

A couple notes before continuing:

  1. I'll often mention PHPUnit in this article because it's the framework that Pest is built upon. Pest provides many abstractions over PHPUnit's API, but the underlying logic and/or constraints I'll be mentioning still apply.
  2. When I mention vendor/bin/pest <some_command> [--some_options], it's assumed that you've cd'd into root directory of the project. If using Laravel, it's also functionally equivalent to using php artisan test <some_command> [--some_options].

The Challenge and Solution

PHPUnit executes data provider methods before the setup method has been called. In many frameworks, this means that many/all of the tooling and functionality is not available yet. Generators solve this by delaying evaluation until PHPUnit's setup method has run. This is the heart of what we'll be discussing.

Covering the basics: Generic Data Providers

If your datasets are simple or you don't need fine-grained control over which tests to run, omitting the name of the data provider is perfectly fine. The output might be slightly less descriptive, but the functionality remains the same.

A basic example looks like this:

test('something runs', function ($truthy, $falsy) {
    expect($truthy)
        ->toBeTrue()
        ->and($falsy)
        ->toBeFalse();
})->with(function () {
    yield [true, false];
});

Pest makes this more intuitive than PHPUnit by running logic that inserts the yielded values as the 2 arguments we'd expect.

Another example that provides the same functionality looks like this:

test('something runs', function ($truthy, $falsy) {
    expect($truthy)
        ->toBeTrue()
        ->and($falsy)
        ->toBeFalse();
})->with(function () {
    yield [fn () => [true, false]];
});

A few notes about what we're yielding here:

  • This won't work in PHPUnit, at least at the time I'm writing this article.
  • The initial array wrapping is what's seen as a dictionary that represents all the arguments that will be passed in.
  • The 2nd wrapping, which is a closure that Pest will call automatically, returns an array that serves as a stand-in for the outer wrapper. Again, Pest will pass the two arguments in as one would assume.

Named Data Providers

As with PHPUnit, you have the option to name each dataset that your data provider is yielding. This is done by providing a key as the first element in the array yielded by the data provider:

test('something runs with generators', function ($truthy, $falsy) {
    expect($truthy)
        ->toBeTruthy()
        ->and($falsy)
        ->toBeFalsy();
})->with(function () {
    yield 'dataset 1' => [true, false];

    yield 'dataset 2' => ['1', '0'];
});

The test output clearly identifies which dataset it's running:

"Booted" Data Providers

Up until this point, we've been yielding values that either:

  1. Have to be hard-coded, or
  2. Can be generated dynamically, but won't have access to any framework tooling that you might want/need to use. If you're using Laravel, for example, you wouldn't be able to use Facades, call upon any config() values, etc.

In PHPUnit, you could pass a closure into your test and achieve the same effect, but Pest has our back here. We can yield an array containing a closure and Pest will automatically call our closure before passing the argument(s) into the test.

Here's an example:

test('something runs with "booted" datasets', function ($truthy, $falsy) {
    expect($truthy)
        ->toBeTruthy()
        ->and($falsy)
        ->toBeFalsy();
})->with(function () {
    yield 'dataset 1' => [fn () => [true, false]];

    yield 'dataset 2' => [fn () => ['1', '0']];
});

Based on the previous examples, one might assume that the closure within each yielded array will be passed in as the $truthy parameter, but it isn't.

Pest, being fully aware that we're often using a framework, is providing a convenience layer here which allows us to run framework-based logic from within the closure. If using the beforeEach() function provided by Pest in conjunction with Laravel, this allows to easily warm up the cache, do database setup, etc. from within the beforeEach() function, then use that value from within our datasets.

Here's an example of how these things can play together:

beforeEach(function () {
    if (str(test()->dataName())->containsAll(['dataset', '1'])) {
       cache()->driver('array')->putMany([
           'truthy' => true,
           'falsy' => false,
       ]);
    } elseif (str(test()->dataName())->containsAll(['dataset', '2'])) {
        cache()->driver('array')->putMany([
            'truthy' => '1',
            'falsy' => '0',
        ]);
    } else {
        cache()->driver('array')->deleteMultiple(['truthy', 'falsy']);
    }
});

test('something runs with "booted" datasets', function ($truthy, $falsy) {
    expect($truthy)
        ->toBeTruthy()
        ->and($falsy)
        ->toBeFalsy();
})->with(function () {
    yield 'dataset 1' => [function () {
        return [
            cache()->driver('array')->get('truthy'),
            cache()->driver('array')->get('falsy')
        ];
    }];

    yield 'dataset 2' => [function () {
        return [
            cache()->driver('array')->get('truthy'),
            cache()->driver('array')->get('falsy')
        ];
    }];

    yield 'dataset 3' => [function () {
        return [
            !cache()->driver('array')->has('truthy'),
            cache()->driver('array')->has('falsy')
        ];
    }];
});

Though it's very contrived, this example illustrates the perfect harmony between the beforeEach function and "booted data providers" in Pest. It passes!

If we put a dump statement within each callback, we see how this logic flows:

Laravel dump statements that provide insight into how our test runs
The dump results using Laravel Herd

Though our test is passing on every dataset, it's important to note that these dump statements are out of order. As nice as it would be, we can't depend on each of our datasets to be passed in in a logical order.

Conclusion

Pest provides flexible data provider methodologies which allow us to generate dynamic datasets based on almost any domain/framework code. This comes in very handy when drying or cleaning up our Pest tests.