sent_at is pinned, so the timestamp no longer flakes. Three bad statuses are one behaviour: the invoice is rejected. Copying the test three times makes three places to update when the route changes. A dataset runs the same test once for each value.
Test-driven Laravel · Lesson 3
Datasets
Video
01 One test, a list of bad values
The argument is the status. The name before the arrow is what Pest prints when that case fails.
it('rejects a status the invoice cannot hold', function (string $status) {
$user = User::factory()->create();
$this->actingAs($user)
->post('/invoices', [
'number' => 'INV-1042',
'issued_on' => '2026-10-22',
'status' => $status,
])
->assertInvalid(['status']);
})->with([
'blank' => '',
'unknown' => 'banana',
'paid' => 'paid',
]);paid is in the list because a request must not set the status to paid. That happens in the job, after the provider accepts the charge. The dataset says so without a second test body.
Keep a dataset to values of the same behaviour. A guest and a bad status are different failures, and they stay as different tests.
02 A second dataset can cover the statuses that are allowed
The test above is the reject list. The accept list is a different outcome: the invoice is stored, and the status column matches the value that was posted. Same shape, different assertion, so it stays a second test rather than a flag inside the first.
it('stores a status the invoice is allowed to hold', function (string $status) {
$user = User::factory()->create();
$this->actingAs($user)
->post('/invoices', [
'number' => 'INV-1042',
'issued_on' => '2026-10-22',
'status' => $status,
])
->assertRedirect();
$this->assertDatabaseHas('invoices', [
'number' => 'INV-1042',
'status' => $status,
]);
})->with([
'draft' => 'draft',
'issued' => 'issued',
]);paid is missing on purpose. A request that posts paid belongs in the reject dataset. The job sets paid after the provider accepts the charge, and no row in this test should be able to do that by itself.
Pest prints the name before the arrow when a case fails, so blank, unknown and paid tell you which value broke. A list of bare strings still runs. It fails as dataset 0, and you have to open the file to see which status that was. Name the cases.
Recap
You can run one rejection test for each illegal status without copying the request.
- The name before the arrow is what Pest prints when that case fails.
paidis rejected on the form because the job sets it, not the request.- A guest and a bad status are different failures. They stay different tests.
Check yourself · 3 questions
Question 1 of 3blank, banana, and paid are three tests with the same body. What replaces the copies?