After every CI run, someone asks: "Did the tests pass?" If the answer is "yes, except for these 3 — here's the screenshot of each failure, which step it failed on, and how long the suite took," that person gets what they need in 30 seconds. If the answer is "here's the Jenkins console output," that person opens a wall of text and extracts nothing useful. The reporting layer is not optional, and it's not just for management. A good report is your first debugging tool when something fails overnight. This lesson covers what each major reporter provides, when to use which, and how to wire a reporter into a framework without coupling it to your test code.
What a report must provide
A minimal useful report answers five questions:
- Which tests passed, which failed, which were skipped?
- For failed tests: what assertion failed, and what was the expected vs actual value?
- For failed tests: what did the browser look like at the moment of failure (screenshot)?
- How long did the suite take, and which tests were the slowest?
- What environment, browser, and build number produced these results?
CLI output answers question 1 partially and questions 2–5 not at all. Every serious team needs at least question 3.
Default reports — functional, ugly
TestNG generates test-output/index.html automatically. JUnit generates Surefire XML (target/surefire-reports). pytest generates a terminal summary. All three provide pass/fail counts and exception messages.
They share the same limitations: no screenshots, no step-by-step breakdown, no trend data, no charts, and no format a non-engineer would open voluntarily. They're suitable for CI tools (Jenkins, GitHub Actions parse Surefire XML) but not for human stakeholders.
ExtentReports
ExtentReports generates a self-contained HTML file with charts, test names, pass/fail status, environment info, attached screenshots, and custom tags. One HTML file you can email, upload to S3, or attach to a Jira ticket.
// ExtentReports wiring in a TestNG Listener
public class ExtentReportListener implements ITestListener {
private static ExtentReports extent;
private static final ThreadLocal<ExtentTest> test = new ThreadLocal<>();
@Override
public void onStart(ISuite suite) {
ExtentSparkReporter reporter = new ExtentSparkReporter("reports/extent.html");
reporter.config().setTheme(Theme.DARK);
reporter.config().setDocumentTitle("Test Run Report");
extent = new ExtentReports();
extent.attachReporter(reporter);
extent.setSystemInfo("Environment", Config.env());
extent.setSystemInfo("Browser", Config.browser());
}
@Override
public void onTestStart(ITestResult result) {
ExtentTest extentTest = extent.createTest(result.getMethod().getMethodName());
test.set(extentTest);
}
@Override
public void onTestSuccess(ITestResult result) {
test.get().pass("Test passed");
}
@Override
public void onTestFailure(ITestResult result) {
test.get().fail(result.getThrowable());
// Capture and attach screenshot
String screenshot = ScreenshotHelper.captureBase64(DriverManager.getDriver());
test.get().addScreenCaptureFromBase64String(screenshot, "Failure screenshot");
}
@Override
public void onFinish(ISuite suite) {
extent.flush();
}
}Register the listener in testng.xml:
<listeners>
<listener class-name="com.mycompany.tests.listeners.ExtentReportListener"/>
</listeners>Zero changes to test code. The listener attaches to TestNG's lifecycle and builds the report as tests run.
Allure — cross-language, trend-aware
Allure supports Java, Python, JavaScript, Ruby, and .NET. Its key differentiators: trend charts across runs, retry tracking, step-level breakdown with @Step annotations, and integration with test management tools (TestRail, Jira Xray via Allure TestOps).
// Java — Allure annotations enrich the report
@Test
@Feature("Authentication")
@Story("Login with valid credentials")
@Severity(SeverityLevel.CRITICAL)
public void adminCanLogin() {
loginPage.navigateTo();
loginPage.loginAs(Users.admin());
assertTrue(dashboardPage.isDisplayed());
}
// In the page object — @Step appears as a step in the report
@Step("Log in as {email}")
public void loginAs(String email, String password) {
find(emailInput).sendKeys(email);
find(passwordInput).sendKeys(password);
find(submitButton).click();
}# Python — Allure annotations with pytest
import allure
@allure.feature("Authentication")
@allure.story("Login")
def test_admin_login(login_page, dashboard_page):
with allure.step("Navigate to login page"):
login_page.navigate()
with allure.step("Submit admin credentials"):
login_page.login(Users.admin_email(), Users.admin_password())
with allure.step("Verify dashboard loaded"):
assert dashboard_page.is_displayed()Allure generates a data folder during the run; allure serve or allure generate converts it to an HTML dashboard. This two-step process is its main practical disadvantage over ExtentReports — you can't just email one HTML file; you need either a server or the allure generate step in CI.
Reporting options — same test results, different experience
CLI / Surefire XML
Pass/fail count and exception message only
No screenshots on failure
No trend data or history
Unreadable by non-engineers
Good for CI tool parsing only
ExtentReports
Single-file HTML — shareable by email or link
Screenshots attached on failure
Environment and system info embedded
Charts for pass/fail/skip distribution
No trend across runs without extra setup
Allure
Step-level breakdown with @Step annotations
Cross-run trend charts and history
Retry tracking, flakiness detection
Java, Python, JS, Ruby, .NET support
Requires allure-serve or CI plugin to view
Built-in reporters — Playwright and Cypress
Modern tools include professional reporters out of the box, reducing the need for third-party libraries:
Playwright HTML reporter — generates a full HTML report with screenshots, video recordings, and network traces. One command in playwright.config.ts:
reporter: [
["html", { outputFolder: "playwright-report", open: "never" }],
["junit", { outputFile: "test-results/results.xml" }], // also emit XML for CI
],Cypress Cloud — Cypress's paid reporting service with analytics, parallelism management, and flakiness detection. For open-source, cypress-mochawesome-reporter generates a comparable local HTML report.
Choosing the right reporter
| Situation | Recommendation |
|---|---|
| Java + Selenium + TestNG | ExtentReports or Allure |
| Python + pytest | Allure (best ecosystem fit) or pytest-html |
| Playwright | Built-in HTML reporter |
| Cypress | Mochawesome or Cypress Cloud |
| Cucumber (any language) | Cucumber HTML plugin or Allure |
| Need trend data across runs | Allure |
| Need a single shareable HTML file | ExtentReports |
| Need test management integration | Allure TestOps or custom JUnit XML integration |
Always emit JUnit XML in addition to the human-readable format. CI tools (Jenkins, GitHub Actions) parse JUnit XML to display test results natively. The human-readable report is for debugging; the XML is for the CI system.
Custom reporters — when standard tools don't fit
Some teams need outputs that standard reporters don't support: Slack notifications on failure, TestRail result uploads, custom dashboards with team-specific metrics. A custom ITestListener (TestNG) or Reporter (pytest plugin) can augment any standard reporter:
// Sends a Slack message for every failed test — TestNG Listener
@Override
public void onTestFailure(ITestResult result) {
String message = String.format(":red_circle: FAILED: %s | %s | %s",
result.getName(), Config.env(), result.getThrowable().getMessage());
SlackClient.send(message);
}Custom reporters should augment, not replace. Keep the standard Allure or ExtentReports output as the primary report; add integrations on top.
⚠️ Common mistakes
- Flushing ExtentReports in
@AfterMethodinstead of@AfterSuite. Ifextent.flush()is called after every test, the HTML is overwritten repeatedly and only the last test's data survives. Flush once, at suite teardown. - Taking screenshots from the wrong thread. In parallel execution,
DriverManager.getDriver()in a listener must return the correct thread's driver — which is whyThreadLocalinDriverManageris non-negotiable before adding parallel-safe reporting. - Only having the human-readable report. If Allure serves locally but the CI pipeline has no
allure generatestep, the report never reaches the team. Always verify that reports are generated and accessible after every CI run — build the CI step before you need it.
🎯 Practice task
Wire up a reporter and verify it captures failures — 35 minutes.
- Add ExtentReports. Add the
extentreportsdependency to your project. CreateExtentReportListenerimplementingITestListener(TestNG) or the equivalent. Register it. Run the suite and openreports/extent.html— all tests should appear. - Wire screenshots on failure. In
onTestFailure, callScreenshotHelper.captureBase64(DriverManager.getDriver())and attach to the Extent test. Deliberately break one test (wrong selector) and re-run. Open the report — the failure should show a screenshot of the browser state. - Add environment metadata. In
onStart, addextent.setSystemInfo("Environment", Config.env()),"Browser", and"Build". Run the suite. Verify the System Info tab in the report shows the correct values. - Also emit JUnit XML. Configure TestNG (or your runner) to additionally write Surefire XML. Verify the XML file appears in
target/surefire-reports. This is what CI tools consume — having both formats lets the CI display pass/fail and lets stakeholders see the full report. - Stretch — Allure comparison. Add the
allure-testng(orallure-pytest) dependency. Add@Featureand@Storyannotations to three tests. Run the suite, thenallure serve target/allure-results. Compare the Allure dashboard to your ExtentReports dashboard. Identify one capability each has that the other lacks.
Next lesson: test data management — strategies for creating, sharing, and cleaning up the data your tests depend on, at scale.