Skip to content
Muhammet Şafak
tr
Tools & Technologies 4 min read

Building a Testing Habit in Laravel: Switching to Pest

I explain the syntax and structural differences that turned test writing from a chore into a habit when moving from PHPUnit to Pest.


I knew writing tests was a good idea — but for a long time I couldn’t close the gap between “I should write tests” and “I’m actually writing tests.” Learning PHPUnit wasn’t hard, but every test file was a class, every test was a method, chains of $this->assertSomething() calls… The syntax created a small amount of friction, and as that friction accumulated, test writing kept getting pushed off.

My first reaction to Pest was “another tool?” I waited a while. Early this year I started using Pest on a serious project, and I have no desire to go back to PHPUnit. In this post I’ll try to explain why.

What is Pest

Pest (Pest PHP) is a testing framework built on top of PHPUnit, developed by Nuno Maduro from the Laravel core community. It doesn’t replace PHPUnit — it layers a cleaner syntax on top of it. That’s an important distinction: your existing PHPUnit tests continue to run with Pest, and the migration can be incremental.

Syntax Differences

A Feature test with PHPUnit:

<?php

namespace Tests\Feature;

use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class AuthTest extends TestCase
{
    use RefreshDatabase;

    public function test_user_can_login_with_valid_credentials(): void
    {
        $user = User::factory()->create([
            'password' => bcrypt('gizli123'),
        ]);

        $response = $this->post('/api/login', [
            'email'    => $user->email,
            'password' => 'gizli123',
        ]);

        $response->assertStatus(200)
                 ->assertJsonStructure(['token']);
    }

    public function test_user_cannot_login_with_wrong_password(): void
    {
        $user = User::factory()->create();

        $response = $this->post('/api/login', [
            'email'    => $user->email,
            'password' => 'yanlis',
        ]);

        $response->assertStatus(401);
    }
}

The same tests with Pest:

<?php

use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

it('kullanıcı geçerli bilgilerle giriş yapabilir', function () {
    $user = User::factory()->create([
        'password' => bcrypt('gizli123'),
    ]);

    $this->post('/api/login', [
        'email'    => $user->email,
        'password' => 'gizli123',
    ])
    ->assertStatus(200)
    ->assertJsonStructure(['token']);
});

it('kullanıcı yanlış şifreyle giriş yapamaz', function () {
    $user = User::factory()->create();

    $this->post('/api/login', [
        'email'    => $user->email,
        'password' => 'yanlis',
    ])
    ->assertStatus(401);
});

No class, no namespace, no method. Just an it() or test() function, a string description, and a closure. That’s it.

Expectations Syntax

Pest also ships its own expect() API — instead of assertXxx methods you can use a fluent chain:

<?php

it('sipariş toplam fiyatı doğru hesaplanır', function () {
    $order = Order::factory()
        ->hasItems(3, ['price' => 100])
        ->create();

    expect($order->total)
        ->toBe(300)
        ->toBeGreaterThan(0);
});

Reading expect()->toBe() feels far more natural. The test output is equally more readable.

Parameterized Tests with Datasets

To run the same test with different inputs in PHPUnit you had to write a dataProvider. Pest requires much less ceremony:

<?php

it('geçersiz e-posta formatlarını reddeder', function (string $email) {
    $this->post('/api/register', ['email' => $email])
         ->assertStatus(422);
})->with([
    'düz metin',
    '@eksik-lokal.com',
    '[email protected]',
    'boşluk iç[email protected]',
]);

In PHPUnit you’d define a separate public function, add a @dataProvider docblock, and match the method name. In Pest it’s .with() — that’s all. This ergonomic improvement encourages testing edge cases; once writing a parameterized test is easy, you naturally cover more scenarios.

Installation and Migration

composer require pestphp/pest --dev --with-all-dependencies
composer require pestphp/pest-plugin-laravel --dev
php artisan pest:install

Existing PHPUnit tests continue to work untouched. Writing new tests with Pest is enough — incremental migration is fully supported.

One thing to watch during coexistence: helpers registered with uses() in tests/Pest.php can apply globally to all files. This can sometimes produce unexpected behaviour. If you want to apply RefreshDatabase to a specific test group, writing uses(RefreshDatabase::class) in that individual file is safer than relying on a global definition.

Running a Single Test

In a large test suite you sometimes want to run only the test you’re currently working on. In Pest you use the --filter flag:

php artisan test --filter "kullanıcı geçerli bilgilerle giriş yapabilir"

Or by specifying the test file:

php artisan test tests/Feature/AuthTest.php

Both options exist in PHPUnit too, but because Pest test descriptions are plain strings, --filter searching feels more natural. You can write descriptions in any language you like — the filter works the same way.

The Real Win: Less Friction

What I noticed after adopting Pest wasn’t speed (execution time is nearly identical to PHPUnit). The real difference was that the resistance to opening a test file after writing a new feature went down. As the syntax got shorter, the decision to “test this while I’m here” became easier to make.

This is a good example of how tool choice shapes behaviour. Even without a technical difference, the impact on ergonomics is real. You don’t have to enjoy writing tests — but you can reasonably want them to generate as little friction as possible.

Switching to Pest made me write tests more often — but the deeper change was in habit. Writing at least the core scenarios before shipping a feature became a reflex. The tool doesn’t force that; it just keeps the door open. As friction decreases, so does the urge to say “I’ll write that test later.”

Share:

Comments

Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.

Related Posts

Search the site

Start typing to search posts, projects and pages.

Esc to close Powered by Pagefind