SKILL.md

GitHub

PHPUnit

Test behavior, not implementation. Tests are executable documentation — if the test name doesn’t explain what the code does, rewrite it.

PHPUnit is PHP’s standard testing framework. It uses test case classes extending TestCase, setUp()/tearDown() for fixtures, and a full assertion API. All patterns target PHPUnit 11+ on PHP 8.5+. Use PHP 8 attributes exclusively — annotations are deprecated in 11, removed in 12.

References

  • Assertion catalog, constraints, exception expectations → [${CLAUDE_SKILL_DIR}/references/assertions.md] — Full assertion API grouped by category, constraint system, custom assertions
  • Test doubles — stubs, mocks, MockBuilder → [${CLAUDE_SKILL_DIR}/references/mocking.md] — createStub vs createMock, return config, invocation matchers, argument constraints, MockBuilder
  • Data providers — static, named, generators → [${CLAUDE_SKILL_DIR}/references/data-providers.md] — #[DataProvider], #[TestWith], named datasets, generator providers, external providers
  • phpunit.xml structure, test suites, source config → [${CLAUDE_SKILL_DIR}/references/configuration.md] — XML elements, strict settings, source element, coverage reports, execution order

Test Structure

Discovery and Naming

  • Files: *Test.php in configured test directories. Mirror source structure: src/Service/PaymentService.phptests/Unit/Service/PaymentServiceTest.php.
  • Classes: final class PaymentServiceTest extends TestCase. Always final.
  • Methods: test prefix or #[Test] attribute. Describe the behavior: testReturnsEmptyCollectionWhenNoResults not testSearch.
  • One test class per production class. Split into Unit/Integration directories.

Arrange-Act-Assert

Structure every test in three phases:

public function testUserCreationSetsDefaults(): void
{
    // Arrange
    $data = ['name' => 'Alice', 'email' => 'alice@example.com'];

    // Act
    $user = User::fromArray($data);

    // Assert
    $this->assertSame('Alice', $user->getName());
    $this->assertTrue($user->isActive());
    $this->assertSame([], $user->getRoles());
}
  • One act per test. If you need multiple acts, write multiple tests.
  • Comments optional when phases are obvious. Add them when the test is long enough that phases aren’t immediately clear.

Test Granularity

  • One concept per test. Multiple assertions are fine when they verify the same behavior. Separate tests when behaviors are independent.
  • Fast by default. Unit tests should run in milliseconds. Gate slow tests behind groups: #[Group('slow')].
  • Isolation is mandatory. Tests must not depend on execution order or shared mutable state. Each test sets up its own world.

Fixtures

setUp() / tearDown()

  • setUp() runs before each test method on a fresh instance. Create the SUT and its stubs here.
  • tearDown() runs after each test. Only needed for external resources (files, sockets, DB connections). Not needed for plain object cleanup.
  • setUpBeforeClass() / tearDownAfterClass() run once per class. Use for expensive shared resources (DB connections). Store in static properties.
final class PaymentServiceTest extends TestCase
{
    private PaymentService $service;
    private Gateway&Stub $gateway;

    protected function setUp(): void
    {
        $this->gateway = $this->createStub(Gateway::class);
        $this->service = new PaymentService($this->gateway);
    }
}

Fixture Lifecycle

  • setUpBeforeClass() — Class scope; once before first test

  • setUp() — Method scope; before each test

  • assertPreConditions() — Method scope; after setUp, before test

  • assertPostConditions() — Method scope; after test, before tearDown

  • tearDown() — Method scope; after each test

  • tearDownAfterClass() — Class scope; once after last test

  • Call parent::setUp() when extending abstract test cases — otherwise parent fixture setup is silently skipped.

  • Use #[Before] / #[After] attributes when multiple setup methods are needed (avoids fragile parent::setUp() chains).

Data Providers

Basic Usage

use PHPUnit\Framework\Attributes\DataProvider;

#[DataProvider('additionCases')]
public function testAdd(int $a, int $b, int $expected): void
{
    $this->assertSame($expected, $a + $b);
}

public static function additionCases(): array
{
    return [
        'zeros'        => [0, 0, 0],
        'positive sum' => [1, 2, 3],
        'negative'     => [-1, 1, 0],
    ];
}
  • Providers must be public static. Non-static providers are removed in PHPUnit 11.
  • Always use named datasets — string keys produce readable failure output.
  • Use #[DataProvider] attribute, not @dataProvider annotation.

Inline Data

For small, simple datasets — no provider method needed:

use PHPUnit\Framework\Attributes\TestWith;

#[TestWith([0, 0, 0])]
#[TestWith([1, 2, 3])]
#[TestWith([-1, 1, 0])]
public function testAdd(int $a, int $b, int $expected): void
{
    $this->assertSame($expected, $a + $b);
}

Generator Providers

For large or computed datasets:

public static function boundaryCases(): Generator
{
    yield 'min int' => [PHP_INT_MIN, 0, PHP_INT_MIN];
    yield 'max int' => [PHP_INT_MAX, 0, PHP_INT_MAX];
}

Provider Rules

  • Data must be scalar or immutable — no service objects or complex graphs in providers.
  • No mock objects in providers — framework isn’t initialized during provider execution.
  • Empty providers are forbidden in PHPUnit 11 — throws InvalidDataProviderException.
  • Multiple providers can be stacked on one test method — datasets are combined.

See ${CLAUDE_SKILL_DIR}/references/data-providers.md for external providers, TestDox integration, and edge cases.

Assertions

Core Assertions

$this->assertSame($expected, $actual);        // Strict === (preferred)
$this->assertEquals($expected, $actual);       // Loose == (use sparingly)
$this->assertTrue($condition);
$this->assertFalse($condition);
$this->assertNull($value);
$this->assertInstanceOf(Expected::class, $obj);
$this->assertCount(3, $collection);
$this->assertEmpty($collection);
$this->assertArrayHasKey('key', $array);
$this->assertContains($needle, $haystack);     // Strict comparison
  • Prefer assertSame() over assertEquals() — strict type comparison catches more bugs.
  • Multiple assertions per test are fine when they verify the same behavior.

String Assertions

$this->assertStringStartsWith('Error:', $message);
$this->assertStringEndsWith('.php', $filename);
$this->assertStringContainsString('needle', $haystack);
$this->assertMatchesRegularExpression('/^\d{4}-\d{2}$/', $date);

Float Comparison

$this->assertEqualsWithDelta(3.14, $result, 0.01);

Exception Testing

public function testThrowsOnInvalidInput(): void
{
    $this->expectException(InvalidArgumentException::class);
    $this->expectExceptionMessage('must be positive');

    $calculator->divide(1, 0);
}
  • Call expectException() before the throwing code — it sets up the expectation.
  • Use expectExceptionMessage() when the exception type is broad — validates the message contains the substring.
  • Use expectExceptionMessageMatches() for regex matching.

Deprecation / Error Expectations

public function testTriggersDeprecation(): void
{
    $this->expectUserDeprecationMessage('use newMethod() instead');

    $service->oldMethod();
}

See ${CLAUDE_SKILL_DIR}/references/assertions.md for the full assertion catalog, constraint system, and format string assertions.

Mocking

Stubs vs Mocks

  • Stub (createStub()) — controls return values. No call verification.
  • Mock (createMock()) — verifies interactions (method called, arguments matched).

Use stubs by default. Use mocks only when verifying that a side effect occurred.

Creating Stubs

$repo = $this->createStub(UserRepository::class);
$repo->method('find')->willReturn(new User(name: 'Alice'));

$service = new UserService($repo);
$result = $service->getUser(1);

$this->assertSame('Alice', $result->name);

Shorthand for multiple methods:

$repo = $this->createConfiguredStub(UserRepository::class, [
    'find'   => new User(name: 'Alice'),
    'exists' => true,
]);

Creating Mocks

$logger = $this->createMock(Logger::class);
$logger->expects($this->once())
    ->method('error')
    ->with($this->stringContains('payment failed'));

$service = new PaymentService($logger);
$service->process($invalidPayment);

Return Value Configuration

$stub->method('fetch')->willReturn('value');              // Fixed value
$stub->method('fetch')->willReturn('a', 'b', 'c');       // Consecutive values
$stub->method('fetch')->willReturnArgument(0);            // Return first arg
$stub->method('fetch')->willReturnSelf();                 // Fluent interface
$stub->method('fetch')->willReturnCallback(fn ($id) => "item-{$id}");
$stub->method('fetch')->willThrowException(new RuntimeException('fail'));
$stub->method('fetch')->willReturnMap([
    ['key1', 'value1'],
    ['key2', 'value2'],
]);

Mocking Rules

  • Mock at boundaries. Mock external services, databases, filesystems, clocks — not internal functions.
  • Don’t mock what you own when a fake or in-memory implementation is available.
  • Prefer dependency injection over complex mock setup. Pass collaborators as constructor parameters, stub in tests.
  • Never mock the thing you’re testing. If you need to mock part of the SUT, the SUT has too many responsibilities — split it.
  • Favour interfaces over classes for test doubles — fewer limitations, better design.
  • Do not call expects() on stubs — deprecated in 11, error in 12.

See ${CLAUDE_SKILL_DIR}/references/mocking.md for MockBuilder, intersection types, invocation matchers, and PHP 8.4 property hooks.

Attributes

PHPUnit 11 uses PHP 8 attributes exclusively. All attributes are in the PHPUnit\Framework\Attributes namespace.

Test Metadata

  • #[Test] — Mark non-test* method as a test
  • #[DataProvider('method')] — Connect a data provider
  • #[DataProviderExternal(Class::class, 'method')] — External data provider
  • #[TestWith([args])] — Inline data provider
  • #[TestDox('description')] — Custom TestDox description
  • #[Depends('testMethod')] — Declare test dependency
  • #[Group('name')] — Assign to group
  • #[Ticket('PROJ-123')] — Link to issue tracker

Skip / Conditional

  • #[RequiresPhp('>= 8.4')] — Skip if PHP version doesn’t match
  • #[RequiresPhpExtension('pdo_pgsql')] — Skip if extension missing
  • #[RequiresOperatingSystemFamily('Linux')] — Skip on other OS
  • #[RequiresFunction('sodium_crypto_sign')] — Skip if function missing
  • #[RequiresMethod(PDO::class, 'sqliteCreateFunction')] — Skip if method missing

Coverage

  • #[CoversClass(ClassName::class)] — Test covers this class
  • #[CoversFunction('functionName')] — Test covers this function
  • #[CoversMethod(ClassName::class, 'method')] — Test covers this method
  • #[CoversNothing] — Test contributes no coverage (integration tests)
  • #[UsesClass(ClassName::class)] — Allowed but not covered dependency
  • #[UsesFunction('functionName')] — Allowed but not covered function

Fixture

  • #[Before] — Run method before each test (alternative to setUp)
  • #[After] — Run method after each test (alternative to tearDown)
  • #[BeforeClass] — Run static method before first test
  • #[AfterClass] — Run static method after last test
  • #[BackupGlobals(true)] — Backup/restore globals for this test
  • #[BackupStaticProperties(true)] — Backup/restore static properties

Test Behavior

  • #[DoesNotPerformAssertions] — Suppress risky test warning
  • #[RunInSeparateProcess] — Isolate in separate PHP process
  • #[RunTestsInSeparateProcesses] — All tests in class run isolated
  • #[Small] / #[Medium] / #[Large] — Time limit enforcement (1s/10s/60s)

Test Organization

Directory Structure

tests/
├── Unit/                     # Fast, isolated, no I/O
│   ├── Service/
│   │   └── PaymentServiceTest.php
│   └── Model/
│       └── UserTest.php
├── Integration/              # Real dependencies, slower
│   └── Repository/
│       └── UserRepositoryTest.php
└── bootstrap.php             # Autoloader for tests
  • Mirror source directory structure under tests/Unit/ and tests/Integration/.
  • Unit tests — no database, no filesystem, no network. Mock all boundaries.
  • Integration tests — real dependencies. Mark with #[CoversNothing] to avoid polluting coverage metrics.

Test Suites

Define in phpunit.xml for selective execution:

<testsuites>
    <testsuite name="unit">
        <directory>tests/Unit</directory>
    </testsuite>
    <testsuite name="integration">
        <directory>tests/Integration</directory>
    </testsuite>
</testsuites>

Run subsets: phpunit --testsuite unit, phpunit --group slow.

Configuration

<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/11.5/phpunit.xsd"
         bootstrap="vendor/autoload.php"
         colors="true"
         cacheDirectory=".phpunit.cache"
         executionOrder="depends,random"
         beStrictAboutTestsThatDoNotTestAnything="true"
         beStrictAboutOutputDuringTests="true"
         failOnWarning="true"
         failOnRisky="true"
         failOnDeprecation="true"
         failOnNotice="true">
    <testsuites>
        <testsuite name="unit">
            <directory>tests/Unit</directory>
        </testsuite>
        <testsuite name="integration">
            <directory>tests/Integration</directory>
        </testsuite>
    </testsuites>
    <source restrictDeprecations="true"
            restrictNotices="true"
            restrictWarnings="true">
        <include>
            <directory suffix=".php">src</directory>
        </include>
    </source>
</phpunit>

Key Configuration Choices

  • executionOrder="depends,random" — randomize test order to catch hidden dependencies while respecting explicit #[Depends].
  • beStrictAboutTestsThatDoNotTestAnything="true" — flag tests without assertions as risky.
  • failOnDeprecation="true" — catch deprecations from your code early.
  • <source> with restrictDeprecations — only surface issues from your code, not vendor dependencies.
  • cacheDirectory — add .phpunit.cache to .gitignore.

Code Coverage

Requires PCOV or Xdebug extension:

phpunit --coverage-html build/coverage --coverage-clover build/clover.xml

Use #[CoversClass] and #[UsesClass] attributes to target coverage precisely. With beStrictAboutCoverageMetadata="true", tests without coverage attributes are risky.

See ${CLAUDE_SKILL_DIR}/references/configuration.md for the full XML reference, coverage report types, and execution order options.

Application

When writing tests: apply all conventions silently — don’t narrate each rule being followed. Match the project’s existing test style. If an existing codebase contradicts a convention, follow the codebase and flag the divergence once.

When reviewing tests: cite the specific issue and show the fix inline. Don’t lecture — state what’s wrong and how to fix it.

Bad:  "According to PHPUnit best practices, you should use createStub
       instead of createMock when you don't need expectations..."
Good: "createMock → createStub (no expects() call, stub is sufficient)"

Integration

The php skill governs language choices; this skill governs PHPUnit testing decisions. The coding skill governs workflow (discovery, planning, verification).

Test behavior, not implementation. When in doubt, mock less.

Skill details

Repository

xobotyi/cc-foundry

Repo Stars

17

Last Updated

Jun 30, 2026

License

MIT

Related skills

AsyrafHussin GitHub avatar

PHP best practices

AsyrafHussin

Modern PHP 8.x patterns, PSR standards, type system best practices, and SOLID principles. Contains 51 rules for writing clean, maintainable PHP code.

49
mindrally GitHub avatar

PHP development

mindrally

Expert guidance for PHP 8+ development with SOLID principles, PSR standards, and modern best practices.

168