
Oro Behat Debugging
- 2 installs
- 2 repo stars
- Updated July 22, 2026
- netresearch/orocommerce-skill
Debugs failing, flaky, or hanging OroCommerce 6.1 Behat tests using log forensics, verbosity flags, screenshots, AJAX-race diagnosis, and split-process Xdebug across the CLI runner and PHP-FPM.
About
Provides an escalating debugging flow for OroCommerce 6.1 Behat failures, from reading var/log for hidden 500s to breadth-first diagnostic dumps and two-listener Xdebug across the Behat runner and PHP-FPM. A developer uses it when a scenario fails, flakes, or hangs and needs root-cause investigation.
- Split-process Xdebug setup for the CLI runner and PHP-FPM separately
- Breadth-first single-step state dumps and AJAX-race-not-flake guidance
Oro Behat Debugging by the numbers
- 2 all-time installs (skills.sh)
- +1 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #1,683 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/netresearch/orocommerce-skill --skill oro-behat-debuggingAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2 |
|---|---|
| repo stars | ★ 2 |
| Last updated | July 22, 2026 |
| Repository | netresearch/orocommerce-skill ↗ |
What it does
Debugs failing, flaky, or hanging OroCommerce 6.1 Behat tests using log forensics, verbosity flags, screenshots, AJAX-race diagnosis, and split-process Xdebug across the CLI runner and PHP-FPM.
Files
Debugging Oro Commerce v6.1 Behat Tests
Debugging Flow
Start with the cheapest signal and escalate. Most failures resolve at steps 1-3.
1. Read the failure output and `var/log/<env>.log` — Behat surfaces "element not found" for what is often a 500 in the app. The real cause lives in the server log, not the Behat stack trace. 2. Re-run with `-v` / `-vv` / `-vvv` — verbose shows the matched step definition, very verbose adds hook execution, debug adds the full step matcher trace. Escalate one level at a time. 3. Capture state — insert And I take screenshot at the suspected failure point. Oro's ScreenshotTrait::takeScreenshot captures cursor position too, except when a browser alert is showing. Drop dump($variable) into Context classes to inspect runtime state.
- Multi-gate failure? Go breadth-first with ONE diagnostic step, not N sequential runs. When a scenario fails with an opaque "thing missing / empty" symptom and there are several plausible gates (visibility tables + scope + Elasticsearch index + system config + feature flag, etc.), write a single
@Then I dump X for :argstep in the Context that queries EVERY plausible culprit in one shot and throws the combined state as aRuntimeException. One 2–4 minute behat cycle then returns all the evidence at once. Serialised hypothesis-at-a-time runs compound cost and context-switching. Wrap each sub-query in try/catch so a missing table or wrong service name doesn't suppress the rest. Revert the diagnostic step before committing — it's scaffolding, not permanent test coverage. See theComprehensive Visibility/State Dumppattern inreferences/breadth-first-diagnostics.md.
4. Isolate the scenario — strip every step not strictly required to reproduce. A failing 3-step scenario tells you far more than a failing 30-step one. 5. Run with `--stop-on-failure` — tight feedback loop when chasing a single failing scenario. 6. If intermittent: treat it as an AJAX race, not a flake — see references/ajax-flake.md. Adding And I wait is not a fix. 7. If logic inside a Context is wrong: attach Xdebug — see Xdebug Split-Process below.
Xdebug Split-Process Debugging
This is the part the Oro docs gloss over and it is the highest-leverage technique in the skill.
The Behat runner and the application under test are two separate PHP processes, usually in two containers:
- CLI runner —
bin/behatitself. Runs Context classes, step definitions, element classes, fixtures. Breakpoints in PHP test code fire here. - PHP-FPM — serves the Mink browser's HTTP requests. Breakpoints in controllers, services, repositories, event listeners fire here.
Xdebug on only one of them misses everything on the other. Debugging both requires two targets listening on two ports.
Minimal CLI attach:
XDEBUG_MODE=debug XDEBUG_SESSION=1 php bin/behat path/to/feature.featureDebugging FPM requires setting the XDEBUG_SESSION cookie inside the Mink browser session so FPM activates its debugger — Mink's Selenium2 driver supports this directly:
$this->getSession()->setCookie('XDEBUG_SESSION', 'IDEKEY');Put that in a @BeforeScenario hook or a dedicated debug step. Full setup (two listeners, path mapping, container networking, common silent failures) in references/xdebug-split.md.
Step Discovery & Verbosity
Don't guess step wording. List what's actually available:
php bin/behat -dl -s OroUserBundle # names only
php bin/behat -di -s OroUserBundle # names + descriptions + examples
php bin/behat -dl -s OroUserBundle | grep "grid" # filterGenerate skeletons for undefined steps instead of writing them by hand:
php bin/behat path/to/your.feature --dry-run --append-snippets --snippets-type=regexTag filtering for focused runs:
php bin/behat --tags=@smoke
php bin/behat --tags="@smoke&&@checkout" # AND
php bin/behat --tags="@smoke,@checkout" # OR
php bin/behat --tags="~@wip" # NOTFull reference: references/step-discovery.md.
Pausing for Manual Inspection
And I wait for actionPrints "Press [RETURN] to continue..." and blocks until you hit return. Local only — in CI this hangs the job until it times out. Add a pre-commit check (grep for the phrase) if this bites repeatedly.
Key Pitfalls
1. Debugging the wrong process. Xdebug on only the CLI runner never hits controller breakpoints; on only FPM it never hits Context breakpoints. Two targets, two ports, two breakpoint sets. 2. `waitForAjax` on non-jQuery requests. The helper only tracks jQuery-registered XHR. Native fetch() and raw XMLHttpRequest never enter its queue, so it returns immediately while the request is still in flight. Wait for an observable DOM state, not the AJAX queue. 3. Committing `And I wait for action`. CI blocks forever on the stdin read. Budget a pre-commit hook for it. 4. Not reading `var/log/<env>.log` after a failure. A server-side exception in a controller surfaces as a frontend "element not found" — Behat can't see the 500, only the missing element that would have rendered on success. 5. Path-mapping misconfigured in split-process Xdebug. When container paths differ from the IDE's local paths, breakpoints silently don't fire. No error, no warning — the debugger just skips them. Always verify by setting a trivial breakpoint on line 1 of a known-loaded file first.
See Also
references/xdebug-split.md— Full two-process Xdebug setup, cookie propagation, path mapping, silent-failure checklistreferences/step-discovery.md—-dl/-di, snippet generation, tag filtering, verbosity progressionreferences/ajax-flake.md— Race conditions, detached element references, backend-vs-HTTP completion, spin predicate patternreferences/performance-tmpfs.md— PostgreSQL in tmpfs for faster test runs (advanced, not in official docs)references/v6.1.md— 6.1-stable debugging specificsreferences/v7.0.md— 7.x notes (placeholder)- For writing tests: oro-behat-testing skill
- For remote/staging runs: oro-e2e-testing skill
- Upstream: Oro Debug Behat Tests
AJAX Flakiness Root-Causing
"Flaky test" is almost never a test framework problem. It's a race condition the test is silent about. Treat every intermittent failure as a bug to root-cause, not noise to retry around.
The Four Real Causes
1. waitForAjax doesn't see the request
Oro's waitForAjax polls jQuery's $.active counter. Only XHR requests made through jQuery.ajax() (and internally $.get/$.post) increment it. Everything else is invisible to the helper:
fetch(...)— native Fetch API, used in any modernized storefront code.- Raw
new XMLHttpRequest()— rare but happens in third-party integrations. - Web Workers, Service Worker fetches.
EventSource/ SSE streams.navigator.sendBeacon().
If the storefront uses fetch() anywhere in the flow, waitForAjax returns immediately while the actual request is still in flight. The next step runs against a stale DOM.
Fix: wait for an observable DOM state that only appears after the real response has been processed. Never wait for the AJAX queue.
2. Detached element reference
You grab an element reference, then something re-renders the container, then you click. The reference points into a DOM subtree that's been replaced — Selenium raises StaleElementReferenceError intermittently depending on timing.
// Fragile: find, act on another element that re-renders, then click.
$button = $this->find('css', '.add-to-cart');
$this->setQuantity(5); // triggers re-render
$button->click(); // may be staleFix: re-query immediately before the action. Don't cache element references across any DOM-mutating call.
3. Backend state hasn't caught up
The HTTP request completed, but the business state the test asserts on hasn't finalized yet — typical when async message consumers handle the real work:
- "Order placed" returns 200 as soon as the order enters
pending, but the test asserts onin_progresswhich a consumer sets. - "Inventory updated" responds on write to the event queue, not on the read model being updated.
- "Email sent" is queued; the test looks at the spool seconds later.
waitForAjax is useless here — the HTTP round-trip already finished. The test needs to wait for the business state, not the transport.
Fix: poll the database or the observable entity state, not the network layer. Oro's PageObjectContext::spin() is the right primitive.
4. Silent JS throw inside a row/view render — .loader-mask.shown persists
waitForAjax also polls for .loader-mask.shown, .lazy-loading, .loading-bar.show, and mediator isInAction. Any of these sticking non-false makes it wait out the full timeout.
When a Backbone row view or Underscore template throws inside its render callback, Oro's datagrid never clears the loader mask — the DOM fragment covering the grid stays .shown, waitForAjax polls it for the full 120 s and Behat reports "Wait for ajax > 120 seconds" with no hint that anything was wrong server-side.
Classic trigger a preserved Underscore template uses bare <%= varName %> instead of <%= obj.varName %>. Underscore compiles to with (obj || {}) { ... }. As long as row models always carry varName, the with lookup works. The moment a new row-model variant (kit sub-row, grouped line item, variant child) legitimately lacks that field, render throws ReferenceError: varName is not defined, silently, inside the view — no PHP trace, no server error, nothing in var/log/<env>.log.
Diagnostic recipe:
1. Confirm the hang isn't a real pending XHR: in a debug step, dump window.jQuery.active during the wait. Zero = no real AJAX in flight; the blocker is one of the other waitForAjax predicates. 2. Capture Chrome browser console for the session. Add to behat.yml under the session's chromeOptions:
extra_capabilities:
'goog:loggingPrefs':
browser: ALL3. Add a one-shot @AfterStep hook that dumps $this->getSession()->getDriver()->getWebDriverSession()->log(['type' => 'browser']) to a JSON file when the hang triggers. The ReferenceError (or TypeError, or whatever silent throw) is in there with file + line pointing at the compiled template chunk. 4. Follow the file reference back to the source .html template or .js view — the chunk filename usually carries the bundle's public-path segment. 5. Revert the `goog:loggingPrefs` capability and the probe hook before committing. They are diagnostic-only; leaving browser: ALL in CI capabilities bloats every test run's log payload.
Why `waitForAjax` never warns you: the helper is doing exactly what it was written to do — waiting for observable DOM signals to clear. It can't distinguish "request still loading" from "renderer threw and never cleared the mask." Once you know the pattern, the cause is an easy 5-minute fix (prefix bare refs with obj. in the throwing template). Getting there the first time costs hours.
The Principled Pattern: Spin on a Predicate
Replace "sleep a bit longer" with "wait until the thing we actually care about is true":
$this->spin(function () {
return $this->getSession()->getPage()->find('css', '.product-added-notice') !== null;
}, 10); // 10-second ceilingOr for backend state:
$this->spin(function () {
$order = $this->em->getRepository(Order::class)->find($orderId);
$this->em->refresh($order);
return $order->getStatus() === OrderStatus::IN_PROGRESS;
}, 30);Key properties of a good predicate:
- Observable — something the test can check cheaply without side effects.
- Causal — it only becomes true after the thing you're waiting for has happened.
- Idempotent — polling it twice in a row gives the same answer until state changes.
Diagnostic Checklist for a Flaky Scenario
1. Run it 10 times in a row (for i in {1..10}; do ...; done). Reproducible at > 10% failure rate? Race condition. Reproducible at < 1%? Still a race, but environmental. 2. Add And I take screenshot immediately before the failing step to capture the DOM state just before it blows up. 3. Check var/log/<env>.log for the exact moment of failure — async consumer errors surface there, not in the Behat output. 4. Is any storefront JS using fetch() instead of jQuery? grep -r "fetch(" src/*/Resources/public/. If yes, waitForAjax is lying to you. 5. Replace the last waitForAjax before the failure with a spin on an observable DOM or DB predicate.
Breadth-First Diagnostic Steps
A behat scenario fails with a "thing missing / empty" symptom. Several plausible gates could be responsible. Don't hypothesise one at a time — write one diagnostic step that dumps every plausible culprit in a single run.
Why
Each make behat FEATURE=… cycle costs 2–4 minutes (baseline restore + ES reindex + fixture load + scenario). Narrowing a multi-gate failure by re-running once per hypothesis compounds to 20–40 minutes of sequential wall time when a single dump would have returned the answer in one run.
Human asks: "can you add more diagnose to see ALL possible culprits and not do 200 single runs?" — the reaction to the anti-pattern.
Pattern
Add a temporary @Then I dump X for :arg on :arg step to a Context. It: 1. Resolves the entities under investigation (product, website, cart, etc.). 2. Queries every related table via DBAL (rule + resolved, chain tables, scopes). 3. Optionally queries Elasticsearch per-website-index for the entity presence. 4. Optionally pulls system config values scoped to the entity. 5. Wraps each sub-query in try/catch so one missing table / wrong service name doesn't suppress the rest. 6. throw new \RuntimeException("=== DUMP ===\n" . json_encode(...)) so the combined state lands in behat output directly.
After one run you have ground truth. Then think.
Example: product-listing visibility dump
/**
* @Then I dump visibility for :sku on website :websiteName
*/
public function iDumpVisibilityFor(string $sku, string $websiteName): void
{
$container = $this->getAppContainer();
$doctrine = $container->get('doctrine');
$product = $doctrine->getRepository(Product::class)->findOneBy(['sku' => $sku]);
$website = $doctrine->getRepository(Website::class)->findOneBy(['name' => $websiteName]);
$conn = $doctrine->getConnection();
$pid = $product?->getId() ?? 0;
$wid = $website?->getId() ?? 0;
// Product core
$productCore = $conn->fetchAssociative('SELECT id, sku, status, category_id, organization_id FROM oro_product WHERE id = ?', [$pid]);
// All six product visibility tables
$tables = [
'oro_product_visibility' => "SELECT pv.id, pv.visibility, s.id AS scope_id, s.website_id, s.customergroup_id, s.customer_id
FROM oro_product_visibility pv JOIN oro_scope s ON s.id = pv.scope_id WHERE pv.product_id = ?",
'oro_prod_vsb_resolv' => 'SELECT * FROM oro_prod_vsb_resolv WHERE product_id = ?',
'oro_cus_grp_prod_visibility' => "SELECT v.id, v.visibility, s.id AS scope_id, s.website_id, s.customergroup_id
FROM oro_cus_grp_prod_visibility v JOIN oro_scope s ON s.id = v.scope_id WHERE v.product_id = ?",
'oro_cus_grp_prod_vsb_resolv' => 'SELECT * FROM oro_cus_grp_prod_vsb_resolv WHERE product_id = ?',
'oro_cus_product_visibility' => "SELECT v.id, v.visibility, s.id AS scope_id, s.website_id, s.customer_id
FROM oro_cus_product_visibility v JOIN oro_scope s ON s.id = v.scope_id WHERE v.product_id = ?",
'oro_cus_prod_vsb_resolv' => 'SELECT * FROM oro_cus_prod_vsb_resolv WHERE product_id = ?',
];
$results = [];
foreach ($tables as $name => $sql) {
try {
$results[$name] = $conn->fetchAllAssociative($sql, [$pid]);
} catch (\Throwable $e) {
$results[$name] = 'ERROR: ' . $e->getMessage();
}
}
// ES hits per website-scoped index
$es = [];
try {
$engine = $container->get('oro_website_search.eleastic_search.engine');
$esClient = $engine->getClient();
$indexNames = array_keys(($esClient->indices()->stats())['indices'] ?? []);
$websiteIndexes = array_filter($indexNames, fn ($n) => str_contains($n, "_{$wid}_"));
foreach ($websiteIndexes ?: $indexNames as $index) {
try {
$resp = $esClient->search([
'index' => $index,
'body' => ['query' => ['match_phrase' => ['sku' => $sku]], 'size' => 3],
]);
$es[$index] = $resp['hits']['total']['value'] ?? 0;
} catch (\Throwable $e) {
$es[$index] = 'ERROR: ' . $e->getMessage();
}
}
} catch (\Throwable $e) {
$es = 'ES lookup failed: ' . $e->getMessage();
}
// System config relevant to the domain
$cfg = [];
try {
$cm = $container->get('oro_config.website');
foreach (['oro_visibility.product_visibility', 'oro_lab_cart.availability_for_guests'] as $k) {
$cfg[$k] = $cm->get($k, false, false, $website);
}
} catch (\Throwable $e) {
$cfg = 'config lookup failed: ' . $e->getMessage();
}
throw new \RuntimeException(
"=== DUMP sku=$sku website=$websiteName (id=$wid) ===\n"
. 'Product core: ' . json_encode($productCore) . "\n"
. 'Visibility tables: ' . json_encode($results, JSON_PRETTY_PRINT) . "\n"
. 'ES: ' . json_encode($es, JSON_PRETTY_PRINT) . "\n"
. 'Config: ' . json_encode($cfg)
);
}Checklist for writing a breadth-first dump
- [ ] Resolve all entities under investigation up-front (product, website, customer, etc.).
- [ ] List every related table — don't assume naming convention (Oro uses non-obvious abbreviated tables like
oro_prod_vsb_resolv,oro_ctgr_vsb_resolv,oro_cus_grp_prod_vsb_resolv). Verify with\dtagainst the running DB before writing the step. - [ ] Include scope rows for the website so FK
scope_idvalues in other tables can be cross-referenced. - [ ] Wrap each sub-query in
try/catch— the step must dump everything it CAN find even when some queries fail. - [ ] Include Elasticsearch hits per-index when the grid is ES-backed.
- [ ] Include relevant system-config keys.
- [ ] Use
json_encode(..., JSON_PRETTY_PRINT)for nested sections. - [ ] Throw as
RuntimeExceptionso it's unmistakable in behat output. - [ ] Revert the diagnostic step and the Gherkin invocation before committing — it's debugging scaffolding, not permanent test coverage.
Variants
- Price resolution dump: for a product+customer+website, dump
oro_price_list_to_product,oro_price_list_to_website,oro_price_list_customer_fb,oro_combined_price_list_to_website,oro_price_product_current. - Checkout workflow dump: for a checkout id, dump
oro_workflow_item,oro_checkout, relatedoro_shopping_list,oro_order, customer group, and any project-specific workflow state. - Cart kit pipeline dump: for a cart id, dump
oro_lab_cart,oro_lab_cart_line_item,oro_product_kit_item_line_item, the cart-grid query result envelope, action_configuration JSON on serialized rows.
Each is a 5-minute investment that eliminates a 30–60 minute sequence of single-hypothesis runs.
PostgreSQL on tmpfs for Faster Behat Runs
Advanced: this is not in the official Oro docs. Verified by experience on Linux developer machines. Do not apply to CI or production hosts.
Why
Behat's per-scenario isolators truncate and re-seed the database hundreds of times per run. Disk I/O dominates the wall-clock time of the whole suite — moving Postgres' data directory onto a RAM-backed tmpfs cuts a 40-minute run to roughly 15 minutes on typical hardware. The database is ephemeral (re-created from fixtures every run) so losing it on reboot is acceptable.
Setup (Linux Host)
# 1. Create the mount point
sudo mkdir -p /var/tmpfs
# 2. Mount 4 GB of RAM there
sudo mount -t tmpfs -o size=4G,mode=1777 tmpfs /var/tmpfs
# 3. Stop Postgres before moving data
sudo systemctl stop postgresql
# 4. Copy the existing cluster into tmpfs
sudo rsync -a /var/lib/postgresql/<version>/main/ /var/tmpfs/postgresql/<version>/main/
sudo chown -R postgres:postgres /var/tmpfs/postgresql
# 5. Point Postgres at the new location
sudo sed -i 's|^data_directory.*|data_directory = '"'"'/var/tmpfs/postgresql/<version>/main'"'"'|' \
/etc/postgresql/<version>/main/postgresql.conf
# 6. Start Postgres
sudo systemctl start postgresqlReplace <version> with the installed major (14, 15, 16).
Persistence Across Reboots
Tmpfs is gone after reboot. Either accept re-seeding fixtures on first run, or add to /etc/fstab:
tmpfs /var/tmpfs tmpfs defaults,size=4G,mode=1777 0 0Add a systemd oneshot or a postgresql.service drop-in that restores the empty directory structure before Postgres starts. Without it, Postgres fails to start on a bare tmpfs.
AppArmor (Ubuntu/Debian)
On distributions with AppArmor profiles for Postgres, the default profile only allows access under /var/lib/postgresql/. Add an alias so the profile recognizes the tmpfs path:
echo "alias /var/lib/postgresql/ -> /var/tmpfs/postgresql/," | \
sudo tee /etc/apparmor.d/tunables/alias.d/postgresql-tmpfs
sudo systemctl reload apparmorWithout this, Postgres starts but cannot open its own data files.
Sizing
4 GB is enough for a typical Behat fixture set with headroom. Monitor with df -h /var/tmpfs while a suite runs — if usage climbs past ~75% during a run, bump the size= value. tmpfs sizing is a ceiling, not a reservation, so 4 GB costs no RAM until used.
Docker Alternative
If Postgres runs in a container, skip the host-level setup and mount tmpfs directly in compose:
services:
postgres:
tmpfs:
- /var/lib/postgresql/data:size=4GThis is simpler but loses the data on every container restart. Fine for behat_test; do not use it for a dev database you care about.
Caveats
- Obviously RAM-only — a crash or reboot wipes the database. Non-issue for Behat fixtures.
- Do not apply to CI runners shared with other workloads. The memory pressure is invisible to other jobs.
fsyncbecomes meaningless; do not benchmark disk tuning with tmpfs in place.
Step Discovery, Verbosity, and Tag Filtering
The fastest way to write or debug a scenario is to find out what steps already exist and what Behat is actually doing when it runs them.
Listing Steps
-dl (definitions: list) prints matched regex patterns only. -di (definitions: info) adds descriptions and examples from the @Given/@When/@Then docblocks, which tell you what the step expects and how it behaves.
php bin/behat -dl -s OroUserBundle # pattern list
php bin/behat -di -s OroUserBundle # patterns + docblocks + examples
php bin/behat -dl # all suites (noisy)Filter with grep — the lists are long:
php bin/behat -dl -s OroUserBundle | grep -i "flash message"
php bin/behat -dl -s OroCustomerBundle | grep -i "login"
php bin/behat -dl -s OroProductBundle | grep -i "grid"The -s flag restricts to a single suite. Suite names come from behat.yml (or project-specific behat-*.yml). Without -s, Behat lists every registered suite's steps, which is useful when you don't know which bundle owns a step.
Generating Missing Step Skeletons
When a feature file references steps that don't exist yet, Behat normally errors out with "undefined step". Let it write the skeletons instead:
php bin/behat path/to/your.feature --dry-run --append-snippets --snippets-type=regex--dry-runparses steps without executing them (and without booting the browser).--append-snippetswrites stubs for undefined steps into the Context class of the matching suite.--snippets-type=regexuses regex patterns rather than turnip. Oro conventions use regex throughout — don't mix the two.
Review the generated Context before committing. The stubs have a throw new PendingException() body you need to replace.
Verbosity Progression
Escalate one level at a time — more verbose output drowns the signal if you jump straight to -vvv.
| Flag | What it adds |
|---|---|
| (none) | Pass/fail lines, failure stack trace |
-v | Matched step definition path for each step — "which PHP method ran for this step?" |
-vv | + hook execution (@BeforeScenario, @AfterStep, isolator calls) — tells you which hook is slow or throwing |
-vvv | + full step matcher trace, regex candidates considered, priority resolution — needed when the wrong step definition wins an overlap |
Pairing -vv with --stop-on-failure is the highest-signal combination when chasing one specific failure: you see every hook that ran before the failure plus the exact step method that blew up.
Tag Filtering
Behat's tag expression language:
php bin/behat --tags=@smoke # only @smoke scenarios
php bin/behat --tags="@smoke&&@checkout" # both tags
php bin/behat --tags="@smoke,@checkout" # either tag (comma = OR)
php bin/behat --tags="~@wip" # exclude @wip
php bin/behat --tags="@smoke&&~@flaky" # @smoke except @flakyTags live on scenarios or whole features. @wip is a convention for scenarios in progress — excluding it from CI keeps half-written work out of the build. Use @smoke for critical-path scenarios you want to run first or standalone.
Dry-Run Without Snippet Generation
--dry-run alone is useful for validating feature files — it parses them and resolves step references without booting the browser or touching the DB. Fast sanity check before committing a new feature.
php bin/behat path/to/new.feature --dry-runPair with -v to see which step definition each line resolves to without actually running anything.
v6.1 Notes — Behat Debugging
Oro Commerce 6.1 is the current stable target for this project.
Runtime
- Behat 3.x under
bin/behat, PHP 8.1+ required. behat_testenvironment boots a dedicated kernel. Do not `cache:clear behat_test` independently — it corrupts the prepared fixture state. Re-runbehat-installfrom scratch instead.- Isolators run between scenarios to reset DB, cache, mailer, fixtures, pricing CPLs. A hang in
[Isolator]lines usually means one of them is waiting on a dependency that already died (Redis, Postgres, message queue).
Verbosity & Output
-v/-vv/-vvvbehave as documented upstream.--stop-on-failureplus-vvis the highest-signal combination for a single failing scenario.- Screenshots are written under
media/behat/and printed as file URLs in the runner output.ScreenshotTrait::takeScreenshotis the implementation — custom element classes can call it from inside their own methods when a locator fails.
Split-Process Xdebug
- Projects that split the Behat runner and PHP-FPM into separate containers apply this directly. The split-process technique in
xdebug-split.mdapplies directly. - PHPStorm run configurations for both processes live under
.idea/runConfigurations/— check there before rolling your own.
Known Quirks
And I wait for actionusesfgets(STDIN). Underdocker compose runwithout-Tthe prompt prints but input never arrives; usedocker compose execagainst an already-running container when you need the pause interactively.waitForAjaxonly tracks jQuery's active request queue ($.active). Storefront code that usesfetch()directly bypasses it — seeajax-flake.md.- The
OroFeatureContext::spin()method is the idiomatic way to poll for a DOM predicate without introducing hard-coded sleeps.
v7.0 Notes — Behat Debugging
Placeholder for Oro Commerce 7.0 deltas. The upstream master-branch documentation for Debug Behat Tests shows no material changes to the debugging page at the time of writing: same verbosity flags, same I take screenshot / I wait for action step semantics, same -dl/-di/--append-snippets switches.
Potential Deltas to Watch
- PHP baseline moves forward (8.2+ expected). Xdebug 3 configuration itself is unchanged, but
XDEBUG_MODEcomposition may gain new modes — verify against the installed extension when upgrading. - Behat/Mink major version bumps could change snippet generation output; re-run
--dry-run --append-snippetsagainst a throwaway feature after upgrading to confirm the stubs still land in the right Context. - Any session driver swap (Selenium → WebDriver BiDi) would invalidate the
XDEBUG_SESSIONcookie trick inxdebug-split.md— check Mink docs if the split-FPM debugger stops triggering after an upgrade.
Update this file with concrete deltas when the project upgrades to 7.x.
Xdebug Split-Process Debugging
The Behat runner and the application under test are two PHP processes. To step through both test code and application code in one run you need two debug targets. This reference walks through the setup.
The Two Processes
| Process | What runs there | Typical location | Breakpoints |
|---|---|---|---|
| CLI runner | bin/behat, Contexts, step definitions, Elements, fixtures | toolbox container | Test-side PHP |
| PHP-FPM | Controllers, services, event listeners, repositories | app or php-fpm container | Application PHP |
Mink drives a browser (Chromedriver/Selenium) that sends HTTP to FPM. The CLI runner never sees those requests — it only sees the assertions you write after them.
Enabling Xdebug on Each Process
CLI runner
Enable Xdebug only for the invocation you care about:
XDEBUG_MODE=debug XDEBUG_SESSION=1 XDEBUG_CONFIG="client_host=host.docker.internal client_port=9003" \
php bin/behat path/to/feature.featureXDEBUG_SESSION=1 is the trigger Xdebug 3 uses when start_with_request=trigger. Keeping Xdebug off for normal test runs avoids the per-step performance tax.
PHP-FPM
Xdebug needs to already be loaded inside the FPM container, with start_with_request=trigger. The activation trigger is the XDEBUG_SESSION cookie on the incoming request. Set it from a Context step so every Mink request inside the scenario carries it:
/**
* @BeforeScenario @debug
*/
public function enableXdebugForScenario(): void
{
$this->getSession()->setCookie('XDEBUG_SESSION', 'PHPSTORM');
}Tag scenarios @debug to opt in. Without tagging, every scenario triggers Xdebug on FPM, which slows the whole suite.
IDE Listener Configuration
Two simultaneous debug targets = two listeners, each on a distinct port:
- Port 9003 — default. Point the CLI runner at it.
- Port 9004 (or any free port) — point FPM at it via
xdebug.client_port=9004in the container's Xdebug config.
In PHPStorm: open Run > Edit Configurations > PHP Remote Debug twice, one per port, both with the same IDE key (e.g. PHPSTORM) so either side can attach. Start both listeners before running Behat.
Alternative: same port for both, but then you can only pause in one process at a time — the second connection waits. Fine for most debugging, limiting when a Context and a controller interact in one request.
Path Mapping
The container sees code at (e.g.) /var/www/html/src/... while the IDE sees /home/you/project/src/.... Xdebug sends file paths from the container; the IDE has to translate them to local paths before resolving breakpoints. Per-target mapping:
- CLI runner container: map
/var/www/html→$PROJECT_DIR$ - FPM container: map
/var/www/html→$PROJECT_DIR$
Paths can differ between containers (bind mounts, overlayfs) — verify realpath inside each container matches what the IDE configured.
Silent-Failure Checklist
When nothing fires:
1. Is Xdebug actually loaded in that process? php -v inside the container, check for the Xdebug line. FPM may need php-fpm -i (or the admin status page) instead — CLI and FPM load different INI files. 2. Is `xdebug.mode` set to `debug`? develop alone is not enough. 3. Is the trigger present? CLI: XDEBUG_SESSION=1 env var. FPM: XDEBUG_SESSION cookie on the request (inspect in browser devtools or via dump($_COOKIE)). 4. Is the client host reachable? From inside a Linux container, host.docker.internal requires extra_hosts: ["host.docker.internal:host-gateway"] in compose. Test with nc -zv host.docker.internal 9003. 5. Is path mapping right? Set a breakpoint on the first line of a file you know is hit (e.g. bin/behat or a controller's __invoke). If that doesn't trigger, the mapping is wrong. 6. Is another IDE session holding the port? Xdebug only attaches once per port; a stale PHPStorm instance will steal every connection.
Cookie Propagation Gotcha
setCookie on Mink writes to the browser session, which means the cookie is gone after the browser is reset between scenarios. Setting it in @BeforeScenario rather than @BeforeSuite is deliberate — a suite-level hook runs once and the cookie disappears as soon as the first scenario ends.
For ad-hoc debugging without modifying Context code, add a one-shot Gherkin step you delete before commit:
Given I set cookie "XDEBUG_SESSION" to "PHPSTORM"Most Oro projects already ship a step like this via OroTestFrameworkBundle — check php bin/behat -dl | grep -i cookie.