Writing Behat E2E Tests
Markdown--- name: writing-behat-e2e-tests description: Writes maintainable Behat end-to-end test suites for Symfony applications, including Gherkin scenarios, context classes, and a reusable builder/fixture mini-framework. Use when a Symfony project needs E2E test coverage for HTTP/API flows, third-party integrations (Salesforce, payment gateways, etc.), database state (SQLite/Doctrine), and future-proof, debuggable test architecture rather than shallow auto-generated scenarios. --- # Writing Behat E2E Tests
Bashcomposer require --dev behat/behat behat/symfony2-extension friends-of-behat/mink-extension \ friends-of-behat/mink-browserkit-driver friends-of-behat/symfony-extension \ dmore/behat-chrome-extension zenstruck/foundry --dev vendor/bin/behat --init
behat.yml:
YAMLdefault: suites: default: paths: [ '%paths.base%/features' ] contexts: - App\Tests\Behat\Context\OrderContext - App\Tests\Behat\Context\SalesforceContext - App\Tests\Behat\Context\DatabaseContext extensions: FriendsOfBehat\SymfonyExtension: bootstrap: tests/bootstrap.php kernel: env: test debug: false FriendsOfBehat\MinkExtension: base_url: 'http://localhost' sessions: default: symfony: ~
First scenario (features/order/place_order.feature):
GHERKINFeature: Placing an order In order to buy products As a customer I need to be able to place an order and have it synced to Salesforce Background: Given the following products exist: | name | price | sku | | Blue Mug | 9.99 | MUG-01 | And Salesforce API is stubbed to accept new leads Scenario: Successful order creation Given I am a logged in customer "jane@example.com" When I place an order for "Blue Mug" with quantity 2 Then the order should be created with status "pending" And a lead should be pushed to Salesforce with email "jane@example.com" And an order confirmation email should be queued
Progress:
- [ ] Step 1: Identify business flows worth E2E coverage (not CRUD noise)
- [ ] Step 2: Write Gherkin scenarios (happy path, edge cases, failure/rollback)
- [ ] Step 3: Build Fixture/Builder classes (Foundry factories) for domain objects
- [ ] Step 4: Build Context classes mapping steps to app actions via public API/services
- [ ] Step 5: Stub third-party APIs (Salesforce, payment) via HTTP mock or fake adapter
- [ ] Step 6: Reset DB state per scenario (SQLite in-memory or transactions)
- [ ] Step 7: Run, inspect failures, add debugging hooks (screenshots, request/response dumps)
- [ ] Step 8: Wire into CI with parallel suites tagged by risk
Step 1: Pick real scenarios, not CRUD noise
Don't auto-generate "create/read/update/delete" for every entity. Cover:
- Critical business transactions (checkout, subscription, refund)
- Cross-system flows (order → Salesforce sync → email)
- Permission/auth boundaries (guest vs admin)
- Failure & compensation paths (3rd-party API down, partial failure rollback)
Step 2: Gherkin with intent, not implementation
Bad: When I click the button with id "submit-btn"
Good: When I place an order for "Blue Mug" with quantity 2
Keep steps declarative — implementation details live in Context classes.
Step 3: Builder / Fixture mini-framework
Use Zenstruck/Foundry as the base, wrap it in your own Factory layer so tests stay decoupled from library specifics and evolve independently.
PHP// tests/Behat/Factory/OrderFactory.php final class OrderFactory extends ModelFactory { protected function getDefaults(): array { return [ 'status' => OrderStatus::PENDING, 'createdAt' => new DateTimeImmutable(), 'customer' => CustomerFactory::new(), ]; } protected function initialize(): static { return $this->afterInstantiate(function(Order $order) { $order->recalculateTotals(); }); } }
PHP// tests/Behat/Builder/OrderBuilder.php final class OrderBuilder { private array $lines = []; private ?Customer $customer = null; public static function create(): self { return new self(); } public function forCustomer(Customer $c): static { $this->customer = $c; return $this; } public function withLine(string $sku, int $qty): static { $this->lines[] = ['sku' => $sku, 'qty' => $qty]; return $this; } public function build(): Order { $order = OrderFactory::new(['customer' => $this->customer])->create(); foreach ($this->lines as $l) { $order->addLine($l['sku'], $l['qty']); } return $order; } }
This builder layer is your "mini-framework" — every future scenario composes objects through builders instead of raw entity construction, so schema changes only touch one place.
Step 4: Context classes — thin, delegate to app services
PHPfinal class OrderContext implements Context { public function __construct( private KernelInterface $kernel, private EntityManagerInterface $em, private OrderRepository $orders, private SalesforceContext $salesforce, // shared state via constructor injection ) {} /** @When I place an order for :product with quantity :qty */ public function iPlaceAnOrder(string $product, int $qty): void { $customer = $this->currentCustomer(); $this->currentOrder = OrderBuilder::create() ->forCustomer($customer) ->withLine($this->skuFor($product), $qty) ->build(); $this->kernel->getContainer()->get(PlaceOrderHandler::class) ->handle(new PlaceOrderCommand($this->currentOrder->getId())); } /** @Then the order should be created with status :status */ public function theOrderShouldHaveStatus(string $status): void { $this->em->refresh($this->currentOrder); Assert::eq($this->currentOrder->getStatus()->value, $status); } }
Call application layer (command handlers/services), not controllers directly, unless testing HTTP contract explicitly — keeps tests fast and focused on behavior.
Step 5: Stub third-party APIs (Salesforce, etc.)
Never hit real APIs in E2E tests. Use an interface + fake adapter swapped in services_test.yaml:
PHPinterface SalesforceClientInterface { public function pushLead(LeadDto $lead): void; } final class FakeSalesforceClient implements SalesforceClientInterface { public array $pushedLeads = []; public function pushLead(LeadDto $lead): void { $this->pushedLeads[] = $lead; } }
YAML# config/services_test.yaml App\Integration\Salesforce\SalesforceClientInterface: class: App\Tests\Behat\Fake\FakeSalesforceClient
For HTTP-level stubbing use dmore/behat-chrome-extension + WireMock/Symfony HttpClient MockHttpClient, or a lightweight local fake server if contract fidelity matters (recommend WireMock for Salesforce REST contract testing).
Step 6: DB isolation
- SQLite in-memory for speed on CI:
DATABASE_URL="sqlite:///:memory:" - Wrap each scenario in a transaction rolled back via
@BeforeScenario/@AfterScenariohooks, or reset schema with DAMA/doctrine-test-bundle:
PHP/** @BeforeScenario */ public function beginTransaction(): void { $this->em->getConnection()->beginTransaction(); } /** @AfterScenario */ public function rollback(): void { $this->em->getConnection()->rollBack(); }
Step 7: Debuggability
- On failure, dump last request/response and DB state:
PHP/** @AfterStep */ public function attachDebugOnFailure(AfterStepScope $scope): void { if (!$scope->getTestResult()->isPassed()) { file_put_contents('var/behat-fail-'.time().'.html', $this->getSession()->getPage()->getContent()); } }
- Tag flaky/slow scenarios:
@salesforce @slowand run subsets:behat --tags=@salesforce.
Step 8: CI
Run suites in parallel by tag; fail fast on @critical; separate @integration (real sandbox APIs, nightly) from @e2e (stubbed, every PR).
Example 1: Failure/compensation scenario Input: "Salesforce API times out during order placement" Output:
GHERKINScenario: Order succeeds locally even if Salesforce sync fails Given Salesforce API is stubbed to timeout When I place an order for "Blue Mug" with quantity 1 Then the order should be created with status "pending" And a "salesforce_sync_failed" event should be logged And the order should be queued for retry sync
Context step stubs the fake client to throw SalesforceTimeoutException, asserts order still persists, and checks retry queue table via DatabaseContext.
Example 2: Builder reuse across scenarios
Input: Need both "order with discount" and "order with backordered item" scenarios.
Output: Extend OrderBuilder with withDiscount(Coupon $c) and withBackorderedLine(...), reuse in both .feature files without duplicating entity setup code.
- One Context per bounded context/domain (OrderContext, SalesforceContext, PaymentContext), not one giant
FeatureContext. - Share state between contexts via constructor injection of a shared "TestState" service, not static properties.
- Assert on domain state and side effects (DB rows, dispatched events, queued jobs) — not just HTTP 200.
- Keep Gherkin readable by non-engineers; push all technical detail into step definitions/builders.
- Version your fixture/builder layer like production code — code review it with the same rigor.
- Prefer testing through application/command layer for business logic; use full HTTP+browser (Mink) only for genuine UI/user-journey scenarios.
- Auto-generating a scenario per CRUD endpoint — produces bloat with zero business value.
- Hardcoding IDs/selectors in Gherkin steps — breaks on any UI/schema change.
- Hitting real third-party APIs (Salesforce sandbox) in every CI run — slow, flaky, rate-limited.
- Not isolating DB state — tests pass/fail based on execution order.
- Fat
FeatureContextgod-class handling everything — impossible to navigate as suite grows. - Testing implementation details (e.g., internal method calls) instead of observable behavior.
- Skipping negative/failure-path scenarios — most production bugs live in error handling, not happy path.