Skip to content

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

TestNG test reporting

TestNG is one of the few frameworks that ships a real report — test-output/index.html exists without a single plugin. It is also a folder on a CI worker that is deleted when the job ends. Qualflare turns those runs into hosted, historical reporting: every @DataProvider row as its own case, AI clustering of failures by root cause, and flaky scores that survive retryAnalyzer — through a native TestNG reporter that keeps every retry attempt instead of the one the XML survives with.

What TestNG writes on its own

Run a suite and TestNG populates an output directory — test-output/ by default. This is genuinely more than most frameworks give you, and it is why TestNG users are often surprised to learn they need anything else:

test-output/
├── index.html              # the browsable report
├── emailable-report.html   # single-file summary, made to be mailed
├── testng-results.xml      # TestNG's OWN schema — not JUnit XML
├── testng-failed.xml       # just the failures, re-runnable as a suite
└── junitreports/
    └── TEST-*.xml          # JUnit-format XML — this is the one to upload
  • index.html. The browsable report — suites, groups, timings, and the parameters each method was invoked with.
  • emailable-report.html. A single self-contained file, designed for exactly the workflow its name suggests. Still a point-in-time snapshot.
  • testng-results.xml. TestNG’s own schema, and richer than JUnit XML — it carries groups, parameters and per-invocation detail that the JUnit format has no vocabulary for. It is also non-standard, which is the trade-off.
  • testng-failed.xml. A suite file containing only what failed, so you can re-run just the failures. Useful locally; a trap in CI, because re-running only failures makes a red build go green without anything being fixed.
  • junitreports/TEST-*.xml. A JUnit-format copy. Less detailed, universally readable — and the file every downstream tool actually wants.

Why test-output/ isn’t enough

It is per-run and it is local. Every execution overwrites the last, so the directory only ever holds the most recent answer — “has checkoutWithExpiredCard been getting flakier this month” is not a question index.html can answer, because last month’s copy no longer exists. In CI it is worse: the directory lives on an ephemeral worker and is gone when the job finishes unless you remember to archive it, at which point you have a zip file nobody opens.

For history you need something that stores results over time and analyzes them.

Send TestNG results to Qualflare

Add the reporter as a test dependency. That is the whole setup — it registers itself through TestNG’s ServiceLoader support, so there is no @Listeners annotation, no -listener flag and no testng.xml to edit.

<!-- pom.xml -->
<dependency>
  <groupId>com.qualflare</groupId>
  <artifactId>qualflare-testng</artifactId>
  <version>0.1.0</version>
  <scope>test</scope>
</dependency>

Run your suite as usual. The reporter writes a report directory and makes no network calls; the CLI uploads it:

mvn -B test        # the reporter writes qualflare-results/
qf my-project collect ./qualflare-results

Requires TestNG 7.4.0+ and Java 11+. The floor is not arbitrary: ITestResult.wasRetried() has to exist, and the whole retry story below depends on it.

Without the reporter: uploading XML

If you cannot add a dependency, Qualflare still reads JUnit-format XML — you just get less. One detail matters, because the obvious file is the wrong one: upload the JUnit-format XML, not testng-results.xml. TestNG’s native file uses its own schema — <testng-results>, <test-method> — while the parser reads the JUnit schema of <testsuites> and <testcase>. On Maven, Surefire already writes the right thing with no TestNG configuration at all:

# Surefire's JUnit-format XML — the usual Maven path
qf my-project collect target/surefire-reports/ --format testng

# Or TestNG's own JUnit-format output, if you enabled JUnitReportReporter
qf my-project collect test-output/junitreports/ --format testng

In CI that’s one added step. Authenticate the CLI once with your Qualflare access token, stored as a CI secret — see the CLI docs. GitHub Actions is shown; GitLab CI, Bitbucket Pipelines and Jenkins work the same way.

# .github/workflows/tests.yml
- name: Run tests
  run: mvn -B test

- name: Upload results to Qualflare
  if: always() # upload even when tests fail — that's the point
  run: qf my-project collect ./qualflare-results

The if: always() line is the one people leave out. Without it the upload step is skipped whenever the test step fails, so the only runs that ever reach the platform are the green ones — and failure history, which is the entire point, stays empty.

What you get on top of test-output/

  • Every retry attempt, not just the last one. A test that fails twice and passes on its third retryAnalyzer attempt arrives as one case with three attempts — two failed, one passed — and is marked flaky on the spot. The XML cannot show you that, because it does not contain it.
  • Data-provider rows as first-class cases. Each invocation keeps its own status, duration and history instead of being averaged into its method.
  • AI failure clustering. When one broken fixture or a downed dependency takes out 40 tests, Qualflare groups them by root cause — you fix one thing instead of reading 40 stack traces.
  • Parallel and sharded runs merged. TestNG’s parallel modes and multi-job CI both land in one launch rather than one report per worker.
  • History, trends & defects. Pass rate, slowest tests, and flakiness over time across branches — plus a defect you can open straight from a failing run.

TestNG’s built-in reports vs Qualflare

  test-output/ Qualflare
History across CI runsYes
Flakiness visible through retryAnalyzerYes
Merges parallel & sharded jobs into one runYes
AI failure clustering (root cause)Yes
Groups & parameters in the native schemaYesPartly
Local, zero-setup, offlineYes

Complementary, and the “Partly” is deliberate: TestNG’s own XML carries group and parameter detail that the JUnit format cannot express, so keep test-output/ for local runs and add Qualflare for hosted, historical CI observability.

Get AI analysis on your TestNG runs

Start free — point qf collect at your Surefire reports and get your first AI analysis in minutes.

Get Started Free

Qualflare works the same with JUnit, pytest, RSpec, Playwright, Go and 20+ more frameworks. Testing APIs or load? See our Newman and k6 guides. Weighing tools? See how it compares to other test management platforms, or browse all framework reporting guides. Every reporter is open source.

Retries, data providers, and the flakiness they hide

TestNG is one of the few major frameworks with a genuine in-run retry mechanism. Implement IRetryAnalyzer, attach it to a test, and TestNG re-runs the method after each failure until your analyzer says stop:

public class Retry implements IRetryAnalyzer {
    private int attempt = 0;

    @Override
    public boolean retry(ITestResult result) {
        return attempt++ < 3;   // three tries, then the failure is final
    }
}

@Test(retryAnalyzer = Retry.class)
public void checkoutWithExpiredCard() { ... }

This is useful, and it is also the single biggest reason TestNG suites under-report their own flakiness — though not for the reason usually given. The failures are not merely overwritten by the final pass. They are relabelled. TestNG delivers a retried failure to onTestSkipped with wasRetried() set, and that is what reaches the file. Here is what Surefire actually writes for one test that failed twice and passed on the third attempt:

<!-- target/surefire-reports/TEST-*.xml -->
<testcase name="flakyRecovers" ...><skipped/></testcase>   <!-- actually FAILED -->
<testcase name="flakyRecovers" ...><skipped/></testcase>   <!-- actually FAILED -->
<testcase name="flakyRecovers" .../>                       <!-- passed -->

Two skips and a pass. No <failure> element anywhere, no stack trace, no record that anything went wrong — and a genuinely intermittent test disappears into a green build. It resurfaces later, usually as a production defect nobody connected to it. Any tool reading that XML is working from a file the evidence has already been removed from.

The native reporter reads the run rather than the file. It checks wasRetried() and records the attempt as the failure it was, so the same test arrives as one case with three attempts — failed, failed, passed — flagged flaky, with both stack traces intact. You see it the first time it happens, in a single launch, instead of inferring it from a pattern across weeks of history. Retries stay useful for keeping the pipeline moving; they stop being a way to lose the signal.

Second, data providers fan out. A @DataProvider returning three rows produces three invocations, and each arrives as its own case:

@DataProvider(name = "roles")
public Object[][] roles() {
    return new Object[][] { {"admin"}, {"guest"}, {"owner"} };
}

// Three invocations, three rows in the report — each with its own
// status, duration and history.
@Test(dataProvider = "roles")
public void canOpenDashboard(String role) { ... }

That is what you want, because flakiness lives at the row level. If only the guest row is intermittent, per-row history points straight at the parameter combination that is unstable, whereas a single averaged case tells you only that “canOpenDashboard is a bit flaky”. The same applies to invocationCount, which runs a method N times independently.

Third, a note on testng-failed.xml. Re-running only the failed suite is a fine local habit and a poor CI one: the re-run reports green, the original failures are not in the uploaded result, and the build passes without anything being fixed. If you use it in a pipeline, upload both runs.

Three things JUnit XML has no words for

Beyond retries, the JUnit schema simply lacks the vocabulary for parts of how TestNG runs. These are not losses of detail; they are distinctions the format cannot make at all.

  • A timeout is not a failure. TestNG raises onTestFailedWithTimeout separately, so a test that blew its time limit is a different event from one whose assertion failed. In XML both are <failure>; the reporter keeps timeout as its own status.
  • A broken @BeforeClass is not a test failure. When configuration fails, the tests it guarded never run. The reporter records the configuration failure itself as [config] YourClass#setUp, so the cause is visible rather than inferred from a cluster of skips.
  • Data-provider rows keep their arguments. Case identity is class#method(params), so the guest row stays distinct from the admin row across runs instead of three same-named cases that history cannot tell apart.

Optional: labels, steps and attachments

Because the reporter runs inside your tests rather than parsing their output afterwards, tests can say things about themselves that no report file could carry:

import com.qualflare.testng.Qualflare;

@Test
public void checksOut() {
    Qualflare.label("feature", "checkout");
    Qualflare.tag("smoke");
    Qualflare.priority(Qualflare.HIGH);
    Qualflare.link("https://jira/QF-1", Qualflare.ISSUE, "QF-1");

    Qualflare.step("add to cart", () -> {
        Qualflare.parameter("sku", "widget");
        Qualflare.maskedParameter("token");   // name recorded, value never
    });
}

Labels, tags, links, priority, description, parameters, nested steps and file attachments — plus maskedParameter, which records that a secret was involved without recording its value. There is no annotation and nothing to register; the API finds the running test through TestNG’s own thread-local. Nothing in it can fail your test: no method throws, and every call is inert when no reporter is listening, so the same code runs unchanged in a build that has never heard of Qualflare.

Frequently asked questions

How do I send TestNG results to Qualflare?

Add com.qualflare:qualflare-testng as a test-scoped Maven dependency, run your suite, and upload with qf my-project collect ./qualflare-results. That is the whole setup — the reporter registers itself through TestNG’s ServiceLoader support, so there is no @Listeners annotation, no -listener flag and no testng.xml to edit, and it makes no network calls of its own. If you cannot add a dependency, Qualflare still reads JUnit-format XML: upload target/surefire-reports/TEST-*.xml, not testng-results.xml, because the parser reads the JUnit schema (testsuites / testsuite / testcase) rather than TestNG’s own. You get less detail that way — notably, retry attempts are missing from the file entirely.

How does Qualflare handle TestNG’s retryAnalyzer?

TestNG delivers a retried failure to onTestSkipped with wasRetried() set, so the failures are not overwritten by the final pass — they are relabelled as skips. A test that failed twice and passed on the third attempt appears in Surefire’s XML as two skipped entries and one pass, with no failure element and no stack trace: the evidence is gone before any tool reads the file. The native qualflare-testng reporter reads the run instead of the file, checks wasRetried(), and records one case with three attempts — failed, failed, passed — marked flaky, with both stack traces intact. You see it in a single launch rather than inferring it from history.

Do I need to register the listener in testng.xml?

No. qualflare-testng registers itself through TestNG’s ServiceLoader support: the jar ships a META-INF/services/org.testng.ITestNGListener entry, and TestNG picks it up from the test classpath. Adding the dependency is the entire installation — no @Listeners annotation, no -listener flag, no testng.xml edit, and nothing to remove when you stop using it. It requires TestNG 7.4.0 or newer and Java 11 or newer.

Are @DataProvider invocations reported as separate tests?

Yes. Each data-provider row is its own case with its own status, duration and history, because that is how it reaches the XML. This matters for flaky detection: if only the "guest" row is intermittent, folding all three rows into one case averages that signal away, whereas per-row history points straight at the parameter combination that is actually unstable.

What is the difference between TestNG and JUnit for reporting?

TestNG ships reporting; JUnit needs tooling. Out of the box TestNG writes test-output/index.html, emailable-report.html and testng-results.xml with no plugin at all, whereas JUnit 5 produces no human-readable report on its own and relies on Surefire, Gradle or a separate reporter. The catch is that TestNG’s richer native format is also non-standard — every downstream CI tool speaks JUnit XML, which is why TestNG also emits a JUnit-format copy.

Does this work with Maven Surefire and Gradle?

Yes, and on Maven you usually need no TestNG configuration at all — Surefire already writes JUnit-format XML to target/surefire-reports/ for TestNG suites, so the only new step is the upload. Gradle’s Test task writes the equivalent to build/test-results/test/. Point qf collect at whichever directory your build produces.

Can I merge parallel TestNG runs into one report?

Yes. TestNG’s parallel attribute (methods, classes, tests, instances) still writes into a single output directory, so one qf collect covers the whole run. For suites split across several CI jobs, run collect once per job against the same run id — shards derive it from the CI build automatically, or you can pass --run-id — and Qualflare merges them into one launch rather than one launch per worker.

Setup reflects the Qualflare CLI (docs.qualflare.com) as of September 2026. TestNG behaviour — output files, IRetryAnalyzer, @DataProvider and invocationCount — follows TestNG's own documentation; the JUnit-schema requirement is from the CLI's TestNG parser, which reads <testsuites>, not <testng-results>. Reporter setup and behaviour follow qualflare-testng; the retry XML shown is real Surefire output from that repository's integration fixture. Written by İbrahim Süren, Qualflare.