Skip to content

Early Adopter Offer:Get 40% off Core & Scale for your first year with code EARLYQFView pricing

What Maestro's JUnit XML Leaves Out of a Test Report

Maestro's JUnit XML carries one line per flow. Here is what it drops — steps, screenshots, failed runs — and how to report a Maestro run in full.

İbrahim Süren
Founder · Sep 20, 2026 · 8 min read
What Maestro's JUnit XML Leaves Out of a Test Report
Get Qualflare updates

Product news and testing tips.

Maestro's `--format junit` writes one `<testcase>` per flow: a name, a status, a duration, a failure message, and the flow's own properties. What it does not carry is the inside of the flow — the commands that ran, their timing, and the screenshots Maestro took of the failed step all sit in Maestro's debug output instead. Worse, a run that dies before Maestro finishes — invalid YAML, no connected device — writes no XML at all, so the failure never reaches your dashboard. Reporting a Maestro run in full means reading the debug output alongside the XML.

Key takeaways

  • Maestro's JUnit XML is flow-level only: one <testcase> per flow, with no representation of the commands inside it.
  • Screenshots of failed steps exist on disk under --debug-output, but nothing in the XML references them.
  • A run that fails before results are written — bad YAML, no device — produces no report file, so CI shows a red build and the dashboard shows nothing.
  • JUnit XML rounds Maestro 2.6.x durations to whole seconds; the per-command timestamps in the debug output are millisecond-precision.
  • Flow tags and properties DO survive: Maestro writes them as <property> elements inside each <testcase>.
  • Maestro has no reporter or listener API, so anything richer has to wrap the maestro test command rather than plug into it.

Maestro writes JUnit XML natively — maestro test --format junit --output report.xml .maestro/ — and that file is genuinely useful: it is the standard every CI system reads, and it needs no conversion step. What it is not is a record of what happened. A JUnit <testcase> has a name, a status, a time and an optional failure message, and Maestro fills exactly those fields, once per flow. A flow that tapped through eight screens and failed on the ninth arrives as one line with one message.

Everything else Maestro knows about the run is in its debug output, a separate directory you have to ask for. This post is about the gap between the two, measured against real output from Maestro 2.6.1 and 2.10.0, and what closing it takes.

What one flow looks like in the XML

Here is the shape, with a passing and a failing flow:

<testsuites>
  <testsuite name="Test Suite" device="iPhone 17 - iOS 26.5 - 089E029C-…" tests="2" failures="1" time="36.0">
    <testcase id="Settings fails on purpose" name="Settings fails on purpose"
              classname="Settings fails on purpose" file="flows/settings-fails.yaml"
              time="23.0" status="ERROR">
      <properties>
        <property name="tags" value="probe"/>
      </properties>
      <failure>Assertion is false: "This Text Does Not Exist 12345" is visible</failure>
    </testcase>
    <testcase id="Settings opens" name="Settings opens" classname="Settings opens"
              file="flows/settings-opens.yaml" time="13.0" status="SUCCESS">
      <properties>
        <property name="qualflare.priority" value="high"/>
        <property name="tags" value="smoke, probe"/>
      </properties>
    </testcase>
  </testsuite>
</testsuites>

That is a real report from Maestro 2.6.1, and it is the whole record of a two-flow run. Credit where it is due: the file attribute points at the flow, and <properties> carries the flow’s tags and anything else its YAML header declared — so tags, ownership and custom metadata do survive into the XML.

Two things about the numbers, though. time="23.0" is rounded to a whole second, so a 23.4-second flow and a 23-second flow are indistinguishable on 2.6.x; Maestro 2.10 writes millisecond precision and populates timestamp, which 2.6.x leaves empty. And the device string is suite-level, so with sharded runs across several devices the per-flow results carry no device of their own.

The three things that are missing

1. The commands the flow ran

A Maestro flow is a list of commands, and the XML represents none of them. With --debug-output they are all there, in a commands JSON file per flow, each entry carrying the raw command, its status, a timestamp and a duration in milliseconds. That is the difference between “Settings fails on purpose — ERROR, 23 s” and what the debug output actually recorded for that flow: launchApp completed in 3,849 ms, then the assertVisible failed after 17,803 ms. The same run also shows a tapOn on another flow with status WARNED and a null duration — an optional command that did not match, which the XML reports as nothing at all because its flow still passed.

Two details matter if you ever read those files yourself:

  1. The first entry is not a user command. Every flow starts with a defineVariablesCommand whose raw form contains the real values of every --env variable and every MAESTRO_* variable Maestro copied from the environment — on 2.10 including the device UDID and the flow’s absolute filename. Anything reading these files should drop it rather than report it.
  2. Nesting only exists on 2.10+. There, each entry carries a depth, so commands inside runFlow, repeat and retry can be attributed to their parent. On 2.6.x the entries are a flat list sorted by sequence number, and the structure is unrecoverable.

2. The screenshots Maestro already took

Maestro screenshots a failed step by itself. On 2.6.1 the file lands beside the commands JSON, named with a cross, a millisecond timestamp and the flow name; on 2.10+ each flow gets a folder and every step lists its own screenshots in metadata, which removes the filename-matching guesswork. In both cases the JUnit XML does not mention them, so a dashboard fed only the XML shows a failure with no picture of the screen it failed on — while the picture sits in the CI workspace until the job is cleaned up.

3. The runs that produce no file at all

This is the one that actually hurts. Maestro writes the report when a run finishes. If the run does not get that far, there is no file:

What went wrongMaestro’s exit codeWhat the XML says
A flow fails an assertion1The failure, as one line
Invalid flow YAML1Nothing — no file written
No device or simulator connected1Nothing — no file written
The iOS driver never starts1Nothing — no file written

Every one of those is exit code 1, so CI turns red correctly. But an upload step that runs if: always() finds nothing to upload, and the dashboard has no record that the run happened. The build most in need of investigation is the one that leaves no trace. It is worth wiring your pipeline so that case is visible, because mobile CI produces plenty of it — Bitrise’s 2025 Mobile Insights Report found the share of teams experiencing any test flakiness grew from 10% to 26% across its 2022–2025 data window, and a driver that fails to start on a cold CI emulator is exactly that kind of noise.

Why you cannot fix this with a plugin

The obvious answer would be a Maestro reporter plugin. There isn’t one to write: Maestro’s output formats are a fixed list inside the CLI, and there is no listener or extension API that loads your code into a run. Maestro’s own docs describe the report formats as options on the command, not as an extension point, and the source agrees.

So the only way in is to wrap the command: own the output flags, run maestro test yourself, and read both the XML and the debug output when it exits. Three constraints make or break that approach:

  1. Pass the exit code through unchanged. A wrapper that swallows Maestro’s exit code turns a failing suite into a green build.
  2. Choose the debug directory yourself. --debug-output <dir> --flatten-debug-output puts the files somewhere predictable; a flow config’s own testOutputDir is overridden by flattening, which is what makes the location deterministic.
  3. Handle both layouts. 2.6.x flat files and 2.10+ per-flow bundles are different enough that layout detection has to be based on what is actually on disk, not on a version string.

Reporting a Maestro run in full

That wrapper is what qualflare-maestro is — open source, Apache-2.0, one Go binary with no dependencies. It goes in front of the command you already run:

brew install qualflare/tap/qualflare-maestro
qualflare-maestro -- maestro test .maestro/
qf my-project collect ./qualflare-results

What that recovers, item for item against the list above: a step per command named from the flow’s own YAML and nested on 2.10+; Maestro’s screenshots attached to the step that took them; per-command durations instead of a rounded per-flow number, and per-flow start times where 2.6.x gives none; and an [unattributed failure] case carrying the end of Maestro’s log when a run dies before writing results, so the invisible failures stop being invisible.

Two honest limits, since they follow from the same design. Because the reporter owns --output, a single run cannot also leave a JUnit XML file for something else to render — GitLab’s Tests tab, for instance; run Maestro directly for that. And because it wraps a local command, it cannot report a run executed on someone else’s infrastructure, such as Maestro Cloud; there, the XML path is what you have. Both are documented in the repository’s limitations, along with what redaction does and does not cover.

Qualflare is our product, and the reporter is the free, open-source part of it: it writes a report directory to disk and makes no network calls, so it is usable on its own terms. Where results go afterwards is a separate decision.

What to do with this

  • If you only need a pass/fail record in CI, Maestro’s JUnit XML is fine, and --format junit is one flag. Nothing here argues otherwise.
  • If a failure sends someone digging through CI artifacts, the debug output already holds what they need — the commands, their timing, the screenshot — and something has to read it.
  • If runs sometimes vanish from your reporting, check whether they are the runs that died before Maestro wrote a file. That class of failure needs handling explicitly, whatever tool you use.

For how Maestro compares to the other cross-platform option, see Maestro vs Appium. For getting Android, iOS and cross-platform results into one place, see unified Android + iOS test reporting, and for the CI wiring specifically, the Maestro test reporting guide. If flows pass locally and fail on CI emulators, fixing flaky mobile tests covers the causes, and the flaky test and JUnit XML glossary entries define the terms used here.

Ready to ship with confidence?

Start free with Qualflare's AI-powered test management.