Playwright Traces per Step
Browser tests fail for reasons a stack trace rarely explains. This example wires Playwright tracing to SpecBinder's execution boundaries so that every Gherkin step gets its own self-contained trace zip — screenshots, DOM snapshots, network, console — and the path to that zip is stamped onto the step in the JSON execution report.
The payoff: open the spec in IntelliJ, click a step, and watch that step replay.
Source: examples/going-further/example-8 on GitHub.
What this demonstrates
- The
ExecutionBoundaryListenerSPI — observing feature, rule, scenario and step boundaries as they happen - Playwright's chunked tracing: one recording per feature, sliced into one zip per step
- Recording arbitrary data onto the in-flight step via
SpecBinderReporter.recordPublishedEntry - The reserved
specbinder.playwright.tracekey that lets the IntelliJ plugin find a step's trace - Scenario Outline rows getting a folder each, so rows don't overwrite one another
How it fits together
SpecBinderReporter ──raises──> ExecutionBoundaryListener
│
│ scenarioStarted / stepStarted / stepFinished
▼
PlaywrightTraceListener
│ │
startChunk/ │ │ recordPublishedEntry(
stopChunk ─────┘ └── "specbinder.playwright.trace" → path)
│ │
▼ ▼
one zip per step the step's entry in the JSON report
Activation
Two test dependencies — the reporter (which supplies both the JSON report and the SPI) and Playwright itself:
<dependency>
<groupId>dev.specbinder</groupId>
<artifactId>execution-reporter</artifactId>
<version>2026.47.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.microsoft.playwright</groupId>
<artifactId>playwright</artifactId>
<version>1.54.0</version>
<scope>test</scope>
</dependency>
The listener
This is the whole integration. It implements ExecutionBoundaryListener, which the reporter raises
in strict nesting order — featureStarted → scenarioStarted → stepStarted / stepFinished — and
whose methods are all defaulted, so you override only what you need:
public final class PlaywrightTraceListener implements ExecutionBoundaryListener {
private final BrowserContext browserContext;
private final Path baseDir;
private String scenarioFolder;
private int stepOrdinal;
private Path currentChunk;
@Override
public void scenarioStarted(ScenarioBoundary scenario) {
String folder = scenario.testMethodName() != null ? scenario.testMethodName() : "scenario";
if (scenario.exampleRowIndex() != null) {
folder += String.format("_ex_%02d", scenario.exampleRowIndex());
}
scenarioFolder = folder;
stepOrdinal = 0;
}
@Override
public void stepStarted(StepBoundary step) {
currentChunk = baseDir.resolve(scenarioFolder)
.resolve(String.format("%02d_%s.zip", ++stepOrdinal, step.methodName()));
SpecBinderReporter.recordPublishedEntry(
Map.of(PLAYWRIGHT_TRACE_ENTRY_KEY, currentChunk.toString()), Instant.now());
browserContext.tracing().startChunk();
}
@Override
public void stepFinished(StepBoundary step) {
Path chunk = currentChunk;
currentChunk = null;
browserContext.tracing().stopChunk(new Tracing.StopChunkOptions().setPath(chunk));
}
}
Three things in there are worth calling out.
Chunked tracing. tracing().start(...) runs once for the whole feature. Each step is then a
startChunk() / stopChunk(setPath(zip)) pair inside that one recording. Each zip is a complete,
independently-openable trace rather than a slice you have to scrub to.
The path is recorded at stepStarted, not stepFinished. recordPublishedEntry attaches to
whichever step is in flight, and the reporter has already cleared that by the time the step
finishes. So the path goes in before the zip exists — stopChunk writes the file moments later,
long before anything reads the report.
PLAYWRIGHT_TRACE_ENTRY_KEY is not yours to invent. It's a constant on
ExecutionBoundaryListener ("specbinder.playwright.trace"), defined in the reporter module
precisely so that this producer and its consumers — notably the IntelliJ plugin — agree on one name
instead of duplicating a magic string across repositories.
Wiring the browser
The rest is ordinary JUnit. An extension owns the browser, starts the trace, and registers the listener:
public class PlaywrightTracing implements BeforeAllCallback, AfterAllCallback {
@Override
public void beforeAll(ExtensionContext context) {
playwright = Playwright.create();
browser = playwright.chromium().launch();
browserContext = browser.newContext();
page = browserContext.newPage();
browserContext.tracing().start(new Tracing.StartOptions()
.setScreenshots(true)
.setSnapshots(true)
.setSources(true));
traceListener = new PlaywrightTraceListener(browserContext, Path.of("target", "playwright-traces"));
SpecBinderReporter.addBoundaryListener(traceListener);
}
@Override
public void afterAll(ExtensionContext context) {
SpecBinderReporter.removeBoundaryListener(traceListener);
browserContext.tracing().stop(); // no path: the chunks already own the output
// …close page, context, browser, playwright
}
}
Both extensions go on the marker class, and because @ExtendWith is @Inherited, that one
annotation covers every generated …Scenarios subclass JUnit runs:
@Gherkin2JUnit("specs/CartTrace.specb")
@Gherkin2JUnitOptions(shouldBeAbstract = false)
@ExtendWith({SpecBinderReporter.class, PlaywrightTracing.class})
public abstract class CartTraceFeature {
public void iOpenTheCartPage() {
page().navigate(CART_PAGE);
}
public void iAdd$p1ToTheCart(String item) {
page().click("[data-add='" + item + "']");
}
public void theCartTotalShouldBe$p1(Double expected) {
assertThat(page().locator("#total")).hasText(String.format("%.2f", expected));
}
}
Running it
cd examples/going-further/example-8
mvn test
The steps drive a small bundled cart.html, so there's no server to start and nothing to reach over
the network. If Playwright reports a missing browser:
mvn exec:java -e -Dexec.mainClass=com.microsoft.playwright.CLI -Dexec.args="install chromium"
One scenario fails on purpose — a step asserting the wrong total — because the trace of a red step is the interesting one.
What lands on disk
target/
├── playwright-traces/
│ ├── scenario_2/
│ │ ├── 01_iOpenTheCartPage.zip
│ │ ├── 02_iAdd$p1ToTheCart.zip
│ │ └── 03_theCartTotalShouldBe$p1.zip
│ ├── scenario_6_ex_01/ ← Scenario Outline rows get a folder per row
│ └── …
└── specbinder-reports/specs/CartTrace.specb.json
25 zips for 8 scenarios. Open any one on its own:
npx playwright show-trace target/playwright-traces/scenario_5/03_theCartTotalShouldBe\$p1.zip
Chunk paths come straight from ScenarioBoundary. testMethodName() is already rule-qualified and
unique within the class (e.g. rule_1_scenario_2), so it needs no separate rule segment. Outline
rows share one method name, so exampleRowIndex() is appended as _ex_NN.
In the report
Each step carries its trace next to its outcome. The failing step, in full:
{
"methodName" : "theCartTotalShouldBe$p1",
"text" : "Then the cart total should be \"99.99\"",
"arguments" : [ { "type" : "simple", "value" : 99.99 } ],
"status" : "failed",
"error" : {
"message" : "Locator expected to have text: 99.99\nReceived: 0.75",
"expected" : "99.99",
"actual" : "0.75"
},
"publishedReporterEntries" : [ {
"values" : {
"specbinder.playwright.trace" : "target/playwright-traces/scenario_5/03_theCartTotalShouldBe$p1.zip"
}
} ]
}
The verbatim text and typed arguments come free with emitScenarioHash, which is on by default
— see Execution Reporter.
Reading it in the IDE
This is the half that makes the wiring worth it. With the SpecBinder IntelliJ plugin installed, opening the spec after a run puts a collapsed row under each traced step:
✓ Then the cart total should be "99.99"
│ ▸ Playwright trace — 03_theCartTotalShouldBe$p1.zip ↗
Clicking the file name expands Playwright's viewer inline in the editor; the ↗ opens the same trace full-size in Playwright's hosted viewer, served over a loopback address so the recording never leaves your machine.
The panels are off by default — switch them on under Settings → Tools → SpecBinder → Execution results. See Playwright traces in the plugin docs for the full behaviour.
Running in parallel
PlaywrightTraceListener keeps its scenario folder, step counter and in-flight chunk in plain
fields, which assumes one feature recording at a time. That holds for this example and for any
sequential suite.
If you run test classes concurrently, move that state into a ThreadLocal scoped per thread —
otherwise chunks from different features interleave into the wrong trace. The boundary callbacks
fire on whichever thread is executing the test, and the reporter deliberately does not serialise
them across threads.