Skip to content

Instantly share code, notes, and snippets.

@leeovery
Last active January 30, 2025 14:53
Show Gist options
  • Save leeovery/aa1b51e35700a7ba851ac6c667b383e1 to your computer and use it in GitHub Desktop.
Save leeovery/aa1b51e35700a7ba851ac6c667b383e1 to your computer and use it in GitHub Desktop.
Testing Laravel Validation

Testing Laravel Validation

Register the macro in a service provider or use the MacroServiceProvider to keep things tidy. Be sure to register it in the array in the /bootstrap/providers.php file.

You can then use the test example to “use” a dataset to run through your validation rules.

It will execute each item in the dataset, building the request for the provided parameter and the (incorrect) value, and then assert that the error is as expected.

If the validation fails to run, the error will not be found, and the test will fail. Similarily, if the error message changes, the test will fail.

You can also assert that an error should NOT appear, and then the inverse of the above becomes true.

This has saved me countless times and is well worth the effort.

Once you have a bunch of these rules, it’s really just copy and paste between requests/projects.

CoPilot is also quite good at picking up what’s being tested. After a few dataset items, it manages (mostly) to infer the additional items from the FormRequest under test.

I’ve included a handful of dataset items to show how different rules can be tested, along with one that adds additional request data needed to execute a particular rule.

There’s also a tap method for tapping into the request data before it is merged, along with a few helpers for building long strings to fail string length rules.

<?php
use Tests\Concerns\RequestDataProviderItem;
dataset('application create', [
'UUID is required' => [
new RequestDataProviderItem()
->attribute('uuid')
->empty()
->assertError('The uuid field is required.'),
],
'UUID must be a UUID' => [
new RequestDataProviderItem()
->attribute('uuid')
->string(5)
->assertError('The uuid field must be a valid UUID.'),
],
'Title is required' => [
new RequestDataProviderItem()
->attribute('title')
->empty()
->assertError('The title field is required.'),
],
'Title must be a string' => [
new RequestDataProviderItem()
->attribute('title')
->array(1)
->assertError('The title field must be a string.'),
],
'Title must be <= 100 characters' => [
new RequestDataProviderItem()
->attribute('title')
->string(101)
->assertError('The title field must not be greater than 100 characters.'),
],
'Email is required' => [
new RequestDataProviderItem()
->attribute('email')
->empty()
->assertError('The email field is required.'),
],
'Email must be <= 255 characters' => [
new RequestDataProviderItem()
->attribute('email')
->string(256)
->assertError('The email field must not be greater than 255 characters.'),
],
'Email must be a string' => [
new RequestDataProviderItem()
->attribute('email')
->array(1)
->assertError('The email field must be a string.'),
],
'Email must be an email' => [
new RequestDataProviderItem()
->attribute('email')
->email(valid: false)
->assertError('You must provide a real functioning email address.'),
],
'DOB must be a date' => [
new RequestDataProviderItem()
->attribute('dob')
->string(5)
->assertError('The dob field must match the format Y-m-d.'),
],
'DOB must adhere to a specific format' => [
new RequestDataProviderItem()
->attribute('dob')
->date('d-m-Y')
->assertError('The dob field must match the format Y-m-d.'),
],
'Address must be an array' => [
new RequestDataProviderItem()
->attribute('addresses')
->string(5)
->assertError('The addresses field must be an array.'),
],
'Address must have not have more than 25 items' => [
new RequestDataProviderItem()
->attribute('addresses')
->array(26)
->assertError('The addresses field must not have more than 25 items.'),
],
'Address address1 is required' => [
new RequestDataProviderItem()
->attribute('addresses.0.address1')
->empty()
->assertError('The address line 1 field is required.'),
],
'Address address1 must be a string' => [
new RequestDataProviderItem()
->attribute('addresses.0.address1')
->array(1)
->assertError('The address line 1 field must be a string.'),
],
'Address address1 must be <= 255 characters' => [
new RequestDataProviderItem()
->attribute('addresses.0.address1')
->string(256)
->assertError('The address line 1 field must not be greater than 255 characters.'),
],
'Address address2 must be a string' => [
new RequestDataProviderItem()
->attribute('addresses.0.address2')
->array(1)
->assertError('The address line 2 field must be a string.'),
],
'Address address2 must be <= 255 characters' => [
new RequestDataProviderItem()
->attribute('addresses.0.address2')
->string(256)
->assertError('The address line 2 field must not be greater than 255 characters.'),
],
'Address address3 must be a string' => [
new RequestDataProviderItem()
->attribute('addresses.0.address3')
->array(1)
->assertError('The address line 3 field must be a string.'),
],
'Address address3 must be <= 255 characters' => [
new RequestDataProviderItem()
->attribute('addresses.0.address3')
->string(256)
->assertError('The address line 3 field must not be greater than 255 characters.'),
],
'Address town is required' => [
new RequestDataProviderItem()
->attribute('addresses.0.town')
->empty()
->assertError('The address town field is required.'),
],
'Address town must be a string' => [
new RequestDataProviderItem()
->attribute('addresses.0.town')
->array(1)
->assertError('The address town field must be a string.'),
],
'Address town must be <= 255 characters' => [
new RequestDataProviderItem()
->attribute('addresses.0.town')
->string(256)
->assertError('The address town field must not be greater than 255 characters.'),
],
'Address county is required' => [
new RequestDataProviderItem()
->attribute('addresses.0.county')
->empty()
->assertError('The address county field is required.'),
],
'Address county must be a string' => [
new RequestDataProviderItem()
->attribute('addresses.0.county')
->array(1)
->assertError('The address county field must be a string.'),
],
'Address county must be <= 255 characters' => [
new RequestDataProviderItem()
->attribute('addresses.0.county')
->string(256)
->assertError('The address county field must not be greater than 255 characters.'),
],
'Address postcode is required' => [
new RequestDataProviderItem()
->attribute('addresses.0.postcode')
->empty()
->assertError('The address postcode field is required.'),
],
'Address postcode must be a string' => [
new RequestDataProviderItem()
->attribute('addresses.0.postcode')
->array(1)
->assertError('The address postcode field must be a string.'),
],
'Address postcode must be <= 20 characters' => [
new RequestDataProviderItem()
->attribute('addresses.0.postcode')
->string(21)
->assertError('The address postcode field must not be greater than 20 characters.'),
],
'Address postcode must a valid postcode' => [
new RequestDataProviderItem()
->attribute('addresses.0.postcode')
->value('NR1 DDD')
->assertError('The postcode field is invalid.'),
],
'Address country is required' => [
new RequestDataProviderItem()
->attribute('addresses.0.country')
->empty()
->assertError('The address country field is required.'),
],
'Address country must be a string' => [
new RequestDataProviderItem()
->attribute('addresses.0.country')
->array(1)
->assertError('The address country field must be a string.'),
],
'Address country must be <= 255 characters' => [
new RequestDataProviderItem()
->attribute('addresses.0.country')
->string(256)
->assertError('The address country field must not be greater than 255 characters.'),
],
'Address from date must be a string' => [
new RequestDataProviderItem()
->attribute('addresses.0.from_date')
->array(1)
->assertError('The address from-date field must be a string.'),
],
'Address from date must be a date' => [
new RequestDataProviderItem()
->attribute('addresses.0.from_date')
->string(5)
->assertError('The address from-date field must match the format Y-m-d.'),
],
'Address from date must adhere to a specific format' => [
new RequestDataProviderItem()
->attribute('addresses.0.from_date')
->date('d-m-Y')
->assertError('The address from-date field must match the format Y-m-d.'),
],
'Address from date must be before tomorrow' => [
new RequestDataProviderItem()
->attribute('addresses.0.from_date')
->value(now()->addDays(2)->format('Y-m-d'))
->assertError('The address from-date field must be a date before tomorrow.'),
],
'Address to-date must be a string' => [
new RequestDataProviderItem()
->attribute('addresses.0.to_date')
->array(1)
->assertError('The address to-date field must be a string.'),
],
'Address to-date must be a date' => [
new RequestDataProviderItem()
->attribute('addresses.0.to_date')
->string(5)
->assertError('The address to-date field must match the format Y-m-d.'),
],
'Address to-date must adhere to a specific format' => [
new RequestDataProviderItem()
->attribute('addresses.0.to_date')
->date('d-m-Y')
->assertError('The address to-date field must match the format Y-m-d.'),
],
'Address to-date must be before tomorrow' => [
new RequestDataProviderItem()
->attribute('addresses.0.to_date')
->value(now()->addDays(2)->format('Y-m-d'))
->assertError('The address to-date field must be a date before tomorrow.'),
],
'Address to-date must be after the linked from date' => [
new RequestDataProviderItem()
->attribute('addresses.0.to_date')
->value(now()->subDays(5)->format('Y-m-d'))
->with([
'addresses' => [
[
'from_date' => now()->subDays(2)->format('Y-m-d'),
],
],
])
->assertError('The address to-date field must be a date after address from-date.'),
],
]);
<?php
declare(strict_types=1);
namespace App\Providers;
use App\Enums\ApiTokenAbility;
use Illuminate\Routing\Route;
use Illuminate\Support\ServiceProvider;
use Illuminate\Testing\TestResponse;
use Tests\Concerns\RequestDataProviderItem;
class MacroServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->registerTestResponseMacros();
}
private function registerTestResponseMacros(): void
{
TestResponse::macro(
'assertValidationErrors',
function (RequestDataProviderItem $dataProviderItem): TestResponse {
/* @var TestResponse $this */
return $this
->assertUnprocessable()
->when(
filled($dataProviderItem->expectedError),
fn (TestResponse $test) => $test->assertInvalid([
$dataProviderItem->attribute => $dataProviderItem->expectedError,
])
)
->when(
filled($dataProviderItem->notExpectedError),
fn (TestResponse $test) => $test->assertValid($dataProviderItem->attribute)
);
}
);
}
}
<?php
declare(strict_types=1);
namespace Tests\Concerns;
use Closure;
use Illuminate\Support\Arr;
use Illuminate\Contracts\Support\Arrayable;
class RequestDataProviderItem implements Arrayable
{
public string $attribute;
public mixed $value;
public ?string $expectedError = null;
public ?string $notExpectedError = null;
public array|Closure $extraRequestData = [];
public array $taps = [];
public function attribute(string $attribute): self
{
$this->attribute = $attribute;
return $this;
}
public function tap(callable $callable): self
{
$this->taps[] = $callable;
return $this;
}
public function value(mixed $value): self
{
$this->value = $value;
return $this;
}
public function number(): self
{
$this->value = random_int(10, 1000);
return $this;
}
public function empty(): self
{
$this->value = null;
return $this;
}
public function boolean(bool $true = true): self
{
$this->value = $true;
return $this;
}
public function string(int $length): self
{
$this->value = static::buildString($length);
return $this;
}
public static function buildString(int $count, string $item = 'x'): string
{
return str_repeat($item, $count);
}
public function email(bool $valid = true): static
{
$this->value = $valid
? fake()->unique()->safeEmail()
: 'invalid-email@';
return $this;
}
public function date(string $format = 'Y-m-d'): self
{
$this->value = now()->format($format);
return $this;
}
public function array(int $count, mixed $item = []): static
{
$this->value = static::buildArray($count, $item);
return $this;
}
public static function buildArray(int $count, mixed $item = []): array
{
return array_fill(0, $count, $item);
}
public function assertError(string $error): self
{
$this->expectedError = $error;
return $this;
}
public function assertNotError(string $error): self
{
$this->notExpectedError = $error;
return $this;
}
public function with(array|Closure $extraRequestData): self
{
$this->extraRequestData = $extraRequestData;
return $this;
}
public function toArray(): array
{
return [
$this->attribute,
$this->value,
$this->expectedError,
$this->notExpectedError,
$this->extraRequestData,
];
}
public function buildRequest(...$args): array
{
$requestData = [];
foreach ($this->taps as $tap) {
value($tap, ...$args);
}
return array_replace_recursive(
data_set($requestData, $this->attribute, value($this->value, ...$args)),
Arr::undot(value($this->extraRequestData, $requestData, ...$args))
);
}
}
<?php
use Tests\Concerns\RequestDataProviderItem;
use function Pest\Laravel\postJson;
test('will fail to create an application with invalid request data', function (RequestDataProviderItem $dataProviderItem) {
postJson(
'/applications',
$dataProviderItem->buildRequest()
)->assertValidationErrors($dataProviderItem);
})->with('application create');
@sambenne
Copy link

I like this, what might be good though is pulling the validation messages from the lang file maybe.

@leeovery
Copy link
Author

I like this, what might be good though is pulling the validation messages from the lang file maybe.

Nice idea.

Generally I like to hard type into my tests to make them more reliable. Also I often use attributes or messages method on form request which makes it a little trickier to use the language file. But deffo an idea to explore.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment