Speeding Up Test Suites
Where Selenium suites lose time and how to get it back: measure first, then cut command count, skip UI you are not testing, fix waits, parallelise, block third-party traffic, and shrink the suite itself.
A 40-minute suite gets run nightly; a 6-minute suite gets run on every pull request. Speed changes how a suite is used, which changes how many bugs it catches. Most slow Selenium suites are slow for the same handful of reasons, and none of them is “Selenium is slow.” This lesson is the ordered list of fixes, biggest payoff first, with the measurement that tells you which apply to you.
Measure Before You Optimise
Instrument three numbers per test: wall time, number of WebDriver commands, and time spent inside waits. A command counter is a five-line listener; the ratio of wait time to total time tells you whether you are waiting on the app or on your own sleeps.
// Selenium 4 event listener wraps any driverimport org.openqa.selenium.support.events.EventFiringDecorator;import org.openqa.selenium.support.events.WebDriverListener;
public class CommandTimer implements WebDriverListener { private final AtomicInteger commands = new AtomicInteger(); private final AtomicLong nanos = new AtomicLong(); private long start;
@Override public void beforeAnyCall(Object target, Method method, Object[] args) { start = System.nanoTime(); } @Override public void afterAnyCall(Object target, Method method, Object[] args, Object result) { commands.incrementAndGet(); nanos.addAndGet(System.nanoTime() - start); } public String report() { return String.format("%d commands, %.1f s in driver calls", commands.get(), nanos.get() / 1e9); }}
CommandTimer timer = new CommandTimer();WebDriver driver = new EventFiringDecorator<>(timer).decorate(new ChromeDriver());// ... test ...System.out.println(timer.report());import timefrom selenium.webdriver.support.abstract_event_listener import AbstractEventListenerfrom selenium.webdriver.support.event_firing_webdriver import EventFiringWebDriver
class CommandTimer(AbstractEventListener): def __init__(self): self.commands = 0 self.seconds = 0.0 self._start = 0.0 def before_find(self, by, value, driver): self._start = time.perf_counter() def after_find(self, by, value, driver): self.commands += 1 self.seconds += time.perf_counter() - self._start # Also implement before_click/after_click, before_navigate_to/after_navigate_to, etc.
timer = CommandTimer()driver = EventFiringWebDriver(webdriver.Chrome(), timer)# ... test ...print(f"{timer.commands} commands, {timer.seconds:.1f} s in driver calls")
# Simpler: wrap driver.execute at the lowest leveloriginal = driver.wrapped_driver.executedef timed_execute(command, params=None): t = time.perf_counter() try: return original(command, params) finally: print(f"{command}: {(time.perf_counter() - t) * 1000:.0f} ms")driver.wrapped_driver.execute = timed_execute// Wrap the executor: every command passes through driver.executeconst { Command } = require('selenium-webdriver/lib/command');let commands = 0, totalMs = 0;
const originalExecute = driver.execute.bind(driver);driver.execute = async (command) => {const t = Date.now();try { return await originalExecute(command); }finally { commands++; totalMs += Date.now() - t; }};// ... test ...console.log(`${commands} commands, ${(totalMs / 1000).toFixed(1)} s in driver calls`);// EventFiringWebDriver in OpenQA.Selenium.Support.Eventsusing OpenQA.Selenium.Support.Events;
var timer = Stopwatch.StartNew();int commands = 0;var efd = new EventFiringWebDriver(new ChromeDriver());efd.FindingElement += (_, _) => commands++;efd.ElementClicking += (_, _) => commands++;efd.Navigating += (_, _) => commands++;// ... test with efd ...Console.WriteLine($"{commands} commands in {timer.Elapsed.TotalSeconds:F1} s");Run the suite once with this on and sort tests by time. The top 10% of tests usually hold half the run time.
1. Stop Testing Login Through the UI
The single biggest win in most suites. If 300 tests each spend 8 seconds logging in, that is 40 minutes of the same screen. Log in once and reuse the session via cookies or a token in storage, or call the login API and set the cookie. See Cookies, Local Storage and Session Storage.
The same applies to any setup that is not the subject of the test: creating a product, adding items to a basket, accepting cookie banners. Do it through the API or a database seed; use the browser only for the behaviour under test.
2. Fix the Waits
- Remove every sleep. A
sleep(3)that “makes it stable” adds 3 seconds to every run, even when the app was ready in 200 ms. - Implicit wait to zero. Combined with explicit waits it makes every negative check (waiting for an element to disappear) take the full implicit timeout.
pageLoadStrategy = eagerfor single-page apps. See Page Load Strategy and Timeouts.- Poll faster for cheap conditions: 100 to 200 ms instead of the default 500 ms saves up to 400 ms per wait.
3. Cut Command Round Trips
Every command is an HTTP round trip (single-digit ms locally, tens of ms to a Grid or cloud). Tests that read 50 table cells with 50 getText calls can read them with one script.
// SLOW: 2 commands per row (findElement + getText), 100 rows = 200 round tripsList<WebElement> cells = driver.findElements(By.cssSelector("table.orders td.total"));List<String> totals = new ArrayList<>();for (WebElement cell : cells) totals.add(cell.getText());
// FAST: one round tripList<String> totals = (List<String>) ((JavascriptExecutor) driver).executeScript( "return [...document.querySelectorAll('table.orders td.total')].map(td => td.textContent.trim())");# SLOWtotals = [cell.text for cell in driver.find_elements(By.CSS_SELECTOR, "table.orders td.total")]
# FASTtotals = driver.execute_script( "return [...document.querySelectorAll('table.orders td.total')].map(td => td.textContent.trim())")// SLOWconst cells = await driver.findElements(By.css('table.orders td.total'));const totals = await Promise.all(cells.map((c) => c.getText()));
// FASTconst totals = await driver.executeScript("return [...document.querySelectorAll('table.orders td.total')].map(td => td.textContent.trim())");// SLOWvar totals = driver.FindElements(By.CssSelector("table.orders td.total")).Select(c => c.Text).ToList();
// FASTvar totals = ((IJavaScriptExecutor)driver).ExecuteScript( "return [...document.querySelectorAll('table.orders td.total')].map(td => td.textContent.trim())") as IReadOnlyCollection<object>;Other round-trip savers: scope searches to a container element, avoid findElements polling loops in favour of a single wait condition, and assert with one getAttribute("outerHTML") when you need several properties of one element.
4. Block Third-Party Traffic
Analytics, chat widgets, fonts from CDNs, A/B testing scripts and ad pixels can double page load time and add nondeterminism. Block them at the network layer with BiDi; the app under test is unaffected and every page loads faster. See Network Interception with BiDi. Chrome’s --host-resolver-rules flag is a cruder alternative: --host-resolver-rules=MAP *.doubleclick.net 127.0.0.1.
5. Parallelise, Then Shard
After the per-test fixes, run tests concurrently. On one machine, one browser per 2 vCPUs is a safe density; beyond that, a Grid or CI matrix. Order matters: parallelising a suite with shared state or sleeps multiplies the flakiness. See Parallel Execution and CI/CD Pipelines.
Also balance the shards. If one job runs the 10 slowest tests, the pipeline waits for it. Split by historical duration, not by file count.
6. Headless, Sized, and Close to the App
- Headless Chrome (
--headless=new) renders faster and uses less memory than headed. - Set the window size once; do not
maximizeevery test. - Run the Grid or browsers in the same network as the application under test. A test runner in one cloud region driving browsers in another, hitting an app in a third, pays three latencies per command.
7. Shrink the Suite
The fastest test is one you do not run in the browser. Apply the test pyramid honestly:
- A form’s validation rules belong in unit tests of the component, not in 30 Selenium tests that type invalid values.
- API behaviour belongs in API tests.
- Selenium tests should cover user journeys and integration points that only a real browser exercises: navigation, rendering, JavaScript interaction, authentication flows.
Review the suite quarterly: tests that have never failed in a year and duplicate lower-level coverage are candidates for deletion. Tag a small smoke set to run on every commit and the full set nightly.
8. Reuse Browsers Carefully
Starting Chrome costs around a second; on a 500-test suite that is 8 minutes. Reusing one browser across tests saves it but risks state leakage. A middle ground: reuse the browser within a test class, clear cookies and storage between tests, and start fresh per class. Only do this after the suite is otherwise stable.
A Worked Example
A suite of 320 tests took 38 minutes in CI. The measurements and fixes:
| Change | Time saved |
|---|---|
| Login via cookies instead of UI (320 × 7 s) | 37 min of CPU time; 12 min wall at 3 parallels |
Removed 41 sleep calls averaging 2 s | 1.4 min |
pageLoadStrategy = eager | 3 min |
| Blocked analytics and chat widget | 2 min |
| Parallelism 3 → 8 with rebalanced shards | Wall time divided by ~2.5 |
| Deleted 60 tests duplicating unit coverage | 5 min |
Result: 6 minutes wall time on every pull request.
Summary
- Measure commands and wait time per test; fix the slowest 10% first.
- Skip UI login and setup via cookies, APIs and seeds; that alone often halves the suite.
- Remove sleeps, zero the implicit wait, use
eagerloading, poll faster. - Batch reads with one script; block third-party requests with BiDi.
- Parallelise after the suite is stable, balance shards, and keep the browser layer thin.
Copy-paste recipes for this topic
- Block Analytics and Third-Party Requests
Stop trackers, chat widgets and ad scripts from loading during tests to speed up pages and remove external flakiness.