Creating an HTML Report with Embedded Screenshots of Failed JUnit Tests
Build reliable JUnit HTML reports with per-test screenshots using Jenkins, Maven, Allure, and a self-contained Java renderer.
Direct answer
JUnit does not capture screenshots or universally embed them in its XML. A JUnit-compatible XML file contains test results; Jenkins, Allure, Maven reporting, or your own renderer turns those results into HTML and associates image files with individual tests. Capture the browser image in your test or teardown code, save it with a stable test name, then configure the reporting system to publish that file.
For a CI-hosted report, Jenkins plus the JUnit Attachments plugin is usually the shortest path. For richer step-level attachments, use Allure. If you need one downloadable, self-contained HTML file, generate it yourself by Base64-encoding each PNG into an <img src="data:image/png;base64,..."> element.
Choose the report shape first
| Goal | Recommended route | What you get |
|---|---|---|
| CI test history and inline images | Jenkins JUnit + JUnit Attachments | Jenkins-hosted test results with image previews |
| Attachments on tests, steps, or fixtures | Allure | Interactive report with supported image previews and downloads |
| Basic Maven HTML test summary | Maven Surefire Report Plugin | HTML rendered from Surefire XML; add a separate attachment mechanism for screenshots |
| One portable file with no server | Custom Java or Python renderer | Images embedded directly in HTML as data URLs |
Jenkins understands the JUnit XML format and consumes report paths using Ant-style globs. Its JUnit result publisher and attachment publishing are separate configuration steps. Allure can attach files to a result, step, or fixture, but screenshot capture remains the responsibility of the browser-driver integration.
Capture a screenshot only when a JUnit test fails
The example below uses JUnit 5 and Selenium WebDriver. The same pattern works with another browser driver: detect a failed test in @AfterEach, take a screenshot, and write it beside the test report using a filesystem-safe name.
package example;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.Duration;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInfo;
import org.openqa.selenium.OutputType;
import org.openqa.selenium.TakesScreenshot;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.chrome.ChromeOptions;
class CheckoutTest {
private WebDriver driver;
private TestInfo testInfo;
private boolean failed;
@BeforeEach
void setUp(TestInfo info) {
testInfo = info;
ChromeOptions options = new ChromeOptions();
options.addArguments("--headless=new", "--no-sandbox", "--window-size=1440,1000");
driver = new ChromeDriver(options);
driver.manage().timeouts().pageLoadTimeout(Duration.ofSeconds(30));
}
@Test
void checkoutShowsConfirmation() {
driver.get("https://example.com/checkout");
// Replace with your real assertion.
assertEquals("Order confirmed", driver.getTitle());
}
@AfterEach
void tearDown() throws IOException {
// JUnit marks the test failed after an assertion throws. The extension below
// is the reliable way to know that state; this flag is set by the extension.
if (failed && driver instanceof TakesScreenshot screen) {
String className = testInfo.getTestClass().map(Class::getName).orElse("UnknownTest");
String methodName = testInfo.getTestMethod().map(java.lang.reflect.Method::getName).orElse("unknown");
String safeClass = className.replaceAll("[^A-Za-z0-9_.-]", "_");
String safeMethod = methodName.replaceAll("[^A-Za-z0-9_.-]", "_");
Path dir = Path.of("target", "surefire-reports", safeClass);
Files.createDirectories(dir);
Path source = screen.getScreenshotAs(OutputType.FILE).toPath();
Files.copy(source, dir.resolve(safeMethod + ".png"), StandardCopyOption.REPLACE_EXISTING);
}
if (driver != null) driver.quit();
}
void markFailed() { failed = true; }
}
To set the failure flag without changing every test, register a JUnit extension that receives the thrown exception:
package example;
import org.junit.jupiter.api.extension.AfterTestExecutionCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.TestWatcher;
public final class FailureScreenshotExtension implements TestWatcher, AfterTestExecutionCallback {
private static final ExtensionContext.Namespace NS = ExtensionContext.Namespace.create(FailureScreenshotExtension.class);
@Override
public void afterTestExecution(ExtensionContext context) {
context.getExecutionException().ifPresent(error -> context.getStore(NS).put("failed", true));
}
@Override
public void testFailed(ExtensionContext context, Throwable cause) {
context.getStore(NS).put("failed", true);
}
public static boolean failed(ExtensionContext context) {
return Boolean.TRUE.equals(context.getStore(NS).get("failed"));
}
}
In production, a per-test extension is often cleaner than a shared field: it avoids parallel-test races and lets you include the invocation index for parameterized tests. Ensure each filename is unique when tests run concurrently.
Publish screenshots in Jenkins
Jenkins uses the JUnit plugin to read XML and the JUnit Attachments plugin to display associated files. The attachments plugin supports either a class-named directory beside the XML file or a marker printed on its own line.
Directory layout
target/surefire-reports/
├── TEST-example.CheckoutTest.xml
└── example.CheckoutTest/
└── checkoutShowsConfirmation.png
The class directory must match the test class represented by the XML file. Keep XML globs restricted to XML files; including arbitrary files can make Jenkins parse invalid input.
Jenkinsfile
pipeline {
agent any
stages {
stage('Test') {
steps {
sh './mvnw -B test'
}
}
}
post {
always {
// Publish even when tests fail; Jenkins records the result as UNSTABLE.
junit testResults: 'target/surefire-reports/TEST-*.xml',
allowEmptyResults: false
// In the job configuration, enable the JUnit option
// "Publish test attachments" (provided by JUnit Attachments).
}
}
}
Alternatively, print an absolute path on one line from the test process:
System.err.println("[[ATTACHMENT|/workspace/project/target/surefire-reports/example.CheckoutTest/checkoutShowsConfirmation.png]]");
Use the directory approach when your CI workspace is predictable. Use the marker when files are generated in a temporary location and you can print the final absolute path.
See the Jenkins JUnit plugin documentation and JUnit Attachments plugin documentation for current setup and compatibility details. Plugin versions and Jenkins requirements change, so verify them before pinning a pipeline.
Generate a Maven HTML report
The Maven Surefire Report Plugin reads TEST-*.xml files from target/surefire-reports and renders an HTML summary. That report is a renderer for test results; it does not, by itself, document screenshot embedding. Pair it with Jenkins attachments, Allure, or a custom renderer.
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-report-plugin</artifactId>
<version>3.5.4</version>
</plugin>
./mvnw surefire-report:report
# Open target/reports/surefire.html (path can vary by plugin configuration).
Do not assume this output is a single self-contained file. If portability matters, use the renderer in the next section.
Create a self-contained HTML file with embedded PNGs
This Java utility produces a small standalone report from a list of failed tests and screenshot paths. It embeds the bytes directly, so recipients do not need access to your CI workspace.
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Base64;
public class EmbeddedReport {
record Failure(String name, Path image) {}
public static void main(String[] args) throws IOException {
var failures = java.util.List.of(
new Failure("CheckoutTest.checkoutShowsConfirmation", Path.of("target/surefire-reports/example.CheckoutTest/checkoutShowsConfirmation.png"))
);
var html = new StringBuilder("<!doctype html><meta charset='utf-8'><title>JUnit failures</title><h1>Failed tests</h1>");
for (var failure : failures) {
var bytes = Files.readAllBytes(failure.image());
var encoded = Base64.getEncoder().encodeToString(bytes);
html.append("<section><h2>").append(escape(failure.name())).append("</h2>")
.append("<img style='max-width:100%' alt='Failure screenshot' src='data:image/png;base64,")
.append(encoded).append("'></section>");
}
Files.writeString(Path.of("target/junit-failures.html"), html.toString());
}
static String escape(String value) {
return value.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """);
}
}
Base64 increases image size by roughly one third and can make very large reports slow to load. For dozens of full-page images, prefer linked files in an artifact directory or a CI-hosted report.
Use Allure for step-level attachments
Allure supports attachments on a test result, step, or fixture and previews common image media types including PNG and JPEG. The exact capture API depends on your JUnit/browser integration, so separate the two operations: take the screenshot, then call the integration’s attachment method.
import io.qameta.allure.Attachment;
@Attachment(value = "Failure screenshot", type = "image/png")
static byte[] attach(byte[] png) {
return png;
}
// In an @AfterEach hook after detecting a failure:
byte[] png = ((TakesScreenshot) driver).getScreenshotAs(OutputType.BYTES);
attach(png);
Consult the Allure attachment documentation for the current JUnit integration and supported media behavior.
Configure JUnit Platform XML output
The JUnit Platform reporting module can emit Open Test Reporting XML and legacy XML. These outputs remain result data; they do not establish a screenshot-embedding format.
Maven
<systemPropertyVariables>
<junit.platform.reporting.output.dir>target/junit-platform-reports</junit.platform.reporting.output.dir>
<junit.platform.reporting.open.xml.enabled>true</junit.platform.reporting.open.xml.enabled>
</systemPropertyVariables>
Gradle
test {
systemProperty 'junit.platform.reporting.output.dir', "$buildDir/junit-platform-reports"
systemProperty 'junit.platform.reporting.open.xml.enabled', 'true'
}
The documented default output directory is build for Gradle, target for Maven, or the current working directory otherwise. Keep screenshot paths and XML paths deterministic so a later renderer can join them.
Or skip the browser setup
ScreenshotNeo is a website screenshot API and MCP server. One request returns PNG, JPEG, WebP, or PDF, while options cover full-page capture, element selectors, device presets, dark mode, custom CSS and JavaScript, waits, blocked resources, headers, cookies, geolocation, caching, signed links, asynchronous jobs, and bulk capture.
Use it when your JUnit failure report needs a screenshot of a deployed URL rather than a screenshot from the test’s in-process browser. Read the ScreenshotNeo API documentation for all parameters.
cURL
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
Python
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
Node.js
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
const fs = await import('node:fs/promises');
await fs.writeFile('shot.webp', Buffer.from(await res.arrayBuffer()));
Cookie banners, newsletter popups, and chat widgets are removed before the shot. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, and response headers report the page verdict and whether the request was billed. An MCP server lets Claude, Cursor, and other MCP clients take screenshots. The free plan includes 1,000 shots each month with no card; paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| No image appears in Jenkins | Attachment publishing is disabled or the path does not match the class directory. | Enable “Publish test attachments,” verify the class-named directory beside the XML, and archive the workspace to inspect paths. |
| Jenkins says the XML is invalid | Your glob includes PNGs, logs, or another non-XML file. | Use a pattern such as target/surefire-reports/TEST-*.xml. |
| Screenshot is taken for passing tests | Teardown runs for every test and failure state is not checked. | Capture only when the JUnit extension receives an execution exception. |
| Parallel tests overwrite images | Filenames contain only the method name. | Add class, parameterized invocation index, and a UUID or unique run directory. |
| Browser screenshot is blank | Capture occurs before navigation or before the application finishes rendering. | Wait for a deterministic selector, document readiness, or an application-specific condition. |
| Allure shows a download but no preview | The attachment media type is missing or unsupported. | Set type="image/png" or another supported image type and attach the actual bytes. |
| Standalone HTML is huge | Base64 embeds every image and adds encoding overhead. | Resize images, embed only failures, or link artifacts instead of embedding them. |
| Screenshot API returns an error | Invalid key, unreachable URL, blocked page, or timeout. | Check the HTTP status and response body, increase the wait or timeout where appropriate, and inspect X-Page-Verdict and X-Billed. |
Performance, reliability, and cost
- Capture only on failure. Browser screenshots add I/O and can slow teardown for every test.
- Use a fixed viewport and deterministic waits so diffs remain comparable.
- Keep screenshots near the XML until publishing completes; clean them after artifacts are archived.
- For parallel CI, isolate each worker’s output directory and merge reports afterward.
- Do not make report publication conditional on test success. Jenkins’s
post { always { ... } }pattern preserves evidence from failed runs. - For ScreenshotNeo, cache repeated URLs with a chosen TTL and use bulk capture for up to 100 URLs per call. Only clean shots are billed; failed loads and cache hits cost nothing.
FAQ
Does JUnit XML support an embedded PNG element?
The standard result formats describe tests and outcomes. Treat screenshots as attachments consumed by a renderer or CI plugin.
Can I email one report file?
Yes. Generate a self-contained HTML file with data URLs, but keep image count and dimensions under control.
Should I use Jenkins or Allure?
Choose Jenkins when your team already uses Jenkins test history and inline attachments. Choose Allure when you need attachments organized by steps or fixtures.
Can screenshots be captured outside the test browser?
Yes. A service such as ScreenshotNeo can capture the deployed URL independently, then your test job can save the returned image beside its JUnit result.
Why is a failed Jenkins build sometimes marked unstable?
The Jenkins JUnit result step commonly marks a build with failing tests as UNSTABLE, which is distinct from a pipeline execution state of FAILED.


