Testing PHP Applications

Pest Datasets

Pest datasets run one test shape against several named examples. They work well when the behavior is the same but inputs and expected results vary, especially for validation rules, calculations, parsers, and boundary conditions.

A dataset should make failures easier to understand. It should not compress unrelated scenarios into one table merely to reduce lines of test code.

Start With An Inline Dataset

Attach rows to a test with with():

PHP example
<?php

declare(strict_types=1);

function discount(int $totalPence): int
{
    return $totalPence >= 10_000 ? intdiv($totalPence, 10) : 0;
}

test('calculates the threshold discount', function (
    int $totalPence,
    int $expectedDiscountPence,
): void {
    expect(discount($totalPence))->toBe($expectedDiscountPence);
})->with([
    'one penny below threshold' => [9_999, 0],
    'exactly at threshold' => [10_000, 1_000],
    'well above threshold' => [15_000, 1_500],
]);

Each row supplies the test arguments in order. The names appear in test output, so a failure identifies the business boundary rather than only a numeric index.

Type the closure parameters when the dataset has a stable shape. A row with the wrong type will then fail clearly instead of being silently coerced through a broad parameter.

Name Cases By Meaning

Good names explain why the row exists:

empty password
one character below minimum
exactly at minimum
unicode password above minimum

Names such as case 1, valid, and invalid 2 force the reader to decode values before understanding the failure.

Include representative normal values and important boundaries. Avoid enormous combinatorial datasets that obscure which combinations have business significance.

Keep Each Dataset About One Rule

A password-length dataset should vary length. It should not also test compromised-password lookup, character policy, account lockout, and localization unless the test is explicitly about the combined validation pipeline.

Separate rules give better failure ownership:

password length dataset
password breach-status dataset
password confirmation dataset

A dataset row should contain only inputs needed by that test. Passing a complete application fixture into every row creates hidden coupling and noisy failures.

Use Named Arguments Carefully

Dataset values can be keyed so the relationship is visible, but the test parameters and supplied names must remain aligned with the installed Pest version and suite convention.

For small rows, positional arrays are often clearest. When a row contains several values of the same type, named keys can prevent accidental swaps. Choose one readable local convention and keep it consistent.

Share Stable Datasets

A dataset reused by several test files can be registered with dataset() in a file loaded by the suite:

PHP example
<?php

dataset('supported currencies', [
    'British pound' => 'GBP',
    'euro' => 'EUR',
    'US dollar' => 'USD',
]);

Use it by name:

PHP example
<?php

test('accepts a supported currency', function (string $currency): void {
    expect($currency)->toBeIn(['GBP', 'EUR', 'USD']);
})->with('supported currencies');

Share a dataset only when the rows have one stable meaning across all consumers. A global users dataset that means administrators in one test and customers in another creates fragile ownership.

Name shared datasets by domain meaning, not implementation location. Keep them near the tests that own the contract or in a clearly documented dataset directory.

Use Lazy Values For Test State

Some dataset values must be created after per-test setup, such as database records that depend on migrations or a refreshed application container. Pest supports closures and bound datasets for values resolved after beforeEach().

Use lazy values when creation timing genuinely matters. Do not hide all fixtures behind closures; simple scalar examples are easier to read directly.

Database-backed rows must remain isolated. A dataset that returns the same mutable model to several cases can introduce order dependence and prevent parallel execution.

Avoid Logic Inside The Dataset

The dataset supplies examples; the test performs the behavior and assertion. If the dataset computes the same expected result as production code, both can repeat the same defect.

Prefer explicit expected values:

PHP example
$discountCases = [
    'exactly at threshold' => [10_000, 1_000],
];

rather than calculating intdiv(10_000, 10) inside the dataset. The explicit value makes the contract reviewable.

Know When Not To Use A Dataset

Separate tests are clearer when cases need:

  • different setup or dependencies
  • different actions
  • different assertions
  • different failure explanations
  • a long narrative to explain the scenario

Do not force a successful checkout and a declined payment into one dataset merely because both receive a payment status. Their workflows and evidence differ.

Review Dataset Failures

When a row fails, reproduce that named case alone if possible, inspect the supplied values, and determine whether the implementation or expected result is wrong. Do not update every expected value to match current output without checking the rule.

Dataset changes are behavior changes when they alter the accepted examples. Review additions, removals, and renamed boundaries as carefully as test code.

Common Mistakes

  • Using unnamed numeric rows.
  • Combining unrelated rules in one table.
  • Calculating expected values with the same logic as production.
  • Sharing a broad dataset with unclear ownership.
  • Returning shared mutable objects to parallel tests.
  • Hiding simple values behind unnecessary factories.
  • Using a dataset when each case needs a different workflow.

What To Remember

Datasets are best for one behavior applied to several meaningful examples. Name boundary cases, type test parameters, keep expected values explicit, share only stable domain datasets, and use lazy values only when setup timing requires them.

Before moving on, make sure you can explain why named rows improve CI failures, why expected results should be explicit, and when separate tests are clearer than one dataset.

Practice

Task: Test A Password-Length Rule With A Dataset

Write a Pest test and dataset that cover:

  • an empty password
  • eleven ASCII characters
  • exactly twelve ASCII characters
  • a longer phrase
  • twelve Unicode characters

Requirements:

  • use actual Pest with() dataset syntax
  • give every row a descriptive name
  • type both test parameters
  • store explicit expected booleans rather than calculating them in the dataset
  • keep the dataset limited to length behavior
  • explain why breach lookup and confirmation matching should use separate tests

Then show how the same rows could be registered as a shared dataset named password length boundaries if a second policy test genuinely reused them.

Show solution
PHP example
<?php

declare(strict_types=1);

use App\Security\PasswordPolicy;

test('applies the minimum password length', function (
    string $password,
    bool $expected,
): void {
    $policy = new PasswordPolicy();

    expect($policy->accepts($password))->toBe($expected);
})->with([
    'empty password' => ['', false],
    'one ASCII character below minimum' => ['abcdefghijk', false],
    'exactly twelve ASCII characters' => ['abcdefghijkl', true],
    'longer phrase' => ['correct horse battery staple', true],
    'exactly twelve Unicode characters' => ['áéíóúñüçøåæß', true],
]);

If the same examples have one stable meaning for another test, register them once:

PHP example
<?php

dataset('password length boundaries', [
    'empty password' => ['', false],
    'one ASCII character below minimum' => ['abcdefghijk', false],
    'exactly twelve ASCII characters' => ['abcdefghijkl', true],
    'longer phrase' => ['correct horse battery staple', true],
    'exactly twelve Unicode characters' => ['áéíóúñüçøåæß', true],
]);

The consuming test ends with ->with('password length boundaries').

Breach lookup and confirmation matching require different dependencies, actions, and failure explanations. Keeping them separate prevents a length failure from being confused with an unavailable breach service or a mismatched confirmation field.