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:
// Traditional BDD approach
#[Given('a user {name} exists')]
public function userExists(string $name): User
{
return User::factory()->create(['name' => $name]);
}With factory integration:
// 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:
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:
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 adminThis enables:
- State composition
- Efficient database operations
- Natural Gherkin flow
3. Auto-Injection of Models
Created models are automatically available in subsequent steps:
#[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:
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#[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
// 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
# 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 users3. Add When/Then Steps
// 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
composer dump-autoload --optimize
pest --bddSections
Explore detailed Laravel integration topics:
- Factory Integration - Basic factory as step definitions
- Factory States - State methods and chaining
- Multiple Instances - Aliases for multiple models
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.featureUse Actions for When Steps
// 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
// 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);
}
}