# PHP Testing

> When to activate: PHPUnit, Pest, PHP testing, data providers, mocking, test doubles, Laravel testing, RefreshDatabase, feature tests, HTTP fake, snapshot testing, mutation testing

- Skill: `mattakushi432/php-testing` (Agent Skill)
- Install (CLI): `npx skillmds@latest add mattakushi432/php-testing`
- Raw SKILL.md: https://api.skillmd.com/api/skills/mattakushi432/php-testing/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: Mattakushi432 (https://skillmd.com/u/mattakushi432)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/mattakushi432/php-testing

---


# PHP Testing Patterns

## When to Use

Writing unit, feature, or integration tests for PHP code with PHPUnit or Pest, including Laravel's HTTP/feature test helpers.

## Core Patterns

### Pest: Expressive, Low-Boilerplate Tests

```php
it('rejects an order with no items', function () {
    $response = postJson('/orders', ['customer_id' => 1, 'items' => []]);

    $response->assertStatus(422)
        ->assertJsonValidationErrors('items');
});

it('calculates order total from item prices', function (array $items, int $expectedCents) {
    expect(calculateTotal($items))->toBe($expectedCents);
})->with([
    'single item'   => [[['price' => 500, 'qty' => 2]], 1000],
    'multiple items' => [[['price' => 300, 'qty' => 1], ['price' => 200, 'qty' => 2]], 700],
]);
```

### PHPUnit Data Providers

```php
final class DiscountCalculatorTest extends TestCase
{
    #[DataProvider('discountTiers')]
    public function test_applies_correct_discount_tier(int $totalCents, float $expected): void
    {
        $this->assertSame($expected, DiscountCalculator::rateFor($totalCents));
    }

    public static function discountTiers(): array
    {
        return [
            'below threshold' => [50_00, 0.0],
            'silver tier'     => [1_000_00, 0.05],
            'gold tier'       => [10_000_00, 0.15],
        ];
    }
}
```

### Mocking Collaborators, Not the System Under Test

```php
public function test_it_sends_a_shipping_notification(): void
{
    $mailer = $this->createMock(Mailer::class);
    $mailer->expects($this->once())
        ->method('send')
        ->with($this->isInstanceOf(OrderShippedMail::class));

    $service = new ShippingService($mailer);
    $service->markShipped($order = Order::factory()->create());

    $this->assertSame('shipped', $order->fresh()->status);
}
```

### Laravel Feature Tests with a Real Database

```php
final class OrderApiTest extends TestCase
{
    use RefreshDatabase;

    public function test_authenticated_user_can_create_an_order(): void
    {
        $customer = Customer::factory()->create();
        $product  = Product::factory()->create(['price' => 1500]);

        $response = $this->actingAs($customer->user)
            ->postJson('/api/orders', [
                'customer_id' => $customer->id,
                'items' => [['sku' => $product->sku, 'qty' => 2]],
            ]);

        $response->assertCreated()
            ->assertJsonPath('data.total_cents', 3000);

        $this->assertDatabaseHas('orders', ['customer_id' => $customer->id]);
    }
}
```

### Faking External Services

```php
Http::fake([
    'payments.example.com/*' => Http::response(['status' => 'captured'], 200),
]);

Queue::fake();
Mail::fake();

// ... exercise the code under test ...

Mail::assertSent(OrderShippedMail::class);
Queue::assertPushed(ProcessRefund::class);
```

## Checklist

- [ ] Test names describe behavior, not implementation (`test_rejects_expired_coupon`, not `test_apply_discount_2`)
- [ ] `RefreshDatabase`/`DatabaseTransactions` used for feature tests touching the DB
- [ ] External HTTP calls faked with `Http::fake()`, never hitting real endpoints in CI
- [ ] Mocks assert on collaborators, not on the class under test
- [ ] One logical assertion focus per test method
- [ ] Data providers used instead of copy-pasted near-identical test methods

## Anti-Patterns

```php
// BAD: asserting against a live database seeded by test order
public function test_order_flow(): void {
    // creates, updates, deletes across 40 lines, asserts once at the end
}

// GOOD: one behavior per test, Arrange-Act-Assert
public function test_cancelling_a_pending_order_marks_it_cancelled(): void {
    $order = Order::factory()->pending()->create();
    $order->cancel();
    $this->assertTrue($order->fresh()->status->isTerminal());
}
```

## Quick Reference

| Need | Tool |
|---|---|
| Expressive syntax, less boilerplate | Pest |
| Classic xUnit style, IDE tooling maturity | PHPUnit |
| Fake HTTP calls | `Http::fake()` |
| Fake queue/mail without dispatching | `Queue::fake()` / `Mail::fake()` |
| Reset DB between tests | `RefreshDatabase` trait |
| Mutation testing (verify tests catch real bugs) | Infection |

## See Also

- `skills/php-ecosystem/laravel-patterns.md`
- `skills/php-ecosystem/php-patterns.md`

