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
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
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
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
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
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, nottest_apply_discount_2) -
RefreshDatabase/DatabaseTransactionsused 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
// 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.mdskills/php-ecosystem/php-patterns.md