Skip to content

Testing Strategies

Build robust, maintainable test suites for your specifications. Learn comprehensive testing approaches, from unit tests to integration tests, with practical examples and best practices.

Testing Philosophy

Specifications are perfect candidates for testing because they:

  • Encapsulate single responsibilities - Easy to test in isolation
  • Have clear input/output - Boolean results are easy to assert
  • Are deterministic - Same input always produces same output
  • Are composable - Complex behavior emerges from simple parts

Unit Testing Fundamentals

Basic Specification Tests

Start with simple unit tests that verify specification behavior:

php
<?php

namespace Tests\Unit\Specifications;

use Tests\TestCase;
use App\Models\User;
use App\Specifications\User\ActiveUserSpecification;

class ActiveUserSpecificationTest extends TestCase
{
    private ActiveUserSpecification $specification;
    
    protected function setUp(): void
    {
        parent::setUp();
        $this->specification = new ActiveUserSpecification();
    }
    
    public function test_satisfied_by_active_user()
    {
        $user = new User(['status' => 'active']);
        
        $result = $this->specification->isSatisfiedBy($user);
        
        $this->assertTrue($result);
    }
    
    public function test_not_satisfied_by_inactive_user()
    {
        $user = new User(['status' => 'inactive']);
        
        $result = $this->specification->isSatisfiedBy($user);
        
        $this->assertFalse($result);
    }
    
    public function test_not_satisfied_by_null_status()
    {
        $user = new User(['status' => null]);
        
        $result = $this->specification->isSatisfiedBy($user);
        
        $this->assertFalse($result);
    }
    
    public function test_throws_exception_for_invalid_candidate()
    {
        $this->expectException(\InvalidArgumentException::class);
        
        $this->specification->isSatisfiedBy('not a user');
    }
}

Data Provider Testing

Use data providers to test multiple scenarios efficiently:

php
class UserAgeSpecificationTest extends TestCase
{
    /**
     * @dataProvider ageTestCases
     */
    public function test_age_requirements(int $age, bool $expectedResult)
    {
        $user = User::factory()->make(['birth_date' => now()->subYears($age)]);
        $specification = new MinimumAgeSpecification(18);
        
        $result = $specification->isSatisfiedBy($user);
        
        $this->assertEquals($expectedResult, $result);
    }
    
    public static function ageTestCases(): array
    {
        return [
            'under age' => [17, false],
            'exact age' => [18, true],
            'over age' => [25, true],
            'way over age' => [65, true],
            'edge case' => [0, false],
        ];
    }
    
    /**
     * @dataProvider invalidAgeTestCases  
     */
    public function test_handles_invalid_birth_dates($birthDate, bool $shouldThrowException)
    {
        $user = User::factory()->make(['birth_date' => $birthDate]);
        $specification = new MinimumAgeSpecification(18);
        
        if ($shouldThrowException) {
            $this->expectException(\InvalidArgumentException::class);
        }
        
        $result = $specification->isSatisfiedBy($user);
        
        if (!$shouldThrowException) {
            $this->assertIsBool($result);
        }
    }
    
    public static function invalidAgeTestCases(): array
    {
        return [
            'null birth date' => [null, true],
            'future birth date' => [now()->addYear(), true],
            'invalid date string' => ['invalid-date', true],
            'valid date string' => ['1990-01-01', false],
        ];
    }
}

Testing Composed Specifications

AND Composition Tests

php
class ComposedSpecificationTest extends TestCase
{
    public function test_and_composition_both_true()
    {
        $user = User::factory()->make([
            'status' => 'active',
            'email_verified_at' => now(),
        ]);
        
        $activeSpec = new ActiveUserSpecification();
        $verifiedSpec = new VerifiedUserSpecification();
        $composedSpec = $activeSpec->and($verifiedSpec);
        
        $this->assertTrue($composedSpec->isSatisfiedBy($user));
    }
    
    public function test_and_composition_first_false()
    {
        $user = User::factory()->make([
            'status' => 'inactive',
            'email_verified_at' => now(),
        ]);
        
        $activeSpec = new ActiveUserSpecification();
        $verifiedSpec = new VerifiedUserSpecification();
        $composedSpec = $activeSpec->and($verifiedSpec);
        
        $this->assertFalse($composedSpec->isSatisfiedBy($user));
    }
    
    public function test_and_composition_second_false()
    {
        $user = User::factory()->make([
            'status' => 'active',
            'email_verified_at' => null,
        ]);
        
        $activeSpec = new ActiveUserSpecification();
        $verifiedSpec = new VerifiedUserSpecification();
        $composedSpec = $activeSpec->and($verifiedSpec);
        
        $this->assertFalse($composedSpec->isSatisfiedBy($user));
    }
    
    public function test_and_composition_both_false()
    {
        $user = User::factory()->make([
            'status' => 'inactive',
            'email_verified_at' => null,
        ]);
        
        $activeSpec = new ActiveUserSpecification();
        $verifiedSpec = new VerifiedUserSpecification();
        $composedSpec = $activeSpec->and($verifiedSpec);
        
        $this->assertFalse($composedSpec->isSatisfiedBy($user));
    }
}

Truth Table Testing

Test all combinations systematically:

php
class BooleanAlgebraSpecificationTest extends TestCase
{
    /**
     * @dataProvider booleanCombinations
     */
    public function test_and_truth_table(bool $spec1Result, bool $spec2Result, bool $expectedResult)
    {
        $spec1 = Mockery::mock(SpecificationInterface::class);
        $spec1->shouldReceive('isSatisfiedBy')->andReturn($spec1Result);
        
        $spec2 = Mockery::mock(SpecificationInterface::class);
        $spec2->shouldReceive('isSatisfiedBy')->andReturn($spec2Result);
        
        $composedSpec = $spec1->and($spec2);
        $candidate = new stdClass();
        
        $result = $composedSpec->isSatisfiedBy($candidate);
        
        $this->assertEquals($expectedResult, $result);
    }
    
    public static function booleanCombinations(): array
    {
        return [
            'true AND true = true' => [true, true, true],
            'true AND false = false' => [true, false, false],
            'false AND true = false' => [false, true, false],
            'false AND false = false' => [false, false, false],
        ];
    }
    
    /**
     * @dataProvider orCombinations
     */
    public function test_or_truth_table(bool $spec1Result, bool $spec2Result, bool $expectedResult)
    {
        $spec1 = Mockery::mock(SpecificationInterface::class);
        $spec1->shouldReceive('isSatisfiedBy')->andReturn($spec1Result);
        
        $spec2 = Mockery::mock(SpecificationInterface::class);
        $spec2->shouldReceive('isSatisfiedBy')->andReturn($spec2Result);
        
        $composedSpec = $spec1->or($spec2);
        $candidate = new stdClass();
        
        $result = $composedSpec->isSatisfiedBy($candidate);
        
        $this->assertEquals($expectedResult, $result);
    }
    
    public static function orCombinations(): array
    {
        return [
            'true OR true = true' => [true, true, true],
            'true OR false = true' => [true, false, true],
            'false OR true = true' => [false, true, true],
            'false OR false = false' => [false, false, false],
        ];
    }
}

Database Integration Tests

Query Generation Tests

Test that specifications generate correct SQL:

php
use Illuminate\Foundation\Testing\RefreshDatabase;

class SpecificationQueryTest extends TestCase
{
    use RefreshDatabase;
    
    public function test_generates_correct_sql()
    {
        $specification = new ActiveUserSpecification();
        $query = User::whereSpecification($specification);
        
        $expectedSql = 'select * from "users" where "status" = ?';
        $actualSql = $query->toSql();
        
        $this->assertEquals($expectedSql, $actualSql);
        $this->assertEquals(['active'], $query->getBindings());
    }
    
    public function test_composed_specification_sql()
    {
        $specification = (new ActiveUserSpecification())
            ->and(new VerifiedUserSpecification());
            
        $query = User::whereSpecification($specification);
        
        $expectedSql = 'select * from "users" where "status" = ? and "email_verified_at" is not null';
        $this->assertEquals($expectedSql, $query->toSql());
        $this->assertEquals(['active'], $query->getBindings());
    }
    
    public function test_relationship_specification_sql()
    {
        $specification = new UserWithOrdersSpecification();
        $query = User::whereSpecification($specification);
        
        $sql = $query->toSql();
        $this->assertStringContainsString('exists', strtolower($sql));
        $this->assertStringContainsString('orders', $sql);
    }
}

Database Result Tests

Test actual database queries and results:

php
class SpecificationDatabaseTest extends TestCase
{
    use RefreshDatabase;
    
    public function test_returns_correct_users_from_database()
    {
        // Arrange
        $activeUser = User::factory()->create(['status' => 'active']);
        $inactiveUser = User::factory()->create(['status' => 'inactive']);
        $pendingUser = User::factory()->create(['status' => 'pending']);
        
        $specification = new ActiveUserSpecification();
        
        // Act
        $results = User::whereSpecification($specification)->get();
        
        // Assert
        $this->assertCount(1, $results);
        $this->assertTrue($results->contains($activeUser));
        $this->assertFalse($results->contains($inactiveUser));
        $this->assertFalse($results->contains($pendingUser));
    }
    
    public function test_complex_specification_database_results()
    {
        // Create test data
        $eligibleUser = User::factory()->create([
            'status' => 'active',
            'email_verified_at' => now(),
        ]);
        
        $inactiveUser = User::factory()->create([
            'status' => 'inactive',
            'email_verified_at' => now(),
        ]);
        
        $unverifiedUser = User::factory()->create([
            'status' => 'active',
            'email_verified_at' => null,
        ]);
        
        // Test composed specification
        $specification = (new ActiveUserSpecification())
            ->and(new VerifiedUserSpecification());
            
        $results = User::whereSpecification($specification)->get();
        
        $this->assertCount(1, $results);
        $this->assertTrue($results->contains($eligibleUser));
    }
    
    public function test_relationship_specification_database_results()
    {
        // User with orders
        $userWithOrders = User::factory()
            ->has(Order::factory()->count(3))
            ->create();
            
        // User without orders  
        $userWithoutOrders = User::factory()->create();
        
        $specification = new UserWithOrdersSpecification();
        $results = User::whereSpecification($specification)->get();
        
        $this->assertCount(1, $results);
        $this->assertTrue($results->contains($userWithOrders));
        $this->assertFalse($results->contains($userWithoutOrders));
    }
}

Performance Testing

Query Performance Tests

php
class SpecificationPerformanceTest extends TestCase
{
    use RefreshDatabase;
    
    public function test_specification_query_performance()
    {
        // Create large dataset
        User::factory()->count(5000)->create(['status' => 'active']);
        User::factory()->count(5000)->create(['status' => 'inactive']);
        
        $specification = new ActiveUserSpecification();
        
        // Measure execution time
        $start = microtime(true);
        $results = User::whereSpecification($specification)->get();
        $executionTime = microtime(true) - $start;
        
        // Performance assertions
        $this->assertLessThan(1.0, $executionTime, 'Query should execute in under 1 second');
        $this->assertCount(5000, $results);
    }
    
    public function test_n_plus_one_prevention()
    {
        // Create users with orders
        User::factory()
            ->count(10)
            ->has(Order::factory()->count(5))
            ->create(['status' => 'active']);
        
        // Enable query logging
        DB::enableQueryLog();
        
        $specification = new UserWithOrdersSpecification();
        $users = User::with('orders') // Eager load to prevent N+1
            ->whereSpecification($specification)
            ->get();
        
        // Access orders to trigger potential N+1 queries
        foreach ($users as $user) {
            $orderCount = $user->orders->count();
            $this->assertGreaterThan(0, $orderCount);
        }
        
        $queries = DB::getQueryLog();
        
        // Should only execute 1 query due to eager loading
        $this->assertLessThanOrEqual(1, count($queries));
        
        DB::disableQueryLog();
    }
}

Memory Usage Tests

php
class SpecificationMemoryTest extends TestCase
{
    public function test_memory_efficiency_with_large_collections()
    {
        $specification = new ActiveUserSpecification();
        
        // Create large collection in memory
        $users = User::factory()->count(1000)->make();
        
        $memoryBefore = memory_get_usage(true);
        
        // Filter collection
        $activeUsers = $users->filter(
            fn($user) => $specification->isSatisfiedBy($user)
        );
        
        $memoryAfter = memory_get_usage(true);
        $memoryUsed = $memoryAfter - $memoryBefore;
        
        // Memory should not increase significantly
        $this->assertLessThan(5 * 1024 * 1024, $memoryUsed, 'Should use less than 5MB');
    }
}

Testing Best Practices

Factory-Based Test Data

Create realistic test data with factories:

php
// In UserFactory
class UserFactory extends Factory
{
    public function active(): static
    {
        return $this->state(['status' => 'active']);
    }
    
    public function inactive(): static
    {
        return $this->state(['status' => 'inactive']);
    }
    
    public function verified(): static
    {
        return $this->state(['email_verified_at' => now()]);
    }
    
    public function unverified(): static
    {
        return $this->state(['email_verified_at' => null]);
    }
    
    public function premium(): static
    {
        return $this->state([
            'subscription_type' => 'premium',
            'subscription_expires_at' => now()->addYear(),
        ]);
    }
    
    public function withOrders(int $count = 3): static
    {
        return $this->has(Order::factory()->count($count));
    }
}

// In tests
public function test_premium_user_specification()
{
    $premiumUser = User::factory()->premium()->create();
    $regularUser = User::factory()->create();
    
    $specification = new PremiumUserSpecification();
    
    $this->assertTrue($specification->isSatisfiedBy($premiumUser));
    $this->assertFalse($specification->isSatisfiedBy($regularUser));
}

Mocking External Dependencies

Mock external services and dependencies:

php
class ExternalApiUserSpecification extends AbstractSpecification
{
    public function __construct(
        private readonly ExternalApiService $apiService
    ) {}
    
    public function isSatisfiedBy(mixed $candidate): bool
    {
        $apiResponse = $this->apiService->getUserStatus($candidate->id);
        return $apiResponse['status'] === 'verified';
    }
}

// Test with mocked service
class ExternalApiUserSpecificationTest extends TestCase
{
    public function test_verified_user_from_api()
    {
        // Mock external service
        $mockApiService = Mockery::mock(ExternalApiService::class);
        $mockApiService->shouldReceive('getUserStatus')
            ->with(123)
            ->once()
            ->andReturn(['status' => 'verified']);
        
        $user = User::factory()->make(['id' => 123]);
        $specification = new ExternalApiUserSpecification($mockApiService);
        
        $result = $specification->isSatisfiedBy($user);
        
        $this->assertTrue($result);
    }
    
    public function test_handles_api_failure()
    {
        $mockApiService = Mockery::mock(ExternalApiService::class);
        $mockApiService->shouldReceive('getUserStatus')
            ->andThrow(new ApiException('Service unavailable'));
        
        $user = User::factory()->make();
        $specification = new ExternalApiUserSpecification($mockApiService);
        
        // Should handle gracefully
        $this->expectException(ApiException::class);
        $specification->isSatisfiedBy($user);
    }
}

Edge Case Testing

Test boundary conditions and edge cases:

php
class EdgeCaseSpecificationTest extends TestCase
{
    /**
     * @dataProvider edgeCaseData
     */
    public function test_handles_edge_cases($inputValue, bool $expectedResult)
    {
        $entity = new stdClass();
        $entity->value = $inputValue;
        
        $specification = new PositiveNumberSpecification();
        
        $result = $specification->isSatisfiedBy($entity);
        
        $this->assertEquals($expectedResult, $result);
    }
    
    public static function edgeCaseData(): array
    {
        return [
            'zero' => [0, false],
            'positive integer' => [1, true],
            'negative integer' => [-1, false],
            'positive float' => [0.1, true],
            'negative float' => [-0.1, false],
            'very large number' => [PHP_INT_MAX, true],
            'very small number' => [PHP_INT_MIN, false],
            'infinity' => [INF, true],
            'negative infinity' => [-INF, false],
            'NaN' => [NAN, false],
            'null' => [null, false],
            'string number' => ['123', false], // Should only accept numeric types
            'empty string' => ['', false],
            'boolean true' => [true, false],
            'boolean false' => [false, false],
        ];
    }
}

Test Organization

Test Structure

Organize tests logically:

tests/
├── Unit/
│   ├── Specifications/
│   │   ├── User/
│   │   │   ├── ActiveUserSpecificationTest.php
│   │   │   ├── PremiumUserSpecificationTest.php
│   │   │   └── VerifiedUserSpecificationTest.php
│   │   ├── Product/
│   │   │   ├── InStockProductSpecificationTest.php
│   │   │   └── PublishedProductSpecificationTest.php
│   │   └── ComposedSpecificationTest.php
├── Feature/
│   ├── SpecificationIntegrationTest.php
│   └── SpecificationPerformanceTest.php
└── Helpers/
    └── SpecificationTestHelpers.php

Test Helpers

Create reusable test utilities:

php
// tests/Helpers/SpecificationTestHelpers.php
trait SpecificationTestHelpers
{
    protected function assertSpecificationSatisfied(
        SpecificationInterface $specification,
        $candidate,
        string $message = ''
    ): void {
        $this->assertTrue(
            $specification->isSatisfiedBy($candidate),
            $message ?: 'Specification should be satisfied'
        );
    }
    
    protected function assertSpecificationNotSatisfied(
        SpecificationInterface $specification,
        $candidate,
        string $message = ''
    ): void {
        $this->assertFalse(
            $specification->isSatisfiedBy($candidate),
            $message ?: 'Specification should not be satisfied'
        );
    }
    
    protected function assertSpecificationGeneratesSQL(
        SpecificationInterface $specification,
        string $expectedSql,
        array $expectedBindings = []
    ): void {
        $query = User::whereSpecification($specification);
        
        $this->assertEquals($expectedSql, $query->toSql());
        $this->assertEquals($expectedBindings, $query->getBindings());
    }
    
    protected function createSpecificationTestScenario(
        array $trueScenarios,
        array $falseScenarios,
        SpecificationInterface $specification
    ): void {
        foreach ($trueScenarios as $scenario) {
            $this->assertSpecificationSatisfied($specification, $scenario);
        }
        
        foreach ($falseScenarios as $scenario) {
            $this->assertSpecificationNotSatisfied($specification, $scenario);
        }
    }
}

// Usage in test classes
class ActiveUserSpecificationTest extends TestCase
{
    use SpecificationTestHelpers;
    
    public function test_various_scenarios()
    {
        $specification = new ActiveUserSpecification();
        
        $this->createSpecificationTestScenario(
            trueScenarios: [
                User::factory()->make(['status' => 'active']),
                User::factory()->make(['status' => 'ACTIVE']), // Case insensitive
            ],
            falseScenarios: [
                User::factory()->make(['status' => 'inactive']),
                User::factory()->make(['status' => 'pending']),
                User::factory()->make(['status' => null]),
            ],
            specification: $specification
        );
    }
}

Test Documentation

Document test scenarios clearly:

php
class UserEligibilitySpecificationTest extends TestCase
{
    /**
     * Test that users are eligible for discount when they meet all criteria:
     * - Active status
     * - Email verified
     * - Member for at least 30 days
     * - Made at least 1 purchase
     */
    public function test_eligible_user_meets_all_criteria()
    {
        // Given: A user that meets all discount criteria
        $user = User::factory()->create([
            'status' => 'active',
            'email_verified_at' => now(),
            'created_at' => now()->subDays(45), // Member for 45 days
        ]);
        Order::factory()->create(['user_id' => $user->id]); // Has made a purchase
        
        $specification = new DiscountEligibleUserSpecification();
        
        // When: Checking if user satisfies specification
        $result = $specification->isSatisfiedBy($user);
        
        // Then: User should be eligible
        $this->assertTrue($result);
    }
    
    /**
     * Test that users are not eligible when they fail any single criterion
     * 
     * @dataProvider ineligibleUserScenarios
     */
    public function test_ineligible_users(array $userAttributes, string $reason)
    {
        $user = User::factory()->create($userAttributes);
        $specification = new DiscountEligibleUserSpecification();
        
        $result = $specification->isSatisfiedBy($user);
        
        $this->assertFalse($result, "User should not be eligible: {$reason}");
    }
    
    public static function ineligibleUserScenarios(): array
    {
        return [
            'inactive user' => [
                ['status' => 'inactive', 'email_verified_at' => now()],
                'inactive status'
            ],
            'unverified user' => [
                ['status' => 'active', 'email_verified_at' => null],
                'email not verified'
            ],
            'new member' => [
                ['status' => 'active', 'email_verified_at' => now(), 'created_at' => now()->subDays(15)],
                'member for less than 30 days'
            ],
            // Note: User without purchases tested separately with factories
        ];
    }
}

Continuous Integration

PHPUnit Configuration

Configure comprehensive testing in phpunit.xml:

xml
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="./vendor/phpunit/phpunit/phpunit.xsd"
         bootstrap="vendor/autoload.php"
         colors="true">
    <testsuites>
        <testsuite name="Unit">
            <directory suffix="Test.php">./tests/Unit</directory>
        </testsuite>
        <testsuite name="Feature">
            <directory suffix="Test.php">./tests/Feature</directory>
        </testsuite>
        <testsuite name="Specifications">
            <directory suffix="Test.php">./tests/Unit/Specifications</directory>
        </testsuite>
    </testsuites>
    <coverage>
        <include>
            <directory suffix=".php">./app/Specifications</directory>
        </include>
    </coverage>
    <php>
        <env name="APP_ENV" value="testing"/>
        <env name="BCRYPT_ROUNDS" value="4"/>
        <env name="CACHE_DRIVER" value="array"/>
        <env name="DB_CONNECTION" value="sqlite"/>
        <env name="DB_DATABASE" value=":memory:"/>
        <env name="MAIL_MAILER" value="array"/>
        <env name="QUEUE_CONNECTION" value="sync"/>
        <env name="SESSION_DRIVER" value="array"/>
    </php>
</phpunit>

GitHub Actions Workflow

yaml
# .github/workflows/specifications-tests.yml
name: Specification Tests

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    
    strategy:
      matrix:
        php-version: [8.0, 8.1, 8.2]
        
    steps:
    - uses: actions/checkout@v3
    
    - name: Setup PHP
      uses: shivammathur/setup-php@v2
      with:
        php-version: ${{ matrix.php-version }}
        extensions: mbstring, dom, fileinfo, mysql
        coverage: xdebug
        
    - name: Install Dependencies
      run: composer install --prefer-dist --no-progress
      
    - name: Run Specification Tests
      run: vendor/bin/phpunit --testsuite=Specifications --coverage-text
      
    - name: Run All Tests
      run: vendor/bin/phpunit --coverage-clover=coverage.xml
      
    - name: Upload Coverage
      uses: codecov/codecov-action@v3
      with:
        file: ./coverage.xml

Next Steps

With robust testing strategies in place, learn how to optimize specification performance:


Testing Excellence

Comprehensive testing makes specifications reliable and maintainable. Test both the happy path and edge cases, and always verify that your specifications work correctly in both memory and database contexts.

Next: Performance & Caching →

Released under the MIT License.