Service Container Integration
Pest BDD seamlessly integrates with Laravel's Service Container, enabling dependency injection in your step definitions. This means you can use services, repositories, and any other container-bound classes directly in your steps.
How It Works
When executing step methods, Pest BDD uses Laravel's app() helper to resolve class instances:
// Pest BDD internally uses:
$instance = app(OrderService::class);
$instance->processOrder($order);If Laravel is not available, it falls back to simple instantiation with new.
Constructor Dependency Injection
Step classes can declare dependencies in their constructors:
use TestFlowLabs\PestTestAttributes\Then;
use TestFlowLabs\PestTestAttributes\When;
class OrderSteps
{
public function __construct(
private OrderService $orderService,
private PaymentGateway $paymentGateway,
) {}
#[When('I place an order')]
public function placeOrder(User $user): Order
{
return $this->orderService->createOrder($user);
}
#[When('I pay for the order')]
public function payForOrder(Order $order): void
{
$this->paymentGateway->charge($order);
}
#[Then('the order should be paid')]
public function orderShouldBePaid(Order $order): void
{
expect($order->fresh()->isPaid())->toBeTrue();
}
}Laravel's container automatically resolves OrderService and PaymentGateway when creating the step class instance.
Service Classes as Steps
You can put step attributes directly on your service classes:
namespace App\Services;
use TestFlowLabs\PestTestAttributes\When;
use App\Repositories\UserRepository;
class UserService
{
public function __construct(
private UserRepository $repository,
private NotificationService $notifications,
) {}
#[When('user {email} is created')]
public function createUser(string $email): User
{
$user = $this->repository->create(['email' => $email]);
$this->notifications->sendWelcome($user);
return $user;
}
#[When('user is deactivated')]
public function deactivate(User $user): void
{
$this->repository->deactivate($user);
$this->notifications->sendDeactivation($user);
}
}This approach has several benefits:
- Step logic lives with related service code
- Full DI support - same as any Laravel service
- Services remain testable independently
- Encourages reuse of existing application code
Action Classes
Laravel Action classes work perfectly as step definitions:
namespace App\Actions;
use TestFlowLabs\PestTestAttributes\When;
class CreateOrder
{
public function __construct(
private InventoryService $inventory,
private PricingService $pricing,
) {}
#[When('an order is created for {product}')]
public function __invoke(string $product, User $user): Order
{
$price = $this->pricing->getPrice($product);
$this->inventory->reserve($product);
return Order::create([
'user_id' => $user->id,
'product' => $product,
'price' => $price,
]);
}
}For invokable actions, use the #[When] attribute on the __invoke method.
Repository Pattern
Repositories with constructor dependencies work seamlessly:
namespace App\Repositories;
use TestFlowLabs\PestTestAttributes\Given;
use Illuminate\Database\DatabaseManager;
class UserRepository
{
public function __construct(
private DatabaseManager $db,
private CacheManager $cache,
) {}
#[Given('a user with email {email} exists')]
public function createWithEmail(string $email): User
{
$user = User::create(['email' => $email, 'name' => fake()->name()]);
$this->cache->forget("user:{$email}");
return $user;
}
#[Given('no users exist')]
public function clearAll(): void
{
$this->db->table('users')->truncate();
$this->cache->flush();
}
}Interface Bindings
If you bind interfaces in your service provider, Pest BDD resolves them correctly:
// AppServiceProvider
$this->app->bind(PaymentGatewayInterface::class, StripeGateway::class);class PaymentSteps
{
public function __construct(
private PaymentGatewayInterface $gateway, // Gets StripeGateway
) {}
#[When('payment is processed')]
public function processPayment(Order $order): void
{
$this->gateway->charge($order->total);
}
}Singleton Services
Singleton bindings are respected:
// AppServiceProvider
$this->app->singleton(MetricsCollector::class);class AnalyticsSteps
{
public function __construct(
private MetricsCollector $metrics, // Same instance across steps
) {}
#[When('I track event {name}')]
public function trackEvent(string $name): void
{
$this->metrics->record($name);
}
}The same MetricsCollector instance is used throughout the scenario.
Contextual Binding
Laravel's contextual bindings work as expected:
// AppServiceProvider
$this->app->when(OrderService::class)
->needs(LoggerInterface::class)
->give(OrderLogger::class);class OrderService
{
public function __construct(
private LoggerInterface $logger, // Gets OrderLogger
) {}
#[When('order is placed')]
public function placeOrder(User $user): Order
{
$order = Order::create(['user_id' => $user->id]);
$this->logger->info("Order placed: {$order->id}");
return $order;
}
}Non-Laravel Fallback
When Laravel's container is not available (non-Laravel projects), Pest BDD falls back to simple instantiation:
// Without Laravel:
$instance = new OrderSteps(); // Requires parameterless constructorFor non-Laravel projects, step classes must have:
- No constructor, OR
- Constructor with only optional parameters
// Works without Laravel
class SimpleSteps
{
#[Given('something exists')]
public function exists(): void
{
// ...
}
}
// Also works without Laravel
class ConfigurableSteps
{
public function __construct(
private string $prefix = 'default',
) {}
#[Given('a {item} exists')]
public function itemExists(string $item): void
{
// Uses $this->prefix
}
}Instance Caching
Within a single scenario, step class instances are cached:
class CounterSteps
{
private int $count = 0;
#[When('I increment')]
public function increment(): void
{
$this->count++;
}
#[Then('count should be {expected}')]
public function assertCount(int $expected): void
{
expect($this->count)->toBe($expected);
}
}Scenario: Counter increments
When I increment
And I increment
And I increment
Then count should be 3The same CounterSteps instance is used for all steps, so state is preserved.
Testing with Container
You can mock services in your tests:
beforeEach(function () {
$this->mock(PaymentGateway::class, function ($mock) {
$mock->shouldReceive('charge')->andReturn(true);
});
});These mocks are automatically used by step classes that depend on them.
Best Practices
Keep Step Classes Focused
// Good: Focused on user operations
class UserSteps
{
public function __construct(private UserService $users) {}
#[Given('a user exists')]
public function userExists(): User { }
#[When('user is updated')]
public function update(User $user): void { }
}
// Avoid: Too many responsibilities
class EverythingSteps
{
public function __construct(
private UserService $users,
private OrderService $orders,
private PaymentService $payments,
// Too many dependencies...
) {}
}Use Constructor Injection
// Good: Dependencies declared in constructor
class OrderSteps
{
public function __construct(
private OrderService $service,
) {}
}
// Avoid: Service location in methods
class OrderSteps
{
#[When('order is created')]
public function create(): void
{
$service = app(OrderService::class); // Avoid this
}
}Leverage Existing Services
// Good: Reuse existing application services
#[When('notification is sent')]
public function send(User $user): void
{
$this->notificationService->send($user, 'welcome');
}
// Avoid: Duplicating logic
#[When('notification is sent')]
public function send(User $user): void
{
// Don't duplicate what NotificationService already does
Mail::to($user)->send(new WelcomeEmail($user));
Notification::send($user, new WelcomeNotification());
}