DEV Community

Cover image for TestFly Part 14: Step traces with per-step ARIA/DOM snapshots for visual debugging
Hakan GÜL
Hakan GÜL

Posted on Edited on Originally published at hakangul.lovable.app

TestFly Part 14: Step traces with per-step ARIA/DOM snapshots for visual debugging

TestFly 1.1.0 adds per-step snapshots to the trace. Each step records the accessibility tree as it looked when the step started (or, with dom/both, the pruned page HTML). A failed test also records the page after its last step and shows a before/after diff. Three new config keys, tracing.snapshots, tracing.snapshotMaxKb and tracing.snapshotOnFailureAfter, control what is captured. StepRecord carries the new fields, and TraceRecorder.saveIfEnabled writes the trace from TestNG, JUnit 5 and now Cucumber. The HTML report links to the trace from every test.

What a step trace is and why every step gets a snapshot

A trace is one self-contained HTML file, target/traces/{ClassName}/{testMethod}-trace.html, that replays a test's step timeline: step names, offsets, screenshots, the final failure screenshot, the error and stack trace, and the video if recording is on. It opens in any browser without a server.

Screenshots show what the page looked like. They don't show what was in the DOM: whether a button was disabled, whether an element had aria-hidden, or whether the markup you expected was there at all. Per-step snapshots fill that gap. Next to each step's screenshot, the trace now embeds a text snapshot of the page exactly as StepSnapshotCapture saw it when the step began.

# testfly.yml
tracing:
  enabled: true
Enter fullscreen mode Exit fullscreen mode

With tracing on, every step gets a snapshot by default, and the test needs no extra code.

What each step's snapshot contains

StepSnapshotCapture.captureForStep() runs once per step. It checks tracing.enabled, reads the tracing.snapshots mode, and touches the browser only if a driver session is alive. API and database tests, and steps logged before a browser exists, get no snapshot:

// StepSnapshotCapture.java (simplified)
public static Snapshots captureForStep() {
    TestFlyConfig.Tracing tracing = tracing();
    if (tracing == null || !tracing.isEnabled()) {
        return Snapshots.NONE;
    }
    String mode = tracing.getSnapshots();
    boolean aria = mode.equals("aria") || mode.equals("both");
    boolean dom  = mode.equals("dom")  || mode.equals("both");
    WebDriver driver = liveDriver();
    if (driver == null) {
        return Snapshots.NONE;
    }
    int maxKb = tracing.getSnapshotMaxKb();
    return new Snapshots(aria ? captureAria(driver, maxKb) : null,
                          dom  ? captureDom(driver, maxKb)  : null);
}
Enter fullscreen mode Exit fullscreen mode

StepRecord already held a step's name, offset, status and screenshot. It now has two more fields, plus a third that is filled in later with a failed test's final state:

// StepRecord.java
public StepRecord(String name, long offsetMs, String status, String screenshotBase64,
                   String ariaSnapshot, String domSnapshot) { ... }

public String getAriaSnapshot()      { return ariaSnapshot; }
public String getDomSnapshot()       { return domSnapshot; }
public String getAfterAriaSnapshot() { return afterAriaSnapshot; } // set only on failure
Enter fullscreen mode Exit fullscreen mode

aria captures the same YAML text that ariaSnapshot() prints. It is small, readable and unaffected by changing CSS class names. dom captures the page HTML after DomPruner strips scripts, styles and decoration. It is bigger, but it shows markup the accessibility tree leaves out, such as a CSS class you're debugging.

Configuring what gets captured

# testfly.yml
tracing:
  enabled: true
  snapshots: both             # none | aria | dom | both — default: aria
  snapshotMaxKb: 256           # longest snapshot kept per step, in KB — default: 256
  snapshotOnFailureAfter: true # also snapshot the page after the last step of a failed test
Enter fullscreen mode Exit fullscreen mode

TestFlyConfig validates tracing.snapshots when the config loads. Any value other than none, aria, dom or both throws IllegalArgumentException at load time instead of halfway through a run. A snapshot longer than snapshotMaxKb is cut and ends with … truncated (N more characters), so one verbose page can't bloat the whole trace file.

What the trace HTML shows

Clicking a step in the timeline opens the screenshot panel with two tabs, ARIA (before step) and DOM (before step). On the last step of a failed test there are two more: ARIA (after failure) and a computed Diff tab. The diff is a plain line-based set difference between the before and after YAML, computed client-side by the trace's inlined JavaScript (lineDiff() in TraceRecorder). Lines only in the "before" snapshot are marked -, and lines only in "after" are marked +. There is no external diff library and no network call, so the trace stays a single file you can email or drop in Slack.

Step blocks in the timeline show a small 🌳 marker when they carry a snapshot, next to the 📷 marker for a screenshot, so you can see which steps have something to open.

Performance and failure risk

Capturing a snapshot takes one script call per step, a few milliseconds on a typical page, and it only runs when tracing.enabled is on. StepSnapshotCapture wraps every capture in a try/catch that returns null on failure instead of propagating the exception. A page that can't be read, a closed driver or a flaky script call leaves that step without a snapshot and does not fail the test. TraceRecorder.saveIfEnabled is wrapped the same way:

// TraceRecorder.java
@TestFlyApi(since = "1.1.0")
public static void saveIfEnabled(String testId, String testName, boolean isPassing) {
    try {
        TestFlyConfig.Tracing tracing = TestFlyContext.getConfig().getTracing();
        if (tracing == null || !tracing.isEnabled()) return;
        if (isPassing && !tracing.isCaptureOnPass()) return;
        save(testId, testName);
    } catch (Exception ignored) {
    }
}
Enter fullscreen mode Exit fullscreen mode

Cucumber support

As of 1.1.0, CucumberHooks calls the same trace code that TestNG and JUnit 5 use. Gherkin scenarios get a trace file with snapshots, and your feature files and step definitions stay as they are. The HTML test report links to each test's trace from its detail panel, so one click takes you from a red row in the report to the step and snapshot that show what went wrong.

FAQ

Do I need to add code to my tests to get per-step snapshots?

No. With tracing.enabled: true, every step captures a snapshot automatically according to tracing.snapshots (default aria). There is no per-step API to call.

What happens if a test has no browser, like an API test?

Nothing is captured. StepSnapshotCapture checks for a live WebDriver session before taking a snapshot and skips the capture if there is none. It never creates or recreates a driver just to take a snapshot.

Can a snapshot capture failure cause my test to fail?

No. Every capture path is wrapped, so an exception is swallowed and logged at FINE level instead of being rethrown. Snapshots are best-effort and never affect the pass/fail result.

Is the diff tab a real structural diff of the accessibility tree?

No. It is a line-based set difference between the "before" and "after" ARIA YAML text, computed in the trace's own inline JavaScript. That is enough to spot what appeared or disappeared, but it does not compare the trees semantically.

Top comments (0)