Introduction
While PHPUnit helps you test in isolation, Behat enables you to test integration and behavior—the way your application is perceived by users or external systems.
The challenge here isn't just mocking services. It’s designing systems with clean boundaries and side-effect control, so your features can be tested without relying on a live database, real APIs, or brittle state.
Let’s see how to achieve this using Dependency Injection, test doubles, and context-aware design.
Why Test Behavior?
Unit tests ensure correctness of internal logic. Behavioral tests verify outcomes that users care about:
- Can a user place an order?
- Is an email sent when a booking is confirmed?
- Do errors show up when input is invalid?
These questions cut across services. You can’t mock everything—but you can design your system so side effects are opt-in.
Designing for Behat: Keep the Core Pure
Split your logic into two layers:
Layer | Characteristics |
---|---|
Core (Domain) | Pure PHP, deterministic, testable |
Infrastructure | APIs, email, DB, randomness |
Make your core logic callable without needing a database, real email, or a browser. For example:
class BookingService {
public function __construct(
private AvailabilityChecker $checker,
private BookingRepository $repository,
private Mailer $mailer
) {}
public function book(User $user, DateTime $date): Booking {
if (!$this->checker->isAvailable($date)) {
throw new UnavailableException();
}
$booking = new Booking($user, $date);
$this->repository->save($booking);
$this->mailer->sendConfirmation($user, $booking);
return $booking;
}
}
This is highly testable in Behat by replacing Mailer
and BookingRepository
with fake versions scoped to the Behat environment.
Behat Setup with Symfony: Injecting Fakes via Services.yaml
In services_test.yaml
:
services:
App\Mail\FakeMailer:
decorates: App\Mail\Mailer
arguments: [ '@.inner' ]
App\Repository\InMemoryBookingRepository:
decorates: App\Repository\BookingRepository
arguments: [ '@.inner' ]
In your test context:
class BookingContext implements Context {
public function __construct(
private InMemoryBookingRepository $repository,
private FakeMailer $mailer
) {}
/**
* @Then the user should receive a confirmation email
*/
public function userShouldReceiveConfirmation() {
Assert::true($this->mailer->wasEmailSent());
}
}
This setup allows real domain behavior with fake infrastructure—the best of both worlds.
Benefits of DI + Fakes in Behat
- No fragile mocks
- Real behavior without real side effects
- Clean separation of concerns
- Fast and stable behavioral tests
Anti-Pattern: Database-Dependent Behat Tests
Scenario: Booking a room
Given a user "john@example.com" exists
And the room is available
When the user books the room
Then the booking is saved in the database
Tests like this are expensive and break often. Prefer checking logical outcomes:
Scenario: Successful booking
When a user books an available date
Then they receive a confirmation
And the system records the booking
Summary: Behat and Testable Code Architecture
- Keep infrastructure pluggable using interfaces and DI
- Use fakes or spies to verify effects without causing them
- Design domain services as pure and stateless where possible
- Keep Behat steps declarative and free from technical noise
Final Thoughts
Writing testable code is about more than just writing tests—it's about thinking in boundaries, contracts, and responsibilities. Whether you're writing fast unit tests with PHPUnit or expressive behavioral specs with Behat, the underlying principles remain:
- Inject dependencies
- Isolate side effects
- Favor clarity over cleverness
- Embrace tests as first-class citizens in your architecture