Files
ai-web/.agents/skills/php-pro/references/testing-quality.md
jiangdong.cheng aa16c9f8c2
Some checks failed
Tests / PHP 8.2 (push) Has been cancelled
Tests / PHP 8.3 (push) Has been cancelled
Tests / PHP 8.4 (push) Has been cancelled
init
2026-02-11 17:28:36 +08:00

11 KiB

Testing & Quality Assurance

PHPUnit with Strict Types

<?php

declare(strict_types=1);

namespace Tests\Unit\Service;

use App\Repository\UserRepositoryInterface;
use App\Service\UserService;
use App\Service\EmailService;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\MockObject\MockObject;

final class UserServiceTest extends TestCase
{
    private UserRepositoryInterface&MockObject $userRepository;
    private EmailService&MockObject $emailService;
    private UserService $userService;

    protected function setUp(): void
    {
        $this->userRepository = $this->createMock(UserRepositoryInterface::class);
        $this->emailService = $this->createMock(EmailService::class);
        $this->userService = new UserService(
            $this->userRepository,
            $this->emailService
        );
    }

    public function testCreateUserSuccessfully(): void
    {
        $email = 'test@example.com';
        $password = 'SecurePass123!';

        $this->userRepository
            ->expects($this->once())
            ->method('findByEmail')
            ->with($email)
            ->willReturn(null);

        $this->userRepository
            ->expects($this->once())
            ->method('create')
            ->willReturn($this->createUser($email));

        $this->emailService
            ->expects($this->once())
            ->method('sendWelcomeEmail');

        $user = $this->userService->createUser($email, $password);

        $this->assertSame($email, $user->email);
    }

    public function testCreateUserThrowsExceptionWhenEmailExists(): void
    {
        $this->expectException(\DomainException::class);
        $this->expectExceptionMessage('Email already exists');

        $this->userRepository
            ->method('findByEmail')
            ->willReturn($this->createUser('test@example.com'));

        $this->userService->createUser('test@example.com', 'password');
    }

    private function createUser(string $email): User
    {
        return new User(
            id: 1,
            email: $email,
            password: password_hash('password', PASSWORD_ARGON2ID),
        );
    }
}

Data Providers

<?php

declare(strict_types=1);

namespace Tests\Unit\Validator;

use App\Validator\EmailValidator;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

final class EmailValidatorTest extends TestCase
{
    #[Test]
    #[DataProvider('validEmailProvider')]
    public function itValidatesCorrectEmails(string $email): void
    {
        $validator = new EmailValidator();
        $this->assertTrue($validator->isValid($email));
    }

    #[Test]
    #[DataProvider('invalidEmailProvider')]
    public function itRejectsInvalidEmails(string $email): void
    {
        $validator = new EmailValidator();
        $this->assertFalse($validator->isValid($email));
    }

    public static function validEmailProvider(): array
    {
        return [
            ['user@example.com'],
            ['john.doe@company.co.uk'],
            ['test+filter@domain.org'],
        ];
    }

    public static function invalidEmailProvider(): array
    {
        return [
            ['invalid'],
            ['@example.com'],
            ['user@'],
            ['user space@example.com'],
        ];
    }
}

Laravel Feature Tests

<?php

declare(strict_types=1);

namespace Tests\Feature;

use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Tests\TestCase;

final class UserControllerTest extends TestCase
{
    use RefreshDatabase, WithFaker;

    public function testUserCanViewTheirProfile(): void
    {
        $user = User::factory()->create();

        $response = $this->actingAs($user)->get('/api/users/me');

        $response->assertOk()
            ->assertJson([
                'data' => [
                    'id' => $user->id,
                    'email' => $user->email,
                ],
            ]);
    }

    public function testUserCanUpdateTheirProfile(): void
    {
        $user = User::factory()->create();
        $newName = $this->faker->name();

        $response = $this->actingAs($user)->putJson('/api/users/me', [
            'name' => $newName,
        ]);

        $response->assertOk();

        $this->assertDatabaseHas('users', [
            'id' => $user->id,
            'name' => $newName,
        ]);
    }

    public function testUnauthorizedUserCannotAccessProfile(): void
    {
        $response = $this->getJson('/api/users/me');

        $response->assertUnauthorized();
    }

    public function testValidationFailsWithInvalidData(): void
    {
        $user = User::factory()->create();

        $response = $this->actingAs($user)->putJson('/api/users/me', [
            'email' => 'not-an-email',
        ]);

        $response->assertUnprocessable()
            ->assertJsonValidationErrors(['email']);
    }
}

Pest Testing (Modern Alternative)

<?php

declare(strict_types=1);

use App\Models\User;
use App\Services\UserService;

beforeEach(function () {
    $this->userService = app(UserService::class);
});

it('creates a user successfully', function () {
    $user = $this->userService->createUser(
        email: 'test@example.com',
        password: 'SecurePass123!'
    );

    expect($user)
        ->toBeInstanceOf(User::class)
        ->email->toBe('test@example.com');
});

it('validates email format', function (string $email, bool $valid) {
    $validator = new EmailValidator();

    expect($validator->isValid($email))->toBe($valid);
})->with([
    ['test@example.com', true],
    ['invalid', false],
    ['@example.com', false],
]);

test('authenticated user can view profile', function () {
    $user = User::factory()->create();

    $this->actingAs($user)
        ->get('/api/users/me')
        ->assertOk()
        ->assertJson(['data' => ['email' => $user->email]]);
});

test('guest cannot access protected routes', function () {
    $this->getJson('/api/users/me')
        ->assertUnauthorized();
});

PHPStan Configuration

# phpstan.neon
parameters:
    level: 9
    paths:
        - src
        - tests
    excludePaths:
        - src/bootstrap.php
        - vendor
    checkMissingIterableValueType: true
    checkGenericClassInNonGenericObjectType: true
    reportUnmatchedIgnoredErrors: true
    tmpDir: var/cache/phpstan

    ignoreErrors:
        # Ignore specific Laravel magic
        - '#Call to an undefined method Illuminate\\Database\\Eloquent\\Builder#'

    type_coverage:
        return_type: 100
        param_type: 100
        property_type: 100

includes:
    - vendor/phpstan/phpstan-strict-rules/rules.neon
    - vendor/phpstan/phpstan-deprecation-rules/rules.neon

PHPStan Annotations

<?php

declare(strict_types=1);

namespace App\Repository;

use App\Entity\User;
use Doctrine\ORM\EntityRepository;

/**
 * @extends EntityRepository<User>
 */
final class UserRepository extends EntityRepository
{
    /**
     * @return User[]
     */
    public function findActive(): array
    {
        return $this->createQueryBuilder('u')
            ->where('u.status = :status')
            ->setParameter('status', 'active')
            ->getQuery()
            ->getResult();
    }

    /**
     * @param int[] $ids
     * @return User[]
     */
    public function findByIds(array $ids): array
    {
        return $this->createQueryBuilder('u')
            ->where('u.id IN (:ids)')
            ->setParameter('ids', $ids)
            ->getQuery()
            ->getResult();
    }
}

/**
 * @template T
 */
final readonly class Result
{
    /**
     * @param T $data
     */
    public function __construct(
        public mixed $data,
        public bool $success,
    ) {}

    /**
     * @return T
     */
    public function getData(): mixed
    {
        return $this->data;
    }
}

Mockery (Advanced Mocking)

<?php

declare(strict_types=1);

namespace Tests\Unit\Service;

use App\Repository\UserRepository;
use App\Service\NotificationService;
use Mockery;
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
use PHPUnit\Framework\TestCase;

final class NotificationServiceTest extends TestCase
{
    use MockeryPHPUnitIntegration;

    public function testSendsNotificationToActiveUsers(): void
    {
        $repository = Mockery::mock(UserRepository::class);
        $repository->shouldReceive('findActive')
            ->once()
            ->andReturn([
                $this->createUser('user1@example.com'),
                $this->createUser('user2@example.com'),
            ]);

        $service = new NotificationService($repository);
        $result = $service->notifyActiveUsers('Important message');

        $this->assertSame(2, $result->count());
    }

    public function testHandlesEmailServiceFailure(): void
    {
        $emailService = Mockery::mock(EmailService::class);
        $emailService->shouldReceive('send')
            ->once()
            ->andThrow(new \RuntimeException('Email service down'));

        $service = new NotificationService($emailService);

        $this->expectException(\RuntimeException::class);
        $service->sendNotification('test@example.com', 'Hello');
    }

    private function createUser(string $email): User
    {
        return new User(id: 1, email: $email, password: 'hashed');
    }
}

Code Coverage

<!-- phpunit.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"
         failOnRisky="true"
         failOnWarning="true"
         stopOnFailure="false">
    <testsuites>
        <testsuite name="Unit">
            <directory>tests/Unit</directory>
        </testsuite>
        <testsuite name="Feature">
            <directory>tests/Feature</directory>
        </testsuite>
    </testsuites>
    <coverage>
        <include>
            <directory suffix=".php">src</directory>
        </include>
        <exclude>
            <directory>src/bootstrap</directory>
            <file>src/Kernel.php</file>
        </exclude>
        <report>
            <html outputDirectory="coverage/html"/>
            <clover outputFile="coverage/clover.xml"/>
        </report>
    </coverage>
    <php>
        <env name="APP_ENV" value="testing"/>
        <env name="DB_CONNECTION" value="sqlite"/>
        <env name="DB_DATABASE" value=":memory:"/>
    </php>
</phpunit>

Quick Reference

Tool Purpose Command
PHPUnit Unit/Feature tests ./vendor/bin/phpunit
Pest Modern testing ./vendor/bin/pest
PHPStan Static analysis ./vendor/bin/phpstan analyse
Psalm Alternative static analysis ./vendor/bin/psalm
PHP-CS-Fixer Code style ./vendor/bin/php-cs-fixer fix
PHPMD Mess detector ./vendor/bin/phpmd src text cleancode
Assertion PHPUnit Pest
Equality $this->assertSame() expect()->toBe()
Type $this->assertInstanceOf() expect()->toBeInstanceOf()
Array $this->assertContains() expect()->toContain()
Exception $this->expectException() expect()->toThrow()
Count $this->assertCount() expect()->toHaveCount()