๐Ÿ”งAutoSkills
All items
skillv1.0.0

pest-testing

Pest PHP testing framework patterns: expectations, datasets, architecture tests, mocking

pestphplaraveltesting

Install

$npx autoskills --items pest-testing

Or scan + install everything matching your stack with npx autoskills.

Pest Testing

Pest is PHPUnit with a saner DX. Same engine, less ceremony. But the ergonomics tempt you into bad shapes โ€” this skill covers the patterns that scale.

Test file shape

<?php
 
use App\Models\User;
 
it('creates a user', function () {
    $user = User::factory()->create(['email' => 'a@b.com']);
    expect($user->email)->toBe('a@b.com');
});
 
it('rejects duplicate emails', function () {
    User::factory()->create(['email' => 'a@b.com']);
    expect(fn () => User::factory()->create(['email' => 'a@b.com']))
        ->toThrow(QueryException::class);
});
  • it() over test() for behavior-focused names. it('does X') reads as a sentence.
  • test() is fine for pure helpers / non-behavioral cases.
  • One assertion focus per test. Multiple expect() lines are fine, but they should all assert the same conceptual outcome.

Expectations chain

expect($user)
    ->name->toBe('Alice')
    ->email->toEndWith('@example.com')
    ->isAdmin->toBeFalse();

Higher-order chaining keeps tests dense without losing clarity. Use for:

  • Object shape: expect($order)->total->toBe(100)->status->toBe('paid');
  • Collection contents: expect($users)->toHaveCount(3)->each->isActive->toBeTrue();

Datasets

it('validates emails', function (string $email, bool $valid) {
    expect((new EmailValidator)->isValid($email))->toBe($valid);
})->with([
    ['alice@example.com', true],
    ['no-at-sign', false],
    ['@no-local.com', false],
]);
  • Named datasets for reuse:
    // tests/Datasets/Emails.php
    dataset('emails', [
        'valid'   => ['alice@example.com', true],
        'no @'    => ['no-at-sign', false],
    ]);
     
    it('validates emails', function ($email, $valid) { ... })->with('emails');
  • Multiple with() calls = cross product. Useful for matrix tests (e.g., [1, 2, 3] ร— ['a', 'b'] = 6 cases).
  • Failure messages include dataset key when you use the named form โ€” much easier to debug.

Hooks

beforeEach(function () {
    $this->user = User::factory()->create();
});
 
afterEach(function () {
    Cache::flush();
});
  • $this works โ€” Pest is closures-on-PHPUnit-cases under the hood.
  • beforeAll/afterAll for expensive setup, but it runs once for the whole file โ€” beware shared state.

Architecture tests

// tests/Architecture/CleanlinessTest.php
arch('controllers do not use models directly')
    ->expect('App\Http\Controllers')
    ->not->toUse('App\Models');
 
arch('no debug calls in production code')
    ->expect(['dd', 'dump', 'var_dump', 'ray'])
    ->not->toBeUsed();

Architecture tests are pest-unique. Use them to enforce structural rules CI can catch.

Mocking (Mockery)

$mock = $this->mock(PaymentGateway::class)
    ->shouldReceive('charge')
    ->once()
    ->with(100)
    ->andReturn(new ChargeResult(success: true))
    ->getMock();
  • Prefer real implementations + DB transactions for Laravel tests. Mocks are for boundaries you don't control (third-party APIs).
  • ->expects('method') is the typed variant โ€” better failure messages.

Laravel-specific helpers

// HTTP test
$this->actingAs($user)
    ->postJson('/api/posts', ['title' => 'X'])
    ->assertCreated()
    ->assertJson(['title' => 'X']);
 
// DB assertions
$this->assertDatabaseHas('posts', ['title' => 'X']);
$this->assertDatabaseCount('posts', 1);
 
// Mail/Queue/Event fakes
Mail::fake(); ...; Mail::assertSent(WelcomeEmail::class);
Queue::fake(); ...; Queue::assertPushed(ProcessOrder::class);

Speed

  • RefreshDatabase is the default for Feature tests โ€” runs migrations once, transactions roll back per test.
  • DatabaseTransactions if migrations are slow โ€” assumes the DB is already in a usable state.
  • Use SQLite in-memory in CI when feasible: DB_CONNECTION=sqlite, DB_DATABASE=:memory:.
  • Parallel: php artisan test --parallel โ€” uses ParaTest under the hood. Big win on multi-core CI.

What to avoid

  • One giant it('handles all the things') โ€” split per behavior.
  • Asserting on internal state instead of observable outcome โ€” couples tests to implementation.
  • Sleeping in tests โ€” use Carbon::setTestNow() or queue fakes instead.
  • Skipping flaky tests with ->skip() โ€” fix or delete. A skipped test rots.
  • Snapshot tests for everything โ€” snapshots are great for stable output, terrible for output you change often.