Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Some links on this page are affiliate links: if you buy through them we may earn a commission, at no extra cost to you.

Capture the image in TestNG’s onTestFailure(ITestResult) callback, while the WebDriver session is still running, and only then let @AfterMethod call quit(). A listener gives you the failure event, Selenium’s TakesScreenshot API produces the image, and an immediate copy turns Selenium’s temporary file into a durable test artifact.

The ordering that prevents missing screenshots

A reliable failure path has three distinct stages:

  1. The test method throws an assertion or another exception, and TestNG creates an ITestResult.
  2. Your ITestListener.onTestFailure (and, where available, onTestFailedWithTimeout) obtains the still-live driver and calls getScreenshotAs.
  3. The test’s @AfterMethod(alwaysRun = true) tears down the browser with driver.quit().

If teardown runs first, there is no browser session from which Selenium can capture a page. Calling quit() inside the listener is therefore the wrong order: capture first, shut down second.

A complete listener implementation

The listener below uses a small project-owned interface to obtain the driver from the failing test instance. It creates the destination directory, gives every file a filesystem-safe, collision-resistant name, copies Selenium’s temporary file immediately, and never masks the original test failure if capture itself fails.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
import org.openqa.selenium.OutputType;
import org.openqa.selenium.TakesScreenshot;
import org.openqa.selenium.WebDriver;
import org.testng.ITestListener;
import org.testng.ITestResult;

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;

public final class FailureScreenshotListener implements ITestListener {

  @Override
  public void onTestFailure(ITestResult result) {
    capture(result);
  }

  // TestNG versions that expose this callback can report a timeout separately.
  @Override
  public void onTestFailedWithTimeout(ITestResult result) {
    capture(result);
  }

  private void capture(ITestResult result) {
    Object instance = result.getInstance();
    if (!(instance instanceof HasDriver)) {
      return;
    }

    WebDriver driver = ((HasDriver) instance).getDriver();
    if (!(driver instanceof TakesScreenshot)) {
      return;
    }

    String className = safe(result.getTestClass().getName());
    String methodName = safe(result.getMethod().getMethodName());
    String fileName = className + "-" + methodName + "-"
        + System.currentTimeMillis() + ".png";
    Path target = Path.of("test-artifacts", "screenshots", fileName);

    try {
      Files.createDirectories(target.getParent());
      File temporary = ((TakesScreenshot) driver)
          .getScreenshotAs(OutputType.FILE);
      Files.copy(temporary.toPath(), target,
          StandardCopyOption.REPLACE_EXISTING);
      System.out.println("Failure screenshot: " + target.toAbsolutePath());
    } catch (IOException | RuntimeException captureError) {
      // Log this error, but preserve the assertion or exception that failed the test.
      captureError.printStackTrace();
    }
  }

  private static String safe(String value) {
    return value.replaceAll("[^a-zA-Z0-9._-]", "_");
  }
}

If your TestNG release does not declare onTestFailedWithTimeout, remove that override and keep onTestFailure. TestNG 7.9.0 documents the timeout callback separately; implementing it when your version supports it prevents timeout diagnostics from being missed.

The driver-access contract

import org.openqa.selenium.WebDriver;

public interface HasDriver {
  WebDriver getDriver();
}

The interface avoids brittle reflection and makes the listener work with any test class that exposes its active driver. A base test class can implement it, or individual test classes can do so directly.

Registering with @Listeners

import org.openqa.selenium.WebDriver;
import org.testng.annotations.AfterMethod;
import org.testng.annotations.Listeners;

@Listeners(FailureScreenshotListener.class)
public class CheckoutTest implements HasDriver {
  private WebDriver driver;

  @Override
  public WebDriver getDriver() {
    return driver;
  }

  // Create the driver in your @BeforeMethod or equivalent setup.

  @AfterMethod(alwaysRun = true)
  public void tearDown() {
    if (driver != null) {
      driver.quit();
      driver = null;
    }
  }
}

@Listeners keeps registration next to the test class and is convenient for a focused suite.

Registering in testng.xml

<listeners>
  <listener class-name="example.FailureScreenshotListener"/>
</listeners>

XML registration is preferable when you want one listener applied across many classes or when test sources should not carry framework annotations. Use the fully qualified class name that matches your package.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Why OutputType.FILE must be copied

TakesScreenshot.getScreenshotAs(OutputType.FILE) returns a temporary file. Selenium’s API makes the caller responsible for copying that file, and the temporary file can be deleted when the JVM exits. Copy it inside the listener, immediately after capture, to a directory retained by your CI system.

The example uses Files.copy, but Apache Commons IO’s FileUtils.copyFile is equivalent. Do not merely log the temporary path and expect it to remain available after the test process ends.

Driver lifetime, teardown, and parallel execution

Keep teardown idempotent

Guard against a setup failure by checking for null. Set the field to null after quit() so a repeated cleanup does not attempt to use a closed session. The listener itself should never call quit(); its sole job is diagnostic capture.

When tests run in parallel

A shared instance field is unsafe if multiple test methods can use the same object concurrently. Prefer one driver per test instance, a thread-local driver, or a framework-owned registry keyed by the TestNG test instance and thread. The listener must resolve the driver belonging to the failing invocation, not whichever driver was most recently assigned.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Include class, method, and a unique suffix (timestamp or UUID) in the filename. Without that, parallel failures can overwrite one another. If parameterized tests use the same method name, add a sanitized parameter or TestNG invocation index to the name.

Custom runners that reorder cleanup

Standard TestNG listener processing is intended to expose the failed result before normal method cleanup completes. If a custom runner or integration closes the driver first, move shutdown to a later suite or test cleanup hook, or retain the driver in a registry until listener processing has finished. The invariant is unchanged: getScreenshotAs must execute before the session is closed.

Failure cases and precise fixes

Symptom Likely cause Fix
No image and no listener log The listener was never registered, or the method did not produce a TestNG failure result. Verify @Listeners or testng.xml, and confirm the test is running under TestNG rather than another runner.
NullPointerException for the driver Setup failed before assigning the driver. Return when the instance has no driver and preserve the setup exception; do not manufacture a screenshot.
UnsupportedOperationException The current WebDriver implementation does not support screenshots. Check instanceof TakesScreenshot, record the limitation, and retain the original failure.
InvalidSessionIdException or “no such session” The browser crashed or teardown already called quit(). Capture earlier, prevent premature cleanup, and log the capture error as secondary information.
Files exist locally but not in CI The artifact directory is outside the CI upload path. Write to a known workspace directory such as test-artifacts/screenshots and configure the CI job to publish it.
Images overwrite each other Names are based only on the method name. Add class, invocation data, thread or UUID, and a timestamp.
Timeouts have no screenshot The TestNG version reports timeout through a separate callback. Implement onTestFailedWithTimeout when that callback exists and delegate to the same capture method.
Screenshot is present but stale The failure occurred before the expected page state, or capture happened after navigation/cleanup. Capture directly in the failure callback and include page URL and other diagnostics in your test logs.

Making artifacts useful in real suites

  • Use an explicit artifact root: Keep screenshots separate from source and build output, and clean or archive it according to your CI retention policy.
  • Record context: Log the test class, method, browser, thread, URL, and destination path alongside the failure. The screenshot answers “what was visible”; the log explains which invocation produced it.
  • Preserve the first failure: Wrap only the capture operation. A broken screenshot must not replace the assertion, timeout, or browser exception that caused the test to fail.
  • Account for browser crashes: A crashed browser cannot produce pixels. Keep the original exception and, if available, collect driver or browser logs separately.
  • Keep filenames portable: Replace slashes, colons, spaces, and other platform-sensitive characters. Avoid unbounded parameter strings.
  • Control parallel load: Screenshot encoding and file I/O add work at the moment many tests may fail together. Use unique paths and, if necessary, a bounded artifact strategy rather than serializing all test cleanup.
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Or skip the browser setup

For a URL-level image rather than a screenshot tied to a live Selenium session, ScreenshotNeo provides a single HTTP call. It accepts consent banners before capture and removes more than 60 known consent platforms, newsletter popups, and chat widgets; each cleanup step can be disabled. Bot checks or CAPTCHAs, blank pages, timeouts, failed loads, and cache hits are not billed, and response headers identify the page verdict and whether it was billed. Its MCP server exposes take_screenshot, get_page_info, and capture_pdf to Claude, Cursor, and other MCP clients.

Use the API when you need a repeatable page artifact without provisioning a browser. It does not replace an in-test Selenium capture of a logged-in, mid-workflow state; it is an alternative for public URL captures, monitoring, documentation, and agent workflows.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp

See the ScreenshotNeo API documentation for authentication, output formats, and the full option set. You can request PNG, JPEG, WebP, or PDF; full-page captures load lazy images; and options include CSS selectors, dark mode, device and viewport settings, custom JavaScript and CSS, waits, request blocking, headers, cookies, geolocation, resizing, caching, signed links, asynchronous webhooks, and bulk capture of up to 100 URLs per call.

The Free plan includes 1,000 screenshots each month with no card. Paid plans start at $5 for 3,000 screenshots, and every feature is available on every plan. Create a free ScreenshotNeo account to try it.

Python and Node.js equivalents

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(`Screenshot failed: ${res.status}`);
const fs = await import('node:fs/promises');
await fs.writeFile('shot.webp', Buffer.from(await res.arrayBuffer()));

Verification checklist

  1. Deliberately fail a test assertion and confirm onTestFailure runs.
  2. Check that the screenshot shows the page state at failure, not a closed or blank browser.
  3. Confirm the copied file remains after the JVM exits and is uploaded by CI.
  4. Run two failing tests in parallel and verify that neither file is overwritten.
  5. Force a setup failure, a browser crash, and (if supported) a timeout to ensure capture errors never hide the original result.

Frequently Asked Questions

Can I capture in @AfterMethod instead of a listener?

You can, but the listener is the dependable test-failure event and keeps capture logic separate from teardown. An @AfterMethod implementation must also distinguish failures and run before quitting the driver.

Does a screenshot prove why the test failed?

No. It records the visible browser state. Keep the assertion or exception, URL, browser logs, and other diagnostics as separate evidence.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

What if my test uses RemoteWebDriver?

RemoteWebDriver commonly implements TakesScreenshot, but check the runtime object with instanceof and handle UnsupportedOperationException because screenshot support is driver-dependent.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.