AI Skill Report Card

Writing Behat E2E Tests

A89·Sep 26, 2026·Source: Web
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
15 / 15
Bash
composer 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:

YAML
default: 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):

GHERKIN
Feature: 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
Recommendation▾
Add a brief troubleshooting section for common Behat/Mink environment setup errors (e.g., driver mismatches)
15 / 15
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

PHP
final 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:

PHP
interface 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/@AfterScenario hooks, 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 @slow and 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).

Recommendation▾
Include an example of testing a genuine UI journey with Mink/Chrome extension to contrast with the API-layer testing approach
18 / 20

Example 1: Failure/compensation scenario Input: "Salesforce API times out during order placement" Output:

GHERKIN
Scenario: 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.

Recommendation▾
Consider trimming some of the verbose code blocks (e.g., full class bodies) to keep under line budget while retaining key snippets
  • 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 FeatureContext god-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.
0
Grade AAI Skill Framework
Scorecard
Criteria Breakdown
Quick Start
15/15
Workflow
15/15
Examples
18/20
Completeness
19/20
Format
14/15
Conciseness
13/15