Skip to content

Laravel Integration

Pest BDD provides first-class Laravel support, making it natural to use BDD with Laravel applications. The integration focuses on leveraging existing Laravel patterns, especially Eloquent Factories.

Why Laravel Integration?

Laravel developers already have powerful tools for testing:

  • Factories for creating test data
  • Actions for encapsulating business logic
  • Database transactions for test isolation
  • HTTP testing for API verification

Pest BDD builds on these patterns rather than replacing them.

Factory-First BDD

The core philosophy is simple: your factories ARE your Given steps.

Instead of:

php
// Traditional BDD approach
#[Given('a user {name} exists')]
public function userExists(string $name): User
{
    return User::factory()->create(['name' => $name]);
}

With factory integration:

php
// Factory IS the step definition
class UserFactory extends Factory
{
    #[Given('a user {name} exists')]
    public function withName(string $name): static
    {
        return $this->state(['name' => $name]);
    }
}

Key Features

1. Factory State Methods as Steps

Annotate factory state methods with Given attributes:

php
class UserFactory extends Factory
{
    #[Given('a user exists')]
    public function definition(): array
    {
        return [
            'name' => $this->faker->name(),
            'email' => $this->faker->email(),
            'role' => 'user',
        ];
    }

    #[Given('an admin user exists')]
    public function admin(): static
    {
        return $this->state(['role' => 'admin']);
    }

    #[Given('a user {name} exists')]
    public function withName(string $name): static
    {
        return $this->state(['name' => $name]);
    }
}

2. Lazy Model Creation

Models aren't created immediately. Instead, states are chained and models are created when transitioning from Given to When/Then:

gherkin
Scenario: Admin with specific name
  Given a user "John" exists        # Chain: withName("John")
  And an admin user exists          # Chain: admin()
  When I check permissions          # CREATE! User::factory()->withName("John")->admin()->create()
  Then the user should be admin

This enables:

  • State composition
  • Efficient database operations
  • Natural Gherkin flow

3. Auto-Injection of Models

Created models are automatically available in subsequent steps:

php
#[When('I check permissions')]
public function checkPermissions(User $user): void
{
    // $user is automatically injected - created from Given steps
    $this->response = $this->actingAs($user)->get('/admin');
}

#[Then('the user should be admin')]
public function shouldBeAdmin(User $user): void
{
    expect($user->role)->toBe('admin');
}

4. Multiple Instances with Aliases

Create multiple instances of the same model using aliases:

gherkin
Scenario: Compare two users
  Given a user "John" exists as {regularUser}
  And a user "Jane" exists as {adminUser}
  And an admin user exists as {adminUser}
  When I compare users
  Then regularUser should not be admin
  And adminUser should be admin
php
#[Then('regularUser should not be admin')]
public function regularUserNotAdmin(User $regularUser): void
{
    expect($regularUser->role)->not->toBe('admin');
}

#[Then('adminUser should be admin')]
public function adminUserIsAdmin(User $adminUser): void
{
    expect($adminUser->role)->toBe('admin');
}

Getting Started

1. Add Attributes to Your Factory

php
// database/factories/UserFactory.php
namespace Database\Factories;

use App\Models\User;
use TestFlowLabs\PestTestAttributes\Given;
use Illuminate\Database\Eloquent\Factories\Factory;

class UserFactory extends Factory
{
    protected $model = User::class;

    #[Given('a user exists')]
    public function definition(): array
    {
        return [
            'name' => $this->faker->name(),
            'email' => $this->faker->unique()->safeEmail(),
            'password' => bcrypt('password'),
        ];
    }

    #[Given('a user {name} exists')]
    public function withName(string $name): static
    {
        return $this->state(['name' => $name]);
    }

    #[Given('a user with email {email} exists')]
    public function withEmail(string $email): static
    {
        return $this->state(['email' => $email]);
    }

    #[Given('an admin user exists')]
    public function admin(): static
    {
        return $this->state(['role' => 'admin']);
    }
}

2. Write Your Feature

gherkin
# tests/Behaviors/user-profile.feature
Feature: User Profile

  Scenario: View own profile
    Given a user "John Doe" exists
    When the user visits their profile
    Then they should see "John Doe"

  Scenario: Admin can view all profiles
    Given an admin user exists
    When the admin visits the users list
    Then they should see all users

3. Add When/Then Steps

php
// app/Actions/Profile/VisitProfile.php - When step (production)
class VisitProfile
{
    #[When('the user visits their profile')]
    public function execute(User $user): TestResponse
    {
        return $this->actingAs($user)->get('/profile');
    }
}

// tests/Assertions/Profile/ProfileAssertions.php - Then step
class ProfileAssertions
{
    #[Then('they should see {name}')]
    public function shouldSeeName(string $name, TestResponse $response): void
    {
        $response->assertSee($name);
    }
}

4. Run Tests

bash
composer dump-autoload --optimize
pest --bdd

Sections

Explore detailed Laravel integration topics:

Best Practices

Organize Steps by Location

database/
└── factories/                      # Given steps - Factory'ler
    ├── UserFactory.php
    └── OrderFactory.php

app/
└── Actions/                        # When steps - Production Actions
    ├── User/
    │   └── CreateUser.php
    └── Order/
        └── PlaceOrder.php

tests/
├── Assertions/                     # Then steps - Assertion'lar
│   ├── User/
│   │   └── UserAssertions.php
│   └── Order/
│       └── OrderAssertions.php
└── Behaviors/                      # Feature files
    ├── authentication.feature
    └── ordering.feature

Use Actions for When Steps

php
// app/Actions/PlaceOrder.php
class PlaceOrder
{
    #[When('the user places an order')]
    public function execute(User $user, Cart $cart): Order
    {
        return Order::create([
            'user_id' => $user->id,
            'items' => $cart->items,
            'total' => $cart->total,
        ]);
    }
}

Use Assertion Classes for Then Steps

php
// tests/Assertions/Order/OrderAssertions.php
class OrderAssertions
{
    #[Then('the order status should be {status}')]
    public function assertOrderStatus(string $status, Order $order): void
    {
        expect($order->fresh()->status)->toBe($status);
    }

    #[Then('the order total should be {total}')]
    public function assertOrderTotal(float $total, Order $order): void
    {
        expect($order->total)->toBe($total);
    }
}

Released under the MIT License.