Help us improve
Share bugs, ideas, or general feedback.
From clarc
Provides PHP testing patterns for PHPUnit 11 mocks/data providers, Pest v3 expectations/datasets, Laravel feature/HTTP tests, Symfony WebTestCase, PHPStan static analysis, and Infection mutation testing.
npx claudepluginhub marvinrichter/clarc --plugin clarcHow this skill is triggered — by the user, by Claude, or both
Slash command
/clarc:php-testingThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
- Writing PHP tests with PHPUnit or Pest
PHPUnit testing framework conventions and practices. Invoke whenever task involves any interaction with PHPUnit — writing tests, configuring PHPUnit, data providers, mocking, assertions, debugging test failures, or coverage.
Writes PHPUnit tests for PHP code: unit tests, mocking, data providers, test doubles, assertions, and TDD practices. Use for testing PHP apps including Magento.
Write tests with Pest 3/PHPUnit, feature tests, unit tests, mocking, fakes, and factories. Use when testing controllers, services, models, or implementing TDD.
Share bugs, ideas, or general feedback.
<?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'],
];
}
}
<?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(): void
{
$this->users->method('findByEmail')->willReturn(null);
$this->hasher->method('hash')->willReturn('hashed_pw');
$this->users->expects($this->once())->method('save');
$this->events->expects($this->once())->method('dispatch')
->with($this->isInstanceOf(UserRegistered::class));
$user = $this->handler->handle(new RegisterUserCommand(
name: 'Alice',
email: 'alice@example.com',
password: 'secure_password_123',
));
$this->assertSame('alice@example.com', (string) $user->getEmail());
}
#[Test]
public function it_throws_on_duplicate_email(): void
{
$this->users->method('findByEmail')->willReturn(new User());
$this->expectException(DuplicateEmailException::class);
$this->handler->handle(new RegisterUserCommand(
name: 'Bob',
email: 'existing@example.com',
password: 'password_123',
));
}
}
<?php
use App\Domain\Email;
use App\Handler\RegisterUserHandler;
// describe + it grouping
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);
});
});
// Dataset (Pest equivalent of DataProvider)
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' => ['ALICE@EXAMPLE.COM', 'alice@example.com'],
]);
// Pest mock via mockery
it('dispatches event on registration', function () {
$users = mock(UserRepository::class)->allows('findByEmail')->andReturn(null)->allows('save');
$hasher = mock(PasswordHasher::class)->allows('hash')->andReturn('hash');
$events = mock(EventBus::class)->expects('dispatch')->once();
$handler = new RegisterUserHandler($users, $hasher, $events);
$handler->handle(new RegisterUserCommand('Alice', 'alice@example.com', 'pw'));
});
<?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']);
}
public function test_create_user_validates_email(): void
{
$response = $this->postJson('/api/users', [
'name' => 'Bob',
'email' => 'not-an-email',
]);
$response->assertUnprocessable()
->assertJsonValidationErrors(['email', 'password']);
}
public function test_duplicate_email_returns_422(): void
{
User::factory()->create(['email' => 'alice@example.com']);
$this->postJson('/api/users', [
'name' => 'Alice 2',
'email' => 'alice@example.com',
'password' => 'super_secure_pass',
'password_confirmation' => 'super_secure_pass',
])->assertUnprocessable();
}
}
<?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('alice@example.com', $data['email']);
}
}
vendor/bin/phpstan analyse src/ --level=9
phpstan.neon:
parameters:
level: 9
paths:
- src
checkMissingIterableValueType: true
checkGenericClassInNonGenericObjectType: true
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.
| 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 |