A test fails in CI reporting AssertionError: expected <true> but was <false>. The assertion tells you the result; it tells you nothing about what the browser displayed at that moment. Was the page still loading? Did an error overlay appear? Did the expected element fail to render? Without a screenshot, re-running the test is the only way to investigate — and if the failure is environment-specific or timing-dependent, the re-run passes and the failure is permanently unexplained. Screenshot capture on failure is the highest-leverage single addition to any Selenium or Playwright framework. It costs nothing per test, attaches automatically via a listener, and makes every CI failure self-explanatory.
Screenshots in Selenium
Selenium's TakesScreenshot interface is the baseline:
public class ScreenshotHelper {
private static final Path SCREENSHOT_DIR = Paths.get("artifacts/screenshots");
static {
try {
Files.createDirectories(SCREENSHOT_DIR);
} catch (IOException e) {
throw new RuntimeException("Cannot create screenshot directory", e);
}
}
public static String capture(WebDriver driver, String testName) {
File src = ((TakesScreenshot) driver).getScreenshotAs(OutputType.FILE);
String filename = sanitise(testName) + "_" + System.currentTimeMillis() + ".png";
Path dest = SCREENSHOT_DIR.resolve(filename);
try {
Files.copy(src.toPath(), dest);
} catch (IOException e) {
LoggerFactory.getLogger(ScreenshotHelper.class).error("Screenshot save failed", e);
}
return dest.toString();
}
public static String captureBase64(WebDriver driver) {
return ((TakesScreenshot) driver).getScreenshotAs(OutputType.BASE64);
}
private static String sanitise(String name) {
return name.replaceAll("[^a-zA-Z0-9_-]", "_");
}
}captureBase64() returns a Base64 string that ExtentReports and Allure can embed directly into HTML — no file path dependency, no broken links if the artifacts folder is moved.
Element-level screenshots (Selenium 4) capture a single element rather than the full window — useful for component tests or for isolating a failing form field:
WebElement errorBanner = driver.findElement(By.cssSelector("[data-testid='error']"));
File elementShot = errorBanner.getScreenshotAs(OutputType.FILE);Full-page screenshots capture content beyond the viewport — useful when the failing element is below the fold:
// Selenium 4 — full page via CDP
ChromeDriver chrome = (ChromeDriver) driver;
Map<String, Object> metrics = chrome.executeCdpCommand("Page.captureScreenshot",
Map.of("captureBeyondViewport", true));Listener-based automatic capture
The goal is zero changes to test methods. Every test gets a screenshot on failure automatically, via a TestNG ITestListener:
public class ArtifactListener implements ITestListener {
@Override
public void onTestFailure(ITestResult result) {
WebDriver driver = DriverManager.getDriver();
if (driver == null) return;
String testName = result.getTestClass().getName() + "." + result.getName();
String path = ScreenshotHelper.capture(driver, testName);
String base64 = ScreenshotHelper.captureBase64(driver);
// Log the path for CI artifact collection
LoggerFactory.getLogger(ArtifactListener.class)
.info("Screenshot saved: {}", path);
// Attach to Allure report
Allure.addAttachment("Failure screenshot", "image/png",
new ByteArrayInputStream(Base64.getDecoder().decode(base64)), "png");
}
}Register in testng.xml:
<listeners>
<listener class-name="com.mycompany.tests.listeners.ArtifactListener"/>
</listeners>The equivalent in pytest (Python):
# conftest.py — automatic screenshot via pytest hook
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item, call):
outcome = yield
report = outcome.get_result()
if report.when == "call" and report.failed:
driver = item.funcargs.get("driver")
if driver:
screenshot = driver.get_screenshot_as_base64()
allure.attach(
base64.b64decode(screenshot),
name="Failure screenshot",
attachment_type=allure.attachment_type.PNG
)Video recording
Video completes what a screenshot starts. A screenshot shows the final frame; video shows the sequence of events leading to failure.
Playwright — built-in video with retain-on-failure:
// playwright.config.ts
use: {
video: "retain-on-failure", // record all tests; delete videos for passing tests
screenshot: "only-on-failure", // capture screenshot at failure moment
trace: "retain-on-failure", // capture full trace for failing tests
}Selenium Grid 4 — Docker Compose with video container:
# docker-compose.yml
services:
chrome:
image: selenium/node-chrome:4.20.0
environment:
- SE_EVENT_BUS_HOST=hub
video:
image: selenium/video:ffmpeg-7.0-20240516
volumes:
- ./videos:/videos
environment:
- DISPLAY_CONTAINER_NAME=chromeCypress — automatic video for all tests, configurable:
// cypress.config.js
module.exports = {
video: true,
videoCompression: 32,
trashAssetsBeforeRuns: true,
};Playwright trace viewer — the best debugging tool
Playwright's trace is a full recording of the test: DOM snapshots before and after every action, network requests and responses, console logs, screenshots, and a timeline. Open it with npx playwright show-trace trace.zip and step through the test as if you're watching a video with a debugger attached.
// playwright.config.ts
use: {
trace: "retain-on-failure",
}After a failure:
npx playwright show-trace test-results/my-test/trace.zipFor complex failures — timing issues, network races, subtle DOM state — the trace viewer eliminates the need to re-run the test locally. You see exactly what happened, in sequence, including the network response that should have triggered the UI update but didn't.
Artifact storage and naming
Artifacts that can't be found are useless. Name and store them so they're accessible long after the CI run:
artifacts/
└── screenshots/
├── LoginTest.testValidLogin_1715077800000.png
├── CheckoutTest.testPaymentFails_1715077830000.png
└── ...
testName_timestamp.png is sortable, traceable, and collision-free in parallel runs. Avoid generic names like screenshot.png — the second failure overwrites the first.
In GitHub Actions, upload as build artifacts:
- name: Upload test artifacts
if: failure()
uses: actions/upload-artifact@v4
with:
name: test-artifacts-${{ github.run_id }}
path: artifacts/
retention-days: 7⚠️ Common mistakes
- Capturing the screenshot before the assertion, not after. If the screenshot is taken in
@AfterMethodrather than in anonTestFailurelistener, the page may have already navigated away from the failure state. Listeners fire synchronously at failure time, before any teardown code runs. - Storing screenshots relative to the working directory without creating the folder.
new File("screenshots/failure.png")fails silently ifscreenshots/doesn't exist. Create the directory in astaticinitialiser or in@BeforeSuitebefore any test can fail. - Not uploading artifacts in CI. Locally saved screenshots are meaningless in CI if there's no artifact upload step. The CI job completes, the workspace is cleaned, and the screenshot is gone. Always add the upload step to your CI pipeline configuration.
🎯 Practice task
Wire automatic screenshot capture into your framework — 30 minutes.
- Create
ScreenshotHelper. Implementcapture(driver, testName)(saves to file) andcaptureBase64(driver)(returns Base64 for reports). Create theartifacts/screenshots/directory in the class initialiser. - Wire the listener. Create
ArtifactListener implements ITestListener. InonTestFailure, capture a screenshot, log its path, and attach the Base64 version to your report (Allure or ExtentReports). - Force a failure. Add a deliberately wrong assertion to one test. Run the suite. Open the report and confirm the screenshot is attached and shows the browser state at failure time.
- Add the CI upload step. Add
actions/upload-artifact(or equivalent JenkinsarchiveArtifacts) to your CI pipeline configuration. Run a pipeline build with a forced failure. Verify the screenshot is downloadable from the CI build page. - Stretch — Playwright trace. If your project uses Playwright, enable
trace: "retain-on-failure"inplaywright.config.ts. Force a test failure. Runnpx playwright show-traceon the resulting trace file. Identify the exact DOM state and network request at the moment the assertion failed.
Next lesson: retry and self-healing strategies — how to handle genuinely flaky tests without hiding the underlying problem.