Screenshot and Video Capture on Failure

8 min read

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=chrome

Cypress — 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.zip

For 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 @AfterMethod rather than in an onTestFailure listener, 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 if screenshots/ doesn't exist. Create the directory in a static initialiser or in @BeforeSuite before 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.

  1. Create ScreenshotHelper. Implement capture(driver, testName) (saves to file) and captureBase64(driver) (returns Base64 for reports). Create the artifacts/screenshots/ directory in the class initialiser.
  2. Wire the listener. Create ArtifactListener implements ITestListener. In onTestFailure, capture a screenshot, log its path, and attach the Base64 version to your report (Allure or ExtentReports).
  3. 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.
  4. Add the CI upload step. Add actions/upload-artifact (or equivalent Jenkins archiveArtifacts) to your CI pipeline configuration. Run a pipeline build with a forced failure. Verify the screenshot is downloadable from the CI build page.
  5. Stretch — Playwright trace. If your project uses Playwright, enable trace: "retain-on-failure" in playwright.config.ts. Force a test failure. Run npx playwright show-trace on 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.

// tip to track lessons you complete and pick up where you left off across devices.