Testing is the cornerstone of reliable software development. In this article, I'll walk you through building a comprehensive test suite for a Laravel application using Pest PHP, covering everything from basic validation tests to complex authorization scenarios with datasets.
๐ Setting Up the Foundation
We're building a user management system with role-based permissions. Our setup includes:
- Laravel 12 with Pest PHP for testing
- Role-based permissions (admin, manager, guest)
- Policy-driven authorization
- Comprehensive test coverage
// Our User model with role support
protected $fillable = [
'name',
'email',
'password',
'role',
];
๐ Test Structure Overview
Our test suite is organized into three main categories:
- Unit Tests - Testing individual components in isolation
- Feature Tests - Testing complete workflows and HTTP endpoints
- Policy Tests - Testing authorization logic with datasets
๐งช Validation Testing: Building Robust Input Validation
The Challenge: Ensuring Data Quality at the Gate
When building APIs, validation is your first line of defense against bad data. A single missed validation rule can cascade into database corruption, security vulnerabilities, or application crashes. That's why we take a methodical approach to testing every validation scenario.
Our Strategy: Test Every Edge Case
Let's start with comprehensive validation testing. Here's how we structure validation tests using Pest's describe
blocks:
describe('User Creation Validation', function () {
test('name field is required', function () {
$response = $this->postJson('/api/users', [
'email' => 'test@example.com',
'password' => 'password123',
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['name']);
});
test('email must be unique', function () {
User::factory()->create(['email' => 'test@example.com']);
$response = $this->postJson('/api/users', [
'name' => 'John Doe',
'email' => 'test@example.com', // Duplicate email
'password' => 'password123',
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['email']);
});
});
Why This Approach Works
The magic is in the methodology. Here's the thinking behind each decision:
Clear organization: Each validation rule gets its own focused test because when a test fails, you want to know exactly which rule broke. No guessing, no debugging multiple scenarios.
Descriptive names: Test names clearly describe what they're validating. When your test suite runs and you see "email must be unique โ", you immediately understand what's being validated. This is living documentation.
Comprehensive coverage: We test required fields, data types, lengths, and uniqueness because real users will try everything. They'll send numbers where you expect strings, empty arrays where you need objects, and duplicate data where you enforce uniqueness.
The Real-World Impact
Each test prevents a specific failure mode:
- Required field tests โ Prevent null pointer exceptions
- Type validation tests โ Prevent type casting errors
- Length tests โ Prevent database overflow errors
- Uniqueness tests โ Prevent constraint violations
๐ Authorization Testing: Role-Based Permissions
The Authorization Problem: Security Complexity
Authorization logic is where bugs become security vulnerabilities. A single wrong condition in your policy can expose sensitive data or allow unauthorized actions. Traditional testing approaches lead to either incomplete coverage or massive code duplication.
The Laravel Flow: Why Order Matters
Here's a critical insight we discovered: Laravel executes FormRequest validation BEFORE reaching your controller. This means if you put authorization in your controller, non-admin users get validation errors (422) instead of forbidden errors (403).
That's why we moved authorization to the FormRequest level:
// In StoreUserRequest
public function authorize(): bool
{
return $this->user() && $this->user()->can('create', User::class);
}
This ensures the security check happens first, giving proper HTTP status codes.
Our Testing Strategy: Comprehensive Role Coverage
Authorization testing gets more complex, especially with multiple roles and permissions. Here's where Pest's datasets really shine:
describe('User Store Authorization', function () {
test('admin can create users', function () {
$admin = User::factory()->create(['role' => 'admin']);
$response = $this->actingAs($admin)->postJson('/api/users', [
'name' => 'John Doe',
'email' => 'john@example.com',
'password' => 'password123',
]);
$response->assertCreated();
});
test('manager cannot create users', function () {
$manager = User::factory()->create(['role' => 'manager']);
$response = $this->actingAs($manager)->postJson('/api/users', [
'name' => 'John Doe',
'email' => 'john@example.com',
'password' => 'password123',
]);
$response->assertForbidden();
});
});
๐ Advanced Testing with Datasets
The Dataset Revolution: From 100 Tests to 1
Traditional authorization testing means writing individual tests for every role/permission combination. For our user system with 3 roles and 5 permissions, that's potentially 15+ separate tests. Each test has setup code, assertions, and maintenance overhead.
The Problem:
- Code duplication across similar tests
- Easy to miss edge cases or role combinations
- Hard to maintain when adding new roles
- Difficult to see the complete permission matrix
The Solution: Pest datasets turn this complexity into elegance.
Here's where Pest really shines. Instead of writing individual tests for each role and permission combination, we can use datasets:
dataset('roles_and_permissions', [
'admin can viewAny' => ['admin', 'viewAny', [], true],
'manager can viewAny' => ['manager', 'viewAny', [], true],
'guest cannot viewAny' => ['guest', 'viewAny', [], false],
'admin can create' => ['admin', 'create', [], true],
'manager cannot create' => ['manager', 'create', [], false],
'guest cannot create' => ['guest', 'create', [], false],
'guest can update only themselves' => ['guest', 'update', ['same_user'], true],
'guest cannot update other users' => ['guest', 'update', ['target_user'], false],
]);
it('tests user policy permissions', function (string $role, string $method, array $params, bool $expected) {
$policy = new UserPolicy();
$user = createUser($role, 1);
$targetUser = createUser('user', 2);
$result = match ($method) {
'viewAny' => $policy->viewAny($user),
'view' => $policy->view($user, in_array('same_user', $params) ? $user : $targetUser),
'create' => $policy->create($user),
'update' => $policy->update($user, in_array('same_user', $params) ? $user : $targetUser),
'delete' => $policy->delete($user, in_array('same_user', $params) ? $user : $targetUser),
};
expect($result)->toBe($expected);
})->with('roles_and_permissions');
Benefits of Dataset Testing
This approach transforms our testing strategy:
Comprehensive coverage: Test all role/permission combinations with minimal code. The dataset becomes a visual permission matrix showing exactly what each role can and cannot do.
Maintainable: Add new scenarios by simply extending the dataset. Want to test a new 'editor' role? Add lines to the dataset, no new test functions needed.
Clear documentation: Each dataset entry documents expected behavior. The dataset becomes living documentation of your authorization rules.
Efficient execution: Run 22+ permission tests with a single test function. Pest executes the test function once for each dataset entry, giving you comprehensive coverage with minimal code.
The Mental Model Shift
Instead of thinking "I need to write a test for admin create permissions", you think "I need to define the permission matrix for all roles". This shift leads to more systematic testing and fewer missed edge cases.
๐๏ธ Database Testing: Ensuring Data Integrity
Beyond HTTP: Testing the Data Layer
API tests verify your endpoints work, but they don't guarantee your data layer is sound. You need to explicitly test that:
- Data actually gets saved to the database
- Sensitive information (like passwords) gets properly encrypted
- Database constraints are working correctly
- Your application doesn't leave orphaned records
The Trust-But-Verify Principle
Even if your HTTP response looks correct, you must verify the database state. Here's why this matters:
describe('User Creation Database', function () {
test('creates user in database with valid data', function () {
$response = $this->postJson('/api/users', [
'name' => 'John Doe',
'email' => 'john@example.com',
'password' => 'password123',
]);
$this->assertDatabaseHas('users', [
'name' => 'John Doe',
'email' => 'john@example.com',
]);
// Verify password is hashed
$user = User::where('email', 'john@example.com')->first();
expect($user->password)->not->toBe('password123');
expect(Hash::check('password123', $user->password))->toBeTrue();
});
test('increments user count in database', function () {
$initialCount = User::count();
$this->postJson('/api/users', [
'name' => 'Jane Doe',
'email' => 'jane@example.com',
'password' => 'password123',
]);
expect(User::count())->toBe($initialCount + 1);
});
});
๐ HTTP Response Testing: API Contract Verification
Your API is a Contract
When you publish an API endpoint, you're making promises to client applications. These promises include:
- What HTTP status codes you'll return in different scenarios
- What data structure your responses will have
- What sensitive information you'll never expose
- How error conditions will be communicated
Breaking Contracts = Breaking Client Applications
A change in your API response structure can break mobile apps, frontend applications, or third-party integrations. That's why we test the API contract explicitly:
Testing API responses ensures your API contract is reliable:
describe('User Creation HTTP Response', function () {
test('returns 201 status for successful creation', function () {
$response = $this->postJson('/api/users', [
'name' => 'John Doe',
'email' => 'john@example.com',
'password' => 'password123',
]);
$response->assertCreated();
$response->assertJson([
'name' => 'John Doe',
'email' => 'john@example.com',
]);
});
test('does not return password in JSON response', function () {
$response = $this->postJson('/api/users', [
'name' => 'John Doe',
'email' => 'john@example.com',
'password' => 'password123',
]);
$response->assertCreated();
$response->assertJsonMissing(['password']);
});
});
๐ฏ Key Testing Principles: The Philosophy Behind Our Approach
1. Organize Tests Logically - The Mental Load Principle
Use describe
blocks to group related tests because context switching is mentally expensive. When a developer opens a test file, they should immediately understand:
- What component is being tested
- What scenarios are covered
- How the tests relate to each other
- Validation tests together โ "I'm looking at input sanitization"
- Database operations together โ "I'm looking at persistence logic"
- HTTP response tests together โ "I'm looking at API contracts"
2. Use Descriptive Test Names - The Documentation Principle
Your test names should read like documentation because they often become the only documentation that stays up-to-date:
- โ
test('email must be unique')
โ Clear requirement - โ
test('email validation')
โ Vague, could mean anything
When tests fail in CI/CD, the failure message should tell you exactly what business rule broke.
3. Leverage Datasets for Complex Scenarios - The DRY Principle Applied Right
Don't repeat yourself, but repeat the right things. Datasets eliminate code duplication while improving coverage because they let you focus on defining scenarios rather than writing test infrastructure.
4. Test at the Right Level - The Pyramid Principle
- Unit tests for isolated logic (policies, services) โ Fast feedback on business logic
- Feature tests for HTTP endpoints and workflows โ Confidence in user-facing behavior
- Integration tests for database operations โ Trust in data persistence
Each level serves a different purpose and catches different types of bugs.
๐ Results: Comprehensive Coverage
Our test suite provides:
- 23 policy permission tests from a single dataset
- 9 validation tests covering all input scenarios
- 4 database integrity tests
- 4 HTTP response contract tests
Total: 40+ test assertions covering the complete user creation workflow.
๐ง Running the Tests
# Run all tests
./vendor/bin/pest
# Run specific test suites
./vendor/bin/pest tests/Unit/UserPolicyTest.php
./vendor/bin/pest tests/Feature/UserStoreAuthorizationTest.php
# Run with coverage
./vendor/bin/pest --coverage
๐ก Conclusion: The Testing Mindset
Why This Approach Works in Practice
Building comprehensive test suites isn't just about toolsโit's about mindset. Here's what we've learned:
1. Tests as Design Tools: Writing tests first forces you to think about edge cases, error conditions, and API contracts before you implement them.
2. Tests as Documentation: Our test names and dataset entries become the most accurate documentation of system behavior because they're verified every time the suite runs.
3. Tests as Safety Nets: Comprehensive coverage means you can refactor confidently, knowing that any breaking change will be caught immediately.
4. Tests as Team Communication: When a new developer joins the project, the test suite teaches them how the system should behave in every scenario.
The Pest Advantage
Pest PHP's expressive syntax and powerful features like datasets make it possible to build comprehensive test suites with minimal code duplication. But the real power isn't in the syntaxโit's in how Pest enables clearer thinking about test scenarios.
The dataset feature in particular represents a fundamental shift: instead of writing tests that happen to cover scenarios, you define scenarios and let Pest generate the tests. This leads to more systematic coverage and fewer blind spots.
The combination of unit tests, feature tests, and policy tests ensures that our user management system is robust, secure, and reliable. Whether you're testing validation logic, authorization rules, or API contracts, Pest provides the tools to write tests that are both comprehensive and enjoyable to maintain.
Top comments (0)