When writing automated tests, sometimes you want to repeat the same test multiple times on various data inputs. In Laravel, you can do this easily with data providers and datasets. I will show you a PHPUnit and a Pest example.
Let's say we have this Controller with the validation:
class CreateTaskController extends Controller{ public function __invoke(Request $request) { $request->validate([ 'name' => ['required', 'string', 'max:255'], 'subtasks' => ['required', 'array'], 'subtasks.*.name' => ['required', 'string', 'max:255'], 'subtasks.*.is_completed' => ['required', 'boolean'], 'subtasks.*.due_date' => ['required', 'date'], ]); // ... saving the task
And then, we want to test that, with different input data, we have different validation errors.
class TaskCreationTest extends TestCase{ public function test_cannot_save_task_without_name() { $this->postJson(route('task.store'), [ 'subtasks' => [ [ 'name' => 'Subtask 1', 'is_completed' => false, 'due_date' => '2024-01-01', ], ], ]) ->assertStatus(422) ->assertJsonValidationErrorFor('name'); } public function test_cannot_save_task_without_subtasks() { $this->postJson(route('task.store'), [ 'name' => 'Task 1', ]) ->assertStatus(422) ->assertJsonValidationErrorFor('subtasks'); } // ... other test methods for other fields
But wouldn't it be cool to have ONE method instead? To not copy-paste the same test method into another one just to assert different statuses for a different data set?
Here's how you can do it: by specifying @dataProvider
and a separate method that returns the array of arrays.
class TaskCreationTest extends TestCase{ /** * @dataProvider inputAndOutputDataProvider */ public function test_validation_checks($input, $statusCode, $errorField): void { $this->postJson(route('task.store'), $input) ->assertStatus($statusCode) ->assertJsonValidationErrorFor($errorField); } public static function inputAndOutputDataProvider(): array { return [ 'name is required' => [ 'input' => [ 'subtasks' => [ [ 'name' => 'Subtask 1', 'is_completed' => false, 'due_date' => '2024-01-01', ], ], ], 'statusCode' => 422, 'errorField' => 'name', ], 'subtasks is required' => [ 'input' => [ 'name' => 'Task 1', ], 'statusCode' => 422, 'errorField' => 'subtasks', ], 'subtasks must be an array' => [ 'input' => [ 'name' => 'Task 1', 'subtasks' => 'not an array', ], 'statusCode' => 422, 'errorField' => 'subtasks', ], 'subtasks must be an array of arrays with name' => [ 'input' => [ 'name' => 'Task 1', 'subtasks' => [ [ 'is_completed' => false, 'due_date' => '2024-01-01', ], ], ], 'statusCode' => 422, 'errorField' => 'subtasks.0.name', ], 'subtasks must be an array of arrays with is_completed' => [ 'input' => [ 'name' => 'Task 1', 'subtasks' => [ [ 'name' => 'Subtask 1', 'due_date' => '2024-01-01', ], ], ], 'statusCode' => 422, 'errorField' => 'subtasks.0.is_completed', ], 'subtasks must be an array of arrays with is_completed as boolean' => [ 'input' => [ 'name' => 'Task 1', 'subtasks' => [ [ 'name' => 'Subtask 1', 'is_completed' => 'no', 'due_date' => '2024-01-01', ], ], ], 'statusCode' => 422, 'errorField' => 'subtasks.0.is_completed', ], 'subtasks must be an array of arrays with due_date' => [ 'input' => [ 'name' => 'Task 1', 'subtasks' => [ [ 'name' => 'Subtask 1', 'is_completed' => false, ], ], ], 'statusCode' => 422, 'errorField' => 'subtasks.0.due_date', ], 'subtasks must be an array of arrays with due_date in correct format' => [ 'input' => [ 'name' => 'Task 1', 'subtasks' => [ [ 'name' => 'Subtask 1', 'is_completed' => false, 'due_date' => '1234', ], ], ], 'statusCode' => 422, 'errorField' => 'subtasks.0.due_date', ], ]; }}
Here's the result when you run the php artisan test
.
As you can see, each item in the dataset has a key and a value from input
, statusCode
, and errorField
, which then automatically become the parameters of the primary method with the same names.
Convenient, isn't it?
Recently Taylor asked the community whether Pest should become the default choice in Laravel 11.
The community has split almost in half, but irrelevant of whether the switch will happen, Pest is a solid alternative, and it's worth it for me to show testing examples with both PHPUnit and Pest.
So, the same thing as above in Pest would look like this:
it('tests validation checks', function ($input, $statusCode, $errorField) { $this->postJson(route('task.store'), $input) ->assertStatus($statusCode) ->assertJsonValidationErrorFor($errorField);}) ->with('taskCreationData');
The main thing here is ->with('taskCreationData')
. And then you can even separate the dataset into a different file:
tests/Feature/Datasets.php:
dataset('taskCreationData', [ 'name is required' => [ 'input' => [ 'subtasks' => [ [ 'name' => 'Subtask 1', 'is_completed' => false, 'due_date' => '2024-01-01', ], ], ], 'statusCode' => 422, 'errorField' => 'name', ], 'subtasks is required' => [ 'input' => [ 'name' => 'Task 1', ], 'statusCode' => 422, 'errorField' => 'subtasks', ], 'subtasks must be an array' => [ 'input' => [ 'name' => 'Task 1', 'subtasks' => 'not an array', ], 'statusCode' => 422, 'errorField' => 'subtasks', ], 'subtasks must be an array of arrays with name' => [ 'input' => [ 'name' => 'Task 1', 'subtasks' => [ [ 'is_completed' => false, 'due_date' => '2024-01-01', ], ], ], 'statusCode' => 422, 'errorField' => 'subtasks.0.name', ], 'subtasks must be an array of arrays with is_completed' => [ 'input' => [ 'name' => 'Task 1', 'subtasks' => [ [ 'name' => 'Subtask 1', 'due_date' => '2024-01-01', ], ], ], 'statusCode' => 422, 'errorField' => 'subtasks.0.is_completed', ], 'subtasks must be an array of arrays with is_completed as boolean' => [ 'input' => [ 'name' => 'Task 1', 'subtasks' => [ [ 'name' => 'Subtask 1', 'is_completed' => 'no', 'due_date' => '2024-01-01', ], ], ], 'statusCode' => 422, 'errorField' => 'subtasks.0.is_completed', ], 'subtasks must be an array of arrays with due_date' => [ 'input' => [ 'name' => 'Task 1', 'subtasks' => [ [ 'name' => 'Subtask 1', 'is_completed' => false, ], ], ], 'statusCode' => 422, 'errorField' => 'subtasks.0.due_date', ], 'subtasks must be an array of arrays with due_date in correct format' => [ 'input' => [ 'name' => 'Task 1', 'subtasks' => [ [ 'name' => 'Subtask 1', 'is_completed' => false, 'due_date' => '1234', ], ], ], 'statusCode' => 422, 'errorField' => 'subtasks.0.due_date', ],]);
Result in the terminal:
Read more about this feature: