# SkillPatch skill: phpunit-skill

A comprehensive PHPUnit testing skill for PHP developers that covers core testing patterns including assertions, data providers, mocking, and test lifecycle management. It provides ready-to-use code snippets for writing unit tests with PHPUnit's TestCase framework. Also includes setup instructions, anti-patterns to avoid, and references to production-grade playbook patterns.

You (the agent) were given this URL and asked to use this skill. This is a **public** skill — no SkillPatch account, API key, or prior setup is required. Two ways to use it:

**1. Use it right now** — the skill's complete file tree (SKILL.md + all reference files) is inlined below; read `SKILL.md` first, then follow it, consulting the other files as it directs.

**2. Install the exact package onto disk** (recommended if you can run a shell — this reproduces the skill byte-for-byte, including any binary assets that can't be inlined):

```bash
mkdir -p .claude/skills/phpunit-skill
curl -sSL https://skillpatch.dev/install_skill/phpunit-skill | tar -xz -C .claude/skills/
```

(`.claude/skills/` is Claude Code's convention; use whatever directory your agent loads skills from.)


---

## Skill files (3)

- `SKILL.md`
- `reference/advanced-patterns.md`
- `reference/playbook.md`


### `SKILL.md`

````markdown
---
name: phpunit-skill
description: >
  Generates PHPUnit tests in PHP. Covers assertions, data providers, mocking,
  and test doubles. Use when user mentions "PHPUnit", "TestCase", "assertEquals",
  "PHP test". Triggers on: "PHPUnit", "TestCase PHP", "assertEquals PHP",
  "PHP unit test".
languages:
  - PHP
category: unit-testing
license: MIT
metadata:
  author: TestMu AI
  version: "1.0"
---

# PHPUnit Testing Skill

## Core Patterns

### Basic Test

```php
<?php
use PHPUnit\Framework\TestCase;

class CalculatorTest extends TestCase
{
    private Calculator $calculator;

    protected function setUp(): void
    {
        $this->calculator = new Calculator();
    }

    public function testAddition(): void
    {
        $this->assertEquals(5, $this->calculator->add(2, 3));
    }

    public function testDivideByZero(): void
    {
        $this->expectException(\DivisionByZeroError::class);
        $this->calculator->divide(10, 0);
    }

    public function testMultipleAssertions(): void
    {
        $this->assertSame(4, $this->calculator->add(2, 2));
        $this->assertSame(0, $this->calculator->subtract(2, 2));
        $this->assertSame(6, $this->calculator->multiply(2, 3));
    }
}
```

### Data Providers

```php
/**
 * @dataProvider additionProvider
 */
public function testAdd(int $a, int $b, int $expected): void
{
    $this->assertEquals($expected, $this->calculator->add($a, $b));
}

public static function additionProvider(): array
{
    return [
        'positive numbers' => [2, 3, 5],
        'negative numbers' => [-1, -1, -2],
        'zeros'            => [0, 0, 0],
        'mixed'            => [10, -5, 5],
    ];
}
```

### Assertions

```php
$this->assertEquals($expected, $actual);
$this->assertSame($expected, $actual);          // Strict type
$this->assertNotEquals($unexpected, $actual);
$this->assertTrue($condition);
$this->assertFalse($condition);
$this->assertNull($value);
$this->assertNotNull($value);
$this->assertCount(3, $array);
$this->assertContains('item', $array);
$this->assertArrayHasKey('key', $array);
$this->assertInstanceOf(MyClass::class, $obj);
$this->assertStringContainsString('sub', $string);
$this->assertMatchesRegularExpression('/\d+/', $string);
$this->assertEmpty($collection);
$this->assertGreaterThan(5, $value);
$this->assertJsonStringEqualsJsonString($expected, $actual);
```

### Mocking

```php
public function testCreateUser(): void
{
    $mockRepo = $this->createMock(UserRepository::class);
    $mockRepo->expects($this->once())
        ->method('save')
        ->with($this->isInstanceOf(User::class))
        ->willReturn(new User(1, 'Alice'));

    $mockEmail = $this->createMock(EmailService::class);
    $mockEmail->expects($this->once())
        ->method('sendWelcome')
        ->with('alice@test.com');

    $service = new UserService($mockRepo, $mockEmail);
    $result = $service->createUser('alice@test.com', 'Alice');

    $this->assertEquals(1, $result->getId());
}
```

### Lifecycle

```php
public static function setUpBeforeClass(): void { }   // Once before all
protected function setUp(): void { }                    // Before each test
protected function tearDown(): void { }                 // After each test
public static function tearDownAfterClass(): void { }  // Once after all
```

### Anti-Patterns

| Bad | Good | Why |
|-----|------|-----|
| `assertEquals` for strict | `assertSame` for type+value | Type coercion |
| No data providers | `@dataProvider` | DRY |
| Global state | `setUp()`/`tearDown()` | Isolation |
| No groups | `@group smoke` | Run subsets |

## Setup: `composer require --dev phpunit/phpunit`
## Run: `./vendor/bin/phpunit` or `./vendor/bin/phpunit --group smoke`
## Config: `phpunit.xml` with testsuites and coverage

## Deep Patterns

See `reference/playbook.md` for production-grade patterns:

| Section | What You Get |
|---------|-------------|
| §1 Project Setup | composer.json, phpunit.xml with suites, coverage config, project structure |
| §2 Test Patterns | Assertions, #[DataProvider], Generator yields, strict comparisons |
| §3 Mocking | createMock with callbacks, Mockery spies, consecutive returns |
| §4 Test Doubles | In-memory fakes, repository pattern, test helpers |
| §5 Faker & Fixtures | TestDataFactory with overrides, bulk generation |
| §6 Exception Testing | Detailed exception assertions, warning testing |
| §7 HTTP & API Testing | Symfony WebTestCase, auth, validation, pagination |
| §8 Database Testing | Transaction rollback, repository integration, Doctrine |
| §9 CI/CD Integration | GitHub Actions with MySQL/Redis, coverage thresholds |
| §10 Debugging Table | 12 common problems with causes and fixes |
| §11 Best Practices | 14-item production PHP testing checklist |

````


### `reference/advanced-patterns.md`

````markdown
# PHPUnit — Advanced Patterns & Playbook

## Data Providers & Mocking

```php
class UserServiceTest extends TestCase
{
    #[DataProvider('userDataProvider')]
    public function testCreateUser(string $name, string $email, bool $valid): void
    {
        if (!$valid) {
            $this->expectException(ValidationException::class);
        }
        $user = $this->service->create($name, $email);
        $this->assertEquals($name, $user->getName());
    }

    public static function userDataProvider(): array
    {
        return [
            'valid user' => ['Alice', 'alice@test.com', true],
            'empty name' => ['', 'test@test.com', false],
            'invalid email' => ['Bob', 'invalid', false],
        ];
    }

    // Mock with Prophecy
    public function testSendsNotification(): void
    {
        $repo = $this->createMock(UserRepository::class);
        $repo->expects($this->once())
            ->method('save')
            ->with($this->callback(fn(User $u) => $u->getName() === 'Alice'))
            ->willReturn(new User(1, 'Alice'));

        $mailer = $this->createMock(MailerInterface::class);
        $mailer->expects($this->once())
            ->method('send')
            ->with($this->stringContains('Welcome'));

        $service = new UserService($repo, $mailer);
        $service->register('Alice', 'alice@test.com');
    }

    // Consecutive returns
    public function testRetries(): void
    {
        $client = $this->createMock(HttpClient::class);
        $client->method('get')
            ->willReturnOnConsecutiveCalls(
                $this->throwException(new TimeoutException()),
                new Response(200, '{"ok":true}')
            );
        $result = (new ApiClient($client))->fetchWithRetry('/data');
        $this->assertTrue($result['ok']);
    }
}
```

## Database Testing

```php
class DatabaseTest extends TestCase
{
    use DatabaseTransactions;   // Laravel: auto-rollback
    // use RefreshDatabase;     // Laravel: full migration

    protected function setUp(): void
    {
        parent::setUp();
        $this->seed(UserSeeder::class);
    }

    public function testCreatesRecord(): void
    {
        $user = User::factory()->create(['name' => 'Alice']);
        $this->assertDatabaseHas('users', ['name' => 'Alice']);
        $user->delete();
        $this->assertDatabaseMissing('users', ['id' => $user->id]);
    }
}
```

## Custom Assertions

```php
trait ApiAssertions
{
    public function assertJsonStructure(array $structure, array $data): void
    {
        foreach ($structure as $key => $value) {
            if (is_array($value)) {
                $this->assertArrayHasKey(is_string($key) ? $key : $value, $data);
            } else {
                $this->assertArrayHasKey($value, $data);
            }
        }
    }

    public function assertApiSuccess($response): void
    {
        $this->assertEquals(200, $response->getStatusCode());
        $body = json_decode($response->getBody(), true);
        $this->assertTrue($body['success'] ?? false);
    }
}
```

## Configuration

```xml
<!-- phpunit.xml -->
<phpunit bootstrap="vendor/autoload.php" colors="true"
    stopOnFailure="false" cacheResult="true">
    <testsuites>
        <testsuite name="Unit"><directory>tests/Unit</directory></testsuite>
        <testsuite name="Integration"><directory>tests/Integration</directory></testsuite>
    </testsuites>
    <coverage>
        <include><directory suffix=".php">src</directory></include>
        <report><html outputDirectory="coverage"/><text outputFile="coverage.txt"/></report>
    </coverage>
    <php>
        <env name="APP_ENV" value="testing"/>
        <env name="DB_DATABASE" value="test_db"/>
    </php>
</phpunit>
```

## Anti-Patterns

- ❌ `$this->assertTrue($a === $b)` → use `$this->assertSame($a, $b)`
- ❌ `@depends` chains longer than 2 — fragile, hard to debug
- ❌ Mocking concrete classes — mock interfaces instead
- ❌ `echo` for debugging — use `$this->addWarning()` or assertions

````


### `reference/playbook.md`

````markdown
# PHPUnit — Advanced Playbook

## §1 Project Setup & Configuration

### composer.json (Test Dependencies)
```json
{
    "require-dev": {
        "phpunit/phpunit": "^11.0",
        "mockery/mockery": "^1.6",
        "fakerphp/faker": "^1.23",
        "phpstan/phpstan": "^1.10",
        "squizlabs/php_codesniffer": "^3.8",
        "symfony/http-client": "^7.0",
        "dms/phpunit-arraysubset-asserts": "^0.5"
    },
    "autoload": {
        "psr-4": { "App\\": "src/" }
    },
    "autoload-dev": {
        "psr-4": { "Tests\\": "tests/" }
    },
    "scripts": {
        "test": "phpunit",
        "test:coverage": "XDEBUG_MODE=coverage phpunit --coverage-html coverage",
        "test:filter": "phpunit --filter"
    }
}
```

### 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"
         failOnWarning="true"
         failOnRisky="true"
         cacheDirectory=".phpunit.cache"
         executionOrder="depends,defects"
         beStrictAboutCoverageMetadata="true"
         requireCoverageMetadata="false">

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

    <coverage>
        <report>
            <html outputDirectory="coverage"/>
            <clover outputFile="coverage/clover.xml"/>
            <text outputFile="php://stdout" showOnlySummary="true"/>
        </report>
    </coverage>

    <source>
        <include>
            <directory>src</directory>
        </include>
        <exclude>
            <directory>src/Migrations</directory>
        </exclude>
    </source>

    <php>
        <env name="APP_ENV" value="testing"/>
        <env name="DB_DATABASE" value="testing"/>
        <env name="CACHE_DRIVER" value="array"/>
    </php>
</phpunit>
```

### Project Structure
```
project/
├── phpunit.xml
├── composer.json
├── src/
│   ├── Entity/
│   ├── Repository/
│   ├── Service/
│   └── Controller/
├── tests/
│   ├── Unit/
│   │   ├── Entity/
│   │   ├── Service/
│   │   └── ValueObject/
│   ├── Feature/
│   │   ├── Api/
│   │   └── Controller/
│   ├── Integration/
│   │   ├── Repository/
│   │   └── ExternalApi/
│   ├── Fixtures/
│   │   └── UserFixture.php
│   └── TestCase.php           # Base test case
├── coverage/
└── .phpunit.cache/
```

---

## §2 Test Patterns — Assertions & Data Providers

### Comprehensive Assertions
```php
<?php

declare(strict_types=1);

namespace Tests\Unit\Entity;

use App\Entity\User;
use App\ValueObject\Email;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;

#[CoversClass(User::class)]
class UserTest extends TestCase
{
    #[Test]
    public function createsUserWithValidData(): void
    {
        $user = new User(
            name: 'Alice Smith',
            email: new Email('alice@example.com'),
            age: 30,
        );

        // Strict identity (===)
        $this->assertSame('Alice Smith', $user->getName());
        $this->assertSame(30, $user->getAge());

        // Type checks
        $this->assertInstanceOf(Email::class, $user->getEmail());

        // Boolean
        $this->assertTrue($user->isActive());
        $this->assertFalse($user->isAdmin());

        // Null checks
        $this->assertNull($user->getDeletedAt());
        $this->assertNotNull($user->getCreatedAt());

        // String assertions
        $this->assertStringStartsWith('usr_', $user->getId());
        $this->assertStringEndsWith('@example.com', (string) $user->getEmail());
        $this->assertStringContainsString('Alice', $user->getName());
        $this->assertMatchesRegularExpression('/^usr_[a-f0-9]{12}$/', $user->getId());

        // Numeric
        $this->assertGreaterThan(0, $user->getAge());
        $this->assertLessThanOrEqual(150, $user->getAge());

        // Array/Collection
        $this->assertCount(0, $user->getRoles());
        $this->assertEmpty($user->getRoles());
        $this->assertContains('ROLE_USER', $user->getDefaultRoles());
    }

    #[Test]
    #[DataProvider('invalidEmailProvider')]
    public function rejectsInvalidEmails(string $email, string $reason): void
    {
        $this->expectException(\InvalidArgumentException::class);
        new Email($email);
    }

    public static function invalidEmailProvider(): \Generator
    {
        yield 'empty string' => ['', 'cannot be empty'];
        yield 'no @ symbol' => ['invalid', 'missing @'];
        yield 'no domain' => ['user@', 'missing domain'];
        yield 'no local part' => ['@domain.com', 'missing local part'];
        yield 'spaces' => ['user @domain.com', 'contains spaces'];
        yield 'double dots' => ['user@domain..com', 'consecutive dots'];
    }

    #[Test]
    #[DataProvider('ageValidationProvider')]
    public function validatesAge(int $age, bool $shouldPass): void
    {
        if (!$shouldPass) {
            $this->expectException(\DomainException::class);
        }

        $user = new User(name: 'Test', email: new Email('t@t.com'), age: $age);

        if ($shouldPass) {
            $this->assertSame($age, $user->getAge());
        }
    }

    public static function ageValidationProvider(): array
    {
        return [
            'minimum valid' => [0, true],
            'typical age' => [30, true],
            'maximum valid' => [150, true],
            'negative' => [-1, false],
            'too old' => [151, false],
        ];
    }
}
```

---

## §3 Mocking — createMock, Mockery, Prophecy

### PHPUnit Native Mocks
```php
<?php

namespace Tests\Unit\Service;

use App\Entity\User;
use App\Repository\UserRepositoryInterface;
use App\Service\UserService;
use App\Service\EmailService;
use App\Event\UserCreatedEvent;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;

class UserServiceTest extends TestCase
{
    private UserRepositoryInterface&MockObject $repository;
    private EmailService&MockObject $emailService;
    private UserService $service;

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

    #[Test]
    public function createsAndPersistsUser(): void
    {
        // Expect save called once with a User argument
        $this->repository->expects($this->once())
            ->method('save')
            ->with($this->callback(function (User $user): bool {
                return $user->getName() === 'Alice'
                    && $user->getEmail()->toString() === 'alice@test.com';
            }))
            ->willReturnCallback(function (User $user): User {
                // Simulate DB assigning ID
                $reflection = new \ReflectionProperty($user, 'id');
                $reflection->setValue($user, 42);
                return $user;
            });

        // Expect welcome email sent
        $this->emailService->expects($this->once())
            ->method('sendWelcome')
            ->with($this->isInstanceOf(User::class));

        $user = $this->service->createUser('Alice', 'alice@test.com');
        $this->assertSame(42, $user->getId());
    }

    #[Test]
    public function throwsOnDuplicateEmail(): void
    {
        $this->repository->method('findByEmail')
            ->with('existing@test.com')
            ->willReturn(new User(name: 'Existing', email: 'existing@test.com'));

        $this->expectException(\DomainException::class);
        $this->expectExceptionMessage('Email already registered');

        $this->service->createUser('New User', 'existing@test.com');
    }

    #[Test]
    public function retriesOnTransientFailure(): void
    {
        $this->repository->expects($this->exactly(3))
            ->method('save')
            ->willReturnOnConsecutiveCalls(
                $this->throwException(new \RuntimeException('Connection lost')),
                $this->throwException(new \RuntimeException('Timeout')),
                $this->returnArgument(0),  // Third call succeeds
            );

        $user = $this->service->createUser('Alice', 'alice@test.com');
        $this->assertSame('Alice', $user->getName());
    }

    #[Test]
    public function listsUsersWithPagination(): void
    {
        $this->repository->method('findPaginated')
            ->with(
                $this->identicalTo(1),          // page
                $this->identicalTo(10),         // limit
                $this->stringContains('name'),  // sort field
            )
            ->willReturn([
                new User(name: 'Alice', email: 'a@t.com'),
                new User(name: 'Bob', email: 'b@t.com'),
            ]);

        $users = $this->service->listUsers(page: 1, limit: 10, sort: 'name_asc');
        $this->assertCount(2, $users);
    }
}
```

### Mockery Integration
```php
<?php

namespace Tests\Unit\Service;

use App\Service\PaymentGateway;
use App\Service\OrderService;
use Mockery;
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
use PHPUnit\Framework\TestCase;

class OrderServiceTest extends TestCase
{
    use MockeryPHPUnitIntegration;

    #[Test]
    public function processesPaymentWithRetry(): void
    {
        $gateway = Mockery::mock(PaymentGateway::class);
        $gateway->shouldReceive('charge')
            ->with(Mockery::on(fn($amount) => $amount > 0), Mockery::type('string'))
            ->once()
            ->andReturn(['id' => 'pay_123', 'status' => 'success']);

        $service = new OrderService($gateway);
        $result = $service->processOrder(orderId: 'ord_1', amount: 99.99);

        $this->assertSame('pay_123', $result->paymentId);
    }

    #[Test]
    public function spiesOnMethodCalls(): void
    {
        $logger = Mockery::spy(\Psr\Log\LoggerInterface::class);
        $service = new OrderService(logger: $logger);

        $service->processOrder('ord_1', 50.00);

        $logger->shouldHaveReceived('info')
            ->with(Mockery::pattern('/Order processed/'), Mockery::hasKey('orderId'))
            ->once();
    }
}
```

---

## §4 Test Doubles — Stubs, Fakes & In-Memory Implementations

```php
<?php

namespace Tests\Doubles;

use App\Repository\UserRepositoryInterface;
use App\Entity\User;

/**
 * In-memory fake for integration tests — no database needed.
 */
class InMemoryUserRepository implements UserRepositoryInterface
{
    /** @var array<int, User> */
    private array $users = [];
    private int $nextId = 1;

    public function save(User $user): User
    {
        if ($user->getId() === null) {
            $reflection = new \ReflectionProperty($user, 'id');
            $reflection->setValue($user, $this->nextId++);
        }
        $this->users[$user->getId()] = $user;
        return $user;
    }

    public function findById(int $id): ?User
    {
        return $this->users[$id] ?? null;
    }

    public function findByEmail(string $email): ?User
    {
        foreach ($this->users as $user) {
            if ($user->getEmail()->toString() === $email) {
                return $user;
            }
        }
        return null;
    }

    public function findAll(): array
    {
        return array_values($this->users);
    }

    public function delete(User $user): void
    {
        unset($this->users[$user->getId()]);
    }

    /** Test helper: reset state between tests. */
    public function clear(): void
    {
        $this->users = [];
        $this->nextId = 1;
    }
}
```

### Using Fakes in Tests
```php
<?php

namespace Tests\Integration\Service;

use App\Service\UserService;
use Tests\Doubles\InMemoryUserRepository;
use PHPUnit\Framework\TestCase;

class UserServiceIntegrationTest extends TestCase
{
    private InMemoryUserRepository $repository;
    private UserService $service;

    protected function setUp(): void
    {
        $this->repository = new InMemoryUserRepository();
        $this->service = new UserService($this->repository);
    }

    #[Test]
    public function fullUserLifecycle(): void
    {
        // Create
        $user = $this->service->createUser('Alice', 'alice@test.com');
        $this->assertSame(1, $user->getId());

        // Read
        $found = $this->service->findUser(1);
        $this->assertSame('Alice', $found->getName());

        // Update
        $this->service->updateUser(1, name: 'Alice Smith');
        $updated = $this->service->findUser(1);
        $this->assertSame('Alice Smith', $updated->getName());

        // Delete
        $this->service->deleteUser(1);
        $this->assertNull($this->repository->findById(1));
    }
}
```

---

## §5 Faker & Fixtures — Realistic Test Data

```php
<?php

namespace Tests\Fixtures;

use App\Entity\User;
use App\Entity\Order;
use App\ValueObject\Email;
use App\ValueObject\Money;
use Faker\Factory as FakerFactory;
use Faker\Generator;

class TestDataFactory
{
    private static ?Generator $faker = null;

    private static function faker(): Generator
    {
        return self::$faker ??= FakerFactory::create();
    }

    public static function createUser(array $overrides = []): User
    {
        $faker = self::faker();
        return new User(
            name: $overrides['name'] ?? $faker->name(),
            email: new Email($overrides['email'] ?? $faker->unique()->safeEmail()),
            age: $overrides['age'] ?? $faker->numberBetween(18, 80),
        );
    }

    public static function createOrder(array $overrides = []): Order
    {
        $faker = self::faker();
        return new Order(
            userId: $overrides['userId'] ?? $faker->randomNumber(5),
            items: $overrides['items'] ?? self::createOrderItems(rand(1, 5)),
            total: $overrides['total'] ?? new Money($faker->randomFloat(2, 10, 1000), 'USD'),
            status: $overrides['status'] ?? 'pending',
        );
    }

    /** @return list<OrderItem> */
    public static function createOrderItems(int $count = 3): array
    {
        $faker = self::faker();
        return array_map(fn() => new \App\Entity\OrderItem(
            productId: $faker->randomNumber(5),
            name: $faker->words(3, true),
            price: new Money($faker->randomFloat(2, 5, 500), 'USD'),
            quantity: $faker->numberBetween(1, 10),
        ), range(1, $count));
    }
}
```

### Using Factories in Tests
```php
<?php

namespace Tests\Unit\Service;

use Tests\Fixtures\TestDataFactory;
use PHPUnit\Framework\TestCase;

class OrderProcessingTest extends TestCase
{
    #[Test]
    public function calculatesOrderTotal(): void
    {
        $order = TestDataFactory::createOrder([
            'items' => [
                TestDataFactory::createOrderItem(['price' => new Money(10.00, 'USD'), 'quantity' => 2]),
                TestDataFactory::createOrderItem(['price' => new Money(25.00, 'USD'), 'quantity' => 1]),
            ],
        ]);

        $this->assertEquals(45.00, $order->calculateTotal()->amount());
    }

    #[Test]
    public function bulkUserCreation(): void
    {
        $users = array_map(fn() => TestDataFactory::createUser(), range(1, 50));
        $this->assertCount(50, $users);
        // All emails unique
        $emails = array_map(fn($u) => $u->getEmail()->toString(), $users);
        $this->assertCount(50, array_unique($emails));
    }
}
```

---

## §6 Exception & Error Testing

```php
<?php

namespace Tests\Unit\Service;

use App\Exception\InsufficientFundsException;
use App\Exception\AccountLockedException;
use App\Service\AccountService;
use PHPUnit\Framework\TestCase;

class ExceptionHandlingTest extends TestCase
{
    #[Test]
    public function throwsInsufficientFundsWithDetails(): void
    {
        $service = new AccountService();

        try {
            $service->withdraw(accountId: 1, amount: 1000.00);
            $this->fail('Expected InsufficientFundsException was not thrown');
        } catch (InsufficientFundsException $e) {
            $this->assertSame(1, $e->getAccountId());
            $this->assertSame(1000.00, $e->getRequestedAmount());
            $this->assertSame(500.00, $e->getAvailableBalance());
            $this->assertSame(
                'Insufficient funds: requested $1000.00, available $500.00',
                $e->getMessage()
            );
            $this->assertSame(422, $e->getCode());
        }
    }

    #[Test]
    public function wrapsExternalApiErrors(): void
    {
        $this->expectException(\App\Exception\ExternalServiceException::class);
        $this->expectExceptionMessageMatches('/Payment gateway.*timeout/i');

        $service = new AccountService(gateway: new TimeoutStubGateway());
        $service->processPayment(orderId: 'ord_1', amount: 50.00);
    }

    #[Test]
    public function warningTriggered(): void
    {
        $this->expectWarning();
        $this->expectWarningMessage('Deprecated method');
        $service = new AccountService();
        $service->legacyTransfer(from: 1, to: 2, amount: 100);
    }
}
```

---

## §7 HTTP & API Testing (Symfony / Laravel)

### Symfony WebTestCase
```php
<?php

namespace Tests\Feature\Api;

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Response;

class UserApiTest extends WebTestCase
{
    private $client;

    protected function setUp(): void
    {
        $this->client = static::createClient();
        // Reset database
        $this->loadFixtures();
    }

    #[Test]
    public function listUsersRequiresAuth(): void
    {
        $this->client->request('GET', '/api/users');
        $this->assertResponseStatusCodeSame(Response::HTTP_UNAUTHORIZED);
    }

    #[Test]
    public function createsUser(): void
    {
        $this->client->request('POST', '/api/users', [], [], [
            'CONTENT_TYPE' => 'application/json',
            'HTTP_AUTHORIZATION' => 'Bearer ' . $this->getAuthToken(),
        ], json_encode([
            'name' => 'Alice',
            'email' => 'alice@test.com',
            'role' => 'editor',
        ]));

        $this->assertResponseStatusCodeSame(Response::HTTP_CREATED);

        $data = json_decode($this->client->getResponse()->getContent(), true);
        $this->assertArrayHasKey('id', $data);
        $this->assertSame('Alice', $data['name']);
        $this->assertSame('alice@test.com', $data['email']);

        // Verify Location header
        $this->assertResponseHeaderSame(
            'Location',
            '/api/users/' . $data['id']
        );
    }

    #[Test]
    public function validatesRequiredFields(): void
    {
        $this->client->request('POST', '/api/users', [], [], [
            'CONTENT_TYPE' => 'application/json',
            'HTTP_AUTHORIZATION' => 'Bearer ' . $this->getAuthToken(),
        ], json_encode([]));

        $this->assertResponseStatusCodeSame(Response::HTTP_UNPROCESSABLE_ENTITY);
        $errors = json_decode($this->client->getResponse()->getContent(), true);
        $this->assertArrayHasKey('errors', $errors);
        $this->assertArrayHasKey('name', $errors['errors']);
        $this->assertArrayHasKey('email', $errors['errors']);
    }

    #[Test]
    public function paginatesResults(): void
    {
        // Seed 25 users
        $this->seedUsers(25);

        $this->client->request('GET', '/api/users?p
...<truncated>
````
