| name | php-testing |
| description | PHP testing patterns: PHPUnit 11 with mocks and data providers, Pest v3 with expectations and datasets, Laravel feature/HTTP tests with RefreshDatabase, Symfony WebTestCase, PHPStan static analysis, Infection mutation testing. Use when writing or reviewing PHP tests. |
PHP Testing
When to Activate
- Writing PHP tests with PHPUnit or Pest
- Setting up Laravel/Symfony test suites
- Configuring PHPStan for static analysis
- Adding mutation testing with Infection
- Applying TDD layer by layer: domain value objects first, then application handlers, then infrastructure repositories, then HTTP controllers
- Verifying that test assertions are meaningful (not just coverage-padding) by running Infection mutation testing with an 80% MSI gate
- Choosing between PHPUnit data providers and Pest datasets to parameterize tests across multiple input variants
PHPUnit 11 — Unit Tests
<?php
declare(strict_types=1);
namespace Tests\Unit;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use App\Domain\Email;
class EmailTest extends TestCase
{
#[Test]
public function it_normalizes_email_to_lowercase(): void
{
$email = new Email('Alice@EXAMPLE.COM');
$this->assertSame('alice@example.com', $email->value);
}
#[Test]
public function it_throws_on_invalid_email(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Invalid email');
new Email('not-an-email');
}
#[Test]
#[DataProvider('validEmails')]
public function it_accepts_valid_email_formats(string $input, string $expected): void
{
$this->assertSame($expected, (new Email($input))->value);
}
public static function validEmails(): array
{
return [
'simple' => ['alice@example.com', 'alice@example.com'],
'mixed case' => ['ALICE@EXAMPLE.COM', 'alice@example.com'],
'subdomain' => ['alice@mail.example.com', 'alice@mail.example.com'],
];
}
}
Mocking with PHPUnit
<?php
declare(strict_types=1);
class RegisterUserHandlerTest extends TestCase
{
private UserRepository $users;
private PasswordHasher $hasher;
private EventBus $events;
private RegisterUserHandler $handler;
protected function setUp(): void
{
$this->users = $this->createMock(UserRepository::class);
$this->hasher = $this->createMock(PasswordHasher::class);
$this->events = $this->createMock(EventBus::class);
$this->handler = new RegisterUserHandler($this->users, $this->hasher, $this->events);
}
#[Test]
public function it_registers_a_new_user():
{
->users->()->();
->hasher->()->();
->users->(->())->();
->events->(->())->()
->(->(::));
= ->handler->( (
: ,
: ,
: ,
));
->(, () ->());
}
{
->users->()->( ());
->(::);
->handler->( (
: ,
: ,
: ,
));
}
}
Pest v3 — Expressive Syntax
<?php
use App\Domain\Email;
use App\Handler\RegisterUserHandler;
describe('Email', function () {
it('normalizes to lowercase', function () {
expect(new Email('ALICE@EXAMPLE.COM'))
->value->toBe('alice@example.com');
});
it('throws on invalid input', function () {
expect(fn () => new Email('bad'))->toThrow(\InvalidArgumentException::class);
});
});
it('accepts valid email formats', function (string $input, string $expected) {
expect((new Email($input))->value)->toBe($expected);
})->with([
'simple' => ['alice@example.com', 'alice@example.com'],
'mixed case' => [, ],
]);
(, function () {
= (::)->()->()->();
= (::)->()->();
= (::)->()->();
= (, , );
->( (, , ));
});
Laravel Feature Tests
<?php
declare(strict_types=1);
namespace Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class UserApiTest extends TestCase
{
use RefreshDatabase;
public function test_create_user_returns_201(): void
{
$response = $this->postJson('/api/users', [
'name' => 'Alice',
'email' => 'alice@example.com',
'password' => 'super_secure_pass',
'password_confirmation' => 'super_secure_pass',
]);
$response->assertStatus(201)
->assertJsonPath('data.email', 'alice@example.com');
$this->assertDatabaseHas('users', ['email' => 'alice@example.com']);
}
{
= ->(, [
=> ,
=> ,
]);
->()
->([, ]);
}
{
::()->([ => ]);
->(, [
=> ,
=> ,
=> ,
=> ,
])->();
}
}
Symfony WebTestCase
<?php
declare(strict_types=1);
namespace Tests\Controller;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class UserControllerTest extends WebTestCase
{
public function testRegisterUser(): void
{
$client = static::createClient();
$client->request('POST', '/users', [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode([
'name' => 'Alice',
'email' => 'alice@example.com',
'password' => 'super_secure_pass',
]));
$this->assertResponseStatusCodeSame(201);
$data = json_decode($client->getResponse()->getContent(), true);
$this->assertSame(, []);
}
}
PHPStan Static Analysis
vendor/bin/phpstan analyse src/ --level=9
phpstan.neon:
parameters:
level: 9
paths:
- src
checkMissingIterableValueType: true
checkGenericClassInNonGenericObjectType: true
Infection — Mutation Testing
Mutation testing verifies that tests actually catch bugs:
vendor/bin/infection --min-msi=80 --min-covered-msi=85
infection.json5:
{
"source": { "directories": ["src"] },
"minMsi": 80,
"minCoveredMsi": 85,
"testFramework": "phpunit"
}
High MSI (Mutation Score Indicator) confirms test assertions are meaningful, not just coverage-chasing.
Strategy: Layer by Layer
| Layer | Framework | Focus |
|---|
| Domain (pure PHP) | PHPUnit / Pest | Value objects, domain logic |
| Application (handlers) | PHPUnit with mocks | Command/query handlers |
| Infrastructure (DB) | TestCase + real DB | Repository implementations |
| HTTP (controllers) | Laravel HTTP tests / Symfony WebTestCase | Endpoints, validation, responses |