PRACTICAL GUIDE / Failsafe post integration test driver cleanup
Stop Selenium sessions from surviving failed Maven builds
Make WebDriver teardown survive failed integration tests, detect sessions left on Selenium Grid, and wire cleanup into Maven without hiding the cause.
In this guide6 sections
- Put cleanup in the layer that still runs after a failed test
- Use Failsafe phases for environment cleanup, not driver ownership
- Prove which sessions your suite leaked
- Tell a cleanup defect from a crashed fork or slow Grid
- Migrate an existing suite without losing the original failure
- Know when post-integration cleanup is the wrong fix
What you will learn
- Put cleanup in the layer that still runs after a failed test
- Use Failsafe phases for environment cleanup, not driver ownership
- Prove which sessions your suite leaked
- Tell a cleanup defect from a crashed fork or slow Grid
An integration test fails halfway through login, Maven exits red, and Selenium Grid still shows a live Chrome session ten minutes later. The next pipeline waits for a slot that the failed build never released. By the time someone opens the Failsafe report, the original browser and the evidence inside it are gone or mixed with a later run.
The fix needs two owners. Test code must end each session during ordinary assertion and application failures. Maven must still run environment cleanup and verify that no session owned by the build survived a crashed fork or broken teardown. That separation is the practical point of Failsafe post integration test driver cleanup.
Put cleanup in the layer that still runs after a failed test
WebDriver sessions are resources. Creating a driver asks a browser driver or Grid for a session, and the returned session remains allocated until it is deleted or the remote system expires it by its own policy. Selenium’s driver-session documentation distinguishes quit() from close() and recommends quit() to end the session. Closing the current window is not a substitute for session deletion.
The nearest reliable owner is the code that created the driver. If every integration test creates its own session, each test should release that session through try with resources, @AfterEach, or a test fixture whose teardown is guaranteed by the framework. If a class owns one shared driver, class teardown owns it, but that choice increases the blast radius: one corrupt session can affect every later method in the class.
Do not postpone normal driver teardown to Maven’s post-integration-test phase. The build process does not retain a usable Java object merely because it knows a test phase has ended. A session ID by itself is useful for diagnosis, but Selenium’s supported client lifecycle still centers on the driver instance and quit(). The later phase should detect or remediate exceptional leaks through infrastructure you explicitly own.
The following wrapper records a marker immediately after a RemoteWebDriver session starts. It leaves the marker with an .open suffix until quit() returns, then renames it to .closed. If marker creation fails, it attempts to quit the newly created session before propagating the file error. That last branch matters. An observability feature must not become a new leak path.
package example.testinfra;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.util.function.Supplier;
import org.openqa.selenium.remote.RemoteWebDriver;
public final class ManagedDriver implements AutoCloseable {
private final RemoteWebDriver driver;
private final Path openMarker;
private final Path closedMarker;
private boolean closed;
private ManagedDriver(
RemoteWebDriver driver,
Path openMarker,
Path closedMarker
) {
this.driver = driver;
this.openMarker = openMarker;
this.closedMarker = closedMarker;
}
public static ManagedDriver start(
Supplier<? extends RemoteWebDriver> factory,
Path ledgerDirectory
) {
RemoteWebDriver driver = factory.get();
String sessionId = driver.getSessionId().toString();
Path open = ledgerDirectory.resolve(sessionId + ".open");
Path closed = ledgerDirectory.resolve(sessionId + ".closed");
try {
Files.createDirectories(ledgerDirectory);
Files.writeString(
open,
sessionId + System.lineSeparator(),
StandardOpenOption.CREATE_NEW,
StandardOpenOption.WRITE
);
return new ManagedDriver(driver, open, closed);
} catch (IOException markerFailure) {
try {
driver.quit();
} catch (RuntimeException quitFailure) {
markerFailure.addSuppressed(quitFailure);
}
throw new UncheckedIOException(markerFailure);
}
}
public RemoteWebDriver driver() {
return driver;
}
@Override
public void close() {
if (closed) {
return;
}
driver.quit();
try {
Files.move(
openMarker,
closedMarker,
StandardCopyOption.REPLACE_EXISTING
);
} catch (IOException moveFailure) {
throw new UncheckedIOException(moveFailure);
}
closed = true;
}
}This wrapper does not claim that a .closed file proves Grid removed the session instantly. It proves that the client’s quit() call returned. The build-level check will compare every recorded ID, including closed ones, with Grid’s current active set. That gives the suite two independent observations instead of treating a local file rename as remote truth.
Use the wrapper where the test owns the browser. The next case deliberately has an assertion that can fail when the page no longer reports a signed-in state. Java closes the resource while unwinding from the failed assertion. No catch block swallows the application failure, and cleanup has a chance to add its own failure if quit() cannot complete.
package example.account;
import static org.junit.jupiter.api.Assertions.assertEquals;
import example.testinfra.ManagedDriver;
import java.net.URI;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
import org.openqa.selenium.By;
import org.openqa.selenium.chrome.ChromeOptions;
import org.openqa.selenium.remote.RemoteWebDriver;
class LoginIT {
@Test
void validCustomerReachesTheAccountPage() throws Exception {
String gridUrl = System.getenv("SELENIUM_GRID_URL");
String appUrl = System.getenv("APP_BASE_URL");
ChromeOptions options = new ChromeOptions();
try (ManagedDriver session = ManagedDriver.start(
() -> {
try {
return new RemoteWebDriver(
URI.create(gridUrl).toURL(),
options
);
} catch (Exception error) {
throw new IllegalStateException(error);
}
},
Path.of("target", "driver-sessions")
)) {
RemoteWebDriver driver = session.driver();
driver.get(appUrl + "/login");
driver.findElement(By.id("email"))
.sendKeys(System.getenv("TEST_CUSTOMER_EMAIL"));
driver.findElement(By.id("password"))
.sendKeys(System.getenv("TEST_CUSTOMER_PASSWORD"));
driver.findElement(By.cssSelector("button[type='submit']")).click();
assertEquals(
"Signed in",
driver.findElement(By.id("account-status")).getText()
);
}
}
}There is a trade-off. Per-test sessions give clean ownership and strong isolation, but session creation adds latency and load to Grid. Shared sessions reduce startup cost, yet they require stronger reset logic and make one bad teardown affect more cases. Choose explicitly. Do not accidentally share a static driver because it was convenient in the first version of the framework.
An @AfterEach method is also valid when a framework fixture assigns the driver before the test. Guard it against partial setup, call quit() once, and never reduce cleanup to if (driver != null) driver.close(). If multiple fixtures can create sessions, put ownership in one extension rather than copying teardown into each test class. Duplication is how one new suite eventually omits the callback.
Use Failsafe phases for environment cleanup, not driver ownership
Maven’s default lifecycle orders pre-integration-test, integration-test, post-integration-test, and verify. The same guide warns against invoking the middle phases directly because the integration-test environment can be left running. CI should request the final outcome with mvn verify, allowing Maven to execute every earlier phase in sequence.
The Failsafe plugin's usage guide binds the integration-test goal to the lifecycle's integration-test phase and the verify goal to verify. The integration goal writes a summary file, and the verify goal reads it. That design permits cleanup bound to post-integration-test to run before the build evaluates the integration-test result at verify, provided the build is configured and invoked through the full lifecycle.
This distinction explains a common failure. A team switches from mvn verify to mvn integration-test because it sounds more specific. The tests run, so the command appears correct. Maven stops at the requested phase, which means later environment teardown and Failsafe verification do not run. The command is not a faster spelling of the same outcome. It asks Maven to stop earlier.
The post phase owns build-scoped resources: a test server, proxy, container, network tunnel, temporary database, or the final session-leak check. Test teardown owns test-scoped resources: drivers, pages, accounts created for one case, and files opened in a fixture. When these responsibilities are reversed, cleanup becomes late and blunt. A build script sees “some session exists” but cannot tell which test created it or what evidence should be attached.
Bind environment decommissioning to post-integration-test and bind Failsafe’s verify goal normally. A leak observation in the post phase may write evidence, but it should not throw before verify if preserving the original integration-test result matters. Maven stops when a plugin goal fails, so a fatal post-phase guard can prevent Failsafe from reading its summary. Run the failure-producing leak assertion from a CI wrapper after Maven returns, capture both exit statuses, and preserve both reports.
Do not use finally in a CI shell as the only cleanup mechanism. If the CI worker is terminated, no user-space cleanup is guaranteed. A Grid idle timeout can limit damage, but it is a backstop with delayed capacity recovery. The suite should still release sessions normally and make leaks visible immediately.
One more boundary matters: a Failsafe test failure is not the same as a forked JVM crash. A normal failed assertion lets JUnit unwind and invoke teardown. A process kill, out-of-memory termination, runner cancellation, or machine loss can bypass Java callbacks. The marker and independent guard exist for that exceptional path. They do not justify weak per-test teardown.
Prove which sessions your suite leaked
Looking at Grid’s total session count is not enough on shared infrastructure. Other builds may legitimately have sessions. A count that stayed at six could hide one leaked session and one normally completed session. A count that rose from six to seven could reflect another team starting work. The oracle must join active Grid sessions to IDs created by this build.
Selenium documents a Grid GraphQL endpoint and a sessionsInfo query that returns active session details. Query only the scalar needed for this check, the session ID. Then intersect the active IDs with the marker names under the current Maven target directory. That intersection is capable of failing when code under test leaks a session.
The script below expects curl, jq, and standard Unix tools on the build agent. It is the failure-producing assertion that the CI wrapper runs after mvn verify; a post-phase observation can call the same query in report-only mode if the project needs earlier evidence. The script polls for a bounded grace period because remote state may take time to settle after quit() returns. Ten attempts with a two-second interval are example policy values, not measured Grid behavior. Tune them from your infrastructure’s observed behavior, and keep the bound short enough that a real leak fails promptly.
#!/usr/bin/env bash
set -euo pipefail
ledger="${DRIVER_SESSION_LEDGER:-target/driver-sessions}"
grid_url="${SELENIUM_GRID_URL:?SELENIUM_GRID_URL is required}"
if [[ ! -d "$ledger" ]]; then
exit 0
fi
owned_ids="$(mktemp)"
active_ids="$(mktemp)"
leaked_ids="$(mktemp)"
trap 'rm -f "$owned_ids" "$active_ids" "$leaked_ids"' EXIT
find "$ledger" -type f \( -name '*.open' -o -name '*.closed' \) -print0 |
while IFS= read -r -d '' marker; do
basename "$marker" | sed -E 's/\.(open|closed)$//'
done |
sort -u > "$owned_ids"
query_active_sessions() {
curl --fail --silent --show-error \
-H 'Content-Type: application/json' \
--data '{"query":"{ sessionsInfo { sessions { id } } }"}' \
"$grid_url/graphql" |
jq -r '
if .errors then
error(.errors | tostring)
else
(.data.sessionsInfo.sessions // [])[].id
end
' |
sort -u
}
for attempt in $(seq 1 10); do
query_active_sessions > "$active_ids"
comm -12 "$owned_ids" "$active_ids" > "$leaked_ids"
if [[ ! -s "$leaked_ids" ]]; then
exit 0
fi
if [[ "$attempt" -lt 10 ]]; then
sleep 2
fi
done
echo "Selenium sessions still active after test teardown:" >&2
sed 's/^/ /' "$leaked_ids" >&2
exit 1This check does not use paths as a fake duplicate detector. File paths are unique by construction. It extracts session IDs from marker names and compares them with independently observed remote session IDs. If driver.quit() is removed from ManagedDriver.close(), a test completes, and Grid retains the session, that session appears in both sets and the script fails.
Keep the raw GraphQL response when the query itself fails. A curl connection error, HTTP error, malformed response, or GraphQL errors member is an infrastructure failure, not proof of zero sessions. The script exits nonzero in those cases because silence would turn an unavailable oracle into a pass.
Worked example one is an assertion failure. The JUnit report contains expected: <Signed in> but was: <Authentication failed>. The .closed marker exists, Grid no longer lists the ID, and the independent guard passes. The product or test assertion failed, but cleanup worked. Do not reopen the cleanup design because the overall build is red.
Worked example two is a missing teardown call. The test report is green, the marker remains .open or becomes .closed only if a broken wrapper says so, and Grid still returns the owned ID. The independent guard fails after Maven returns. This is a framework defect even though the user journey passed.
Worked example three is a fork crash. Failsafe may report that the forked VM terminated without properly saying goodbye, depending on the failure and plugin version. Do not copy an exact message from a different version into an assertion. Use the evidence you control: the marker was written, there is no completed test teardown record, and Grid still lists the same ID. That combination supports the cleanup diagnosis without guessing why the process died.
A fourth failure occurs between session creation and fixture assignment. The driver factory returns, then a screenshot directory, account fixture, or page object constructor throws before the test field receives the driver. An @AfterEach method that reads only that field sees null and cannot quit the already-created session. Put ownership around the constructor itself, as ManagedDriver.start does, and acquire later fixtures only after the managed resource exists. The marker timestamp and the absence of a test-method start record distinguish this setup leak from an assertion failure.
Another case looks like cleanup but is actually reuse. A static driver survives intentionally across methods, one test closes its last window, and the next method receives NoSuchSessionException or a window-related error. Grid has no leaked session, so the remote intersection is empty. The framework reused an invalid local reference. Fix the sharing policy or create a new session; increasing post-phase polling cannot repair it.
Tell a cleanup defect from a crashed fork or slow Grid
Start at the last confirmed boundary. Did the driver constructor return a session ID? If no marker exists and Grid never logged a new session, the failure happened during allocation. Investigate capabilities, Grid availability, driver startup, or network connectivity. There is no created session for the cleanup code to release.
If an .open marker exists, the session was created and the wrapper did not complete its transition after quit(). Read the JUnit result and the test process output. A teardown stack trace from driver.quit() is direct evidence. A sudden fork termination with no teardown output is a different branch. Both may leave a remote session, but one is a client command failure and the other bypassed the callback.
If a .closed marker exists while Grid briefly lists the session, rerun the GraphQL query during the bounded polling window. A session that disappears within the agreed grace period is delayed remote convergence, not necessarily a leak. Preserve the delay as an operational signal if it grows, but do not classify every single observation immediately after quit() as a defect. A session that remains beyond the bound blocks capacity and should fail the guard.
If Grid does not respond, stop calling the problem “driver cleanup.” The oracle is unavailable. Retain the HTTP or connection failure and the marker set. The build should remain red because it cannot prove cleanup, but the owner is Grid or network infrastructure until the endpoint recovers.
Port and process leftovers on a local runner are another near-miss. A browser may be gone while a driver service process remains, or the Java fork may stay alive because a test started a non-daemon thread. Selenium’s local session lifecycle and Failsafe fork shutdown are related operationally but not identical. Check the remote session, browser process, driver process, and Maven fork separately. Do not infer one from another.
Retries make diagnosis harder. Failsafe can rerun tests depending on configuration, but a second execution can create a different session ID. Keep markers per session and reports per attempt. If attempt one leaks and attempt two passes, the build still owns the first active session. A final green assertion must not delete or overwrite the marker that identifies it.
A cleanup report should contain the test class and method, attempt number, session ID, browser capabilities returned by Grid, marker state, quit() outcome, GraphQL observation time, and the Failsafe report path. Avoid logging credentials or full capabilities without review, since vendor capabilities can contain tokens or tunnel identifiers. Record only fields needed to identify the session and route the incident.
Migrate an existing suite without losing the original failure
Begin with inventory, not a global refactor. Find every new ChromeDriver, new FirefoxDriver, new RemoteWebDriver, driver factory, static driver field, and fixture that returns a driver. Trace where each instance is released. A factory that creates sessions but delegates cleanup to callers needs a documented ownership contract. A base class that sometimes creates a driver and sometimes receives one needs to become explicit before automation can enforce anything.
Add session markers in observation mode first. Record created IDs and quit() results, run the suite, and inspect how many paths never close. Do not immediately kill every active Grid session after the job. On shared infrastructure, a global delete can terminate sessions owned by other builds. Scope every action to IDs recorded by the current build.
Next, move teardown next to creation. A try-with-resources wrapper works well for independent tests. A JUnit extension can handle suites that need browser fixtures. Keep the first assertion failure intact. If cleanup throws too, allow the framework to report the cleanup error rather than replacing the test body with a broad catch that logs one exception and discards the other.
Introduce the post-phase observation as non-gating for a small number of runs, but do not publish invented success rates. Count actual owned sessions, actual leaks, and actual query failures from your CI. Review each one. Once the ledger covers every creation path and the Grid query is reliable, keep the post-phase observation nonfatal and enable the independent CI guard to fail on a nonempty intersection.
Then change CI to invoke the full lifecycle. The job below runs mvn verify, captures its status, runs the Grid assertion even when Failsafe is red, and fails after reporting whether one or both checks failed. It does not run integration-test as a standalone shortcut. The project POM still owns environment decommissioning in post-integration-test; the wrapper owns combined reporting.
name: selenium-integration-tests
on:
pull_request:
workflow_dispatch:
jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 40
env:
SELENIUM_GRID_URL: http://selenium-grid.internal:4444
APP_BASE_URL: https://test-app.internal
DRIVER_SESSION_LEDGER: target/driver-sessions
steps:
- uses: actions/checkout@v7
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: "21"
cache: maven
- name: Run Failsafe and the independent session guard
shell: bash
run: |
set +e
mvn --batch-mode --no-transfer-progress verify
failsafe_status=$?
./scripts/assert-no-owned-grid-sessions.sh
cleanup_status=$?
set -e
if [[ "$failsafe_status" -ne 0 ]]; then
echo "Maven or Failsafe verification failed" >&2
fi
if [[ "$cleanup_status" -ne 0 ]]; then
echo "Owned Selenium sessions survived teardown" >&2
fi
if [[ "$failsafe_status" -ne 0 || "$cleanup_status" -ne 0 ]]; then
exit 1
fi
- name: Preserve test and cleanup evidence
if: always()
uses: actions/upload-artifact@v4
with:
name: failsafe-and-session-evidence
path: |
target/failsafe-reports
target/driver-sessionsThe internal URLs are configuration examples, not public services. A real project should inject secrets through its CI platform and avoid putting credentials in the Grid URL. If Grid uses TLS or authentication, configure the client according to the infrastructure contract instead of weakening certificate checks in the test script.
Roll out parallelism last. Concurrent forks can create and close sessions in any order. Per-session marker files avoid a shared append operation and make that safe across JVMs on one workspace. If modules run in separate workspaces or containers, give each module its own ledger and run the guard where all relevant markers are visible. A parent build cannot verify files it never receives.
The cost is added failure surface. Grid GraphQL can be unavailable. Marker storage can fail. Polling adds a bounded delay. Artifact retention consumes space. Those costs are justified only if the team treats cleanup failures as actionable infrastructure defects. If every red guard is manually ignored, the suite has gained complexity without protection.
Plan for cancellation separately. A CI platform can terminate the Maven process before post-integration-test, so neither JUnit teardown, the post-phase observation, nor the later CI guard is guaranteed to finish. For high-cost shared infrastructure, give the scheduler or Grid owner a lease tied to the build identity and an external expiry process. That process should target only sessions carrying the verified lease, retain what it removed, and report the cleanup as an abnormal recovery. It limits damage from machine loss, but it does not make the canceled build successful.
Multi-module builds need an equally explicit scope. If each module creates sessions and has its own target directory, run a guard in each module or aggregate those ledgers into a location the final guard can read. A root script that scans only the parent target directory has no evidence about child markers. Prove ledger discovery with a fixture containing markers in every supported layout before enabling parallel reactor builds.
Know when post-integration cleanup is the wrong fix
Do not use a build-level session killer to compensate for missing quit() calls. It delays release until the whole suite ends, consumes Grid capacity between tests, and destroys the connection between a leak and its creator. Fix ownership in Java first.
Avoid global cleanup on a shared Grid. “Delete all sessions after my job” is unsafe unless the Grid is dedicated and the job owns the entire service. Session IDs, build tags stored in verified capabilities, or an infrastructure lease are acceptable ownership signals. A timestamp or total count is not.
Do not bind essential environment teardown only to verify. Failsafe’s result verification belongs there, but environment decommissioning belongs before it in post-integration-test. The separate CI assertion shown above runs after Maven only so it can combine the Failsafe and leak outcomes. It is a verifier, not the owner of test servers, tunnels, or normal driver shutdown.
Skip the GraphQL guard when the remote provider does not expose Selenium Grid’s documented schema. Commercial clouds may provide their own job and session APIs. Use the provider’s official interface and keep the same ownership rule: compare sessions created by this build with sessions still active. Do not pretend a Selenium endpoint exists on a service that does not implement it.
A short-lived local smoke test may need only try-with-resources and process inspection. A large shared Grid benefits more from a remote leak oracle. Match the safety net to the resource’s impact. The universal requirement is smaller: the creator owns normal cleanup, failures preserve their first cause, and an exceptional process death cannot quietly leave capacity allocated.
Treat timeouts as containment, not correctness. A remote session that expires eventually still blocked a slot and may have retained sensitive application state. A credible cleanup design can name every session the build created, show the quit() outcome for ordinary paths, and fail on an owned session that remains active after the agreed grace period.
// FIELD DISPATCH
Get the QA Field Notes
Weekly QA battles, AI testing guides, and interview drills. Free on Substack.
// LIVE COURSE / THE TESTING ACADEMY
Playwright Automation Mastery
Go beyond Selenium. Master Playwright with JS/TS in 90 days.
From the instructor behind this guide.
Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.
PRIMARY REFERENCES
Verify the details at the source
QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.
- 01Official maven.apache.org reference
maven.apache.org
Primary documentation selected and verified for the claims in this guide.
- 02Official maven.apache.org reference
maven.apache.org
Primary documentation selected and verified for the claims in this guide.
- 03Official selenium.dev reference
selenium.dev
Primary documentation selected and verified for the claims in this guide.
- 04Official selenium.dev reference
selenium.dev
Primary documentation selected and verified for the claims in this guide.
FAQ / QUICK ANSWERS
Questions testers ask
Where should Selenium driver quit run in a Failsafe suite?
Put driver.quit() in per-test teardown or an owned AutoCloseable fixture so it runs when the test body fails. Use a later Maven phase as a leak detector and safety net, not as the normal owner of every driver.
Why is driver.close not enough after an integration test?
Closing a window is not the same operation as ending the WebDriver session. Selenium recommends quit() for session teardown because it deletes the session and releases its associated browser resources.
Should CI call mvn integration-test or mvn verify?
Run mvn verify, or a later lifecycle phase, for the complete Failsafe sequence. Calling integration-test directly stops before post-integration-test and verify, which can leave environment cleanup and result verification undone.
How can I prove a Selenium Grid session leaked?
Record the session IDs created by the current build, then compare them with Grid's active sessions after teardown. A nonempty intersection is direct evidence; a high global session count is not, because other builds may own those sessions.
What if the forked test JVM is killed?
Keep a build-level guard outside the fork and retain a marker as soon as each session starts. The guard can identify an active owned session even when the JVM that created it never reaches a JUnit teardown callback.
Can a cleanup error replace the original test failure?
Preserve both. Let the test report keep the assertion or application failure, attach cleanup evidence separately, and make the build fail if teardown also failed instead of logging and swallowing the second problem.
RELATED GUIDES
Continue the learning route
GUIDE 01
Java Streams for Test Automation Assertions
A practical guide to Java streams test automation assertions, covering design, implementation, debugging, scale, measurable release gates, and senior interview scenarios.
GUIDE 02
Java Reflection and Annotations for Custom Test Runners
Java reflection annotations test runners: practical design, implementation, debugging, CI, metrics, and interview guidance for QA, SDET, and automation engineers.
GUIDE 03
Selenium Grid Trace Correlation with Test IDs
Master Selenium grid trace correlation with practical examples, architecture decisions, failure analysis, CI guidance, metrics, and scenario-led interview answers.
GUIDE 04
Selenium Java Tutorial: Build a Maintainable Test Suite
Selenium Java tutorial for beginners covering Maven setup, WebDriver, waits, TestNG, JUnit, page objects, debugging, CI, and reliable UI tests.
GUIDE 05
Test Passkeys and WebAuthn with Selenium Virtual Authenticator
Test passkey registration and sign-in with Selenium virtual authenticators, explicit WebAuthn options, negative paths, and reliable diagnostics.