π―
What This Article Covers
- The Flakiness Problem: Why time.sleep() and WebDriverWait arenβt enough
- What Causes Flaky Tests: Racing against UI state changes
- The Stability Solution: Monitoring DOM, network, animations, and layout shifts
- One-Line Integration: Wrap your driver with stabilize() β zero test rewrites
- Full Diagnostics: Know exactly why tests are blocked
If youβve worked with Selenium for more than a week, youβve written code like this:
driver.get("https://myapp.com/dashboard")
time.sleep(2) # Wait for page to load
driver.find_element(By.ID, "submit-btn").click()
time.sleep(1) # Wait for AJAX
And youβve felt the shame of knowing itβs wrongβbut also the relief of "it works." Until it doesnβt. Until the CI server is 10% sloβ¦
π―
What This Article Covers
- The Flakiness Problem: Why time.sleep() and WebDriverWait arenβt enough
- What Causes Flaky Tests: Racing against UI state changes
- The Stability Solution: Monitoring DOM, network, animations, and layout shifts
- One-Line Integration: Wrap your driver with stabilize() β zero test rewrites
- Full Diagnostics: Know exactly why tests are blocked
If youβve worked with Selenium for more than a week, youβve written code like this:
driver.get("https://myapp.com/dashboard")
time.sleep(2) # Wait for page to load
driver.find_element(By.ID, "submit-btn").click()
time.sleep(1) # Wait for AJAX
And youβve felt the shame of knowing itβs wrongβbut also the relief of "it works." Until it doesnβt. Until the CI server is 10% slower than your machine, and suddenly your tests fail 20% of the time.
This is the story of flaky tests, why they happen, and how I built a library called waitless to eliminate them.
β οΈ
The Flakiness Problem
Let me show you a real scenario. You have a React dashboard. User clicks a button. The button triggers an API call. The API returns data. React re-renders the component. A spinner disappears. A table appears.
This entire sequence takes maybe 400ms. But your test does this:
button = driver.find_element(By.ID, "load-data")
button.click()
table = driver.find_element(By.ID, "data-table") # π₯ BOOM
The table doesnβt exist yet. React is still fetching. Selenium throws NoSuchElementException.
So you "fix" it:
button.click()
time.sleep(2)
table = driver.find_element(By.ID, "data-table") # Works... usually
The Problem with time.sleep()
The Problem with time.sleep()
Congratulations. Youβve just made your test: 1) 2 seconds slower than necessary, 2) Still flaky when the API takes 2.5 seconds, 3) Impossible to debug when it fails.
β
Why Traditional Solutions Donβt Work
time.sleep() β The Naive Approach
Sleep for a fixed duration and hope the UI is ready. Problems: Too short β test fails. Too long β test suite takes forever. No feedback on whatβs actually happening.
WebDriverWait β The "Correct" Approach
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
WebDriverWait(driver, 10).until(
EC.element_to_be_clickable((By.ID, "submit-btn"))
)
This is better. Youβre waiting for a specific condition. But hereβs the dirty secret: it only checks one element.
- What about the modal thatβs still animating into view?
- What about the AJAX request that hasnβt finished?
- What about the React re-render thatβs about to move your button?
WebDriverWait says "the button is clickable." Reality says "thereβs an invisible overlay from an animation that will intercept your click."
Retry Decorators β The Denial Approach
@retry(tries=3, delay=1)
def test_dashboard():
driver.find_element(By.ID, "submit-btn").click()
This is the equivalent of saying "I know my code is broken, but if I run it enough times, itβll eventually work." Retries donβt fix flakiness. They hide it.
π
What Actually Causes Flaky Tests?
After debugging hundreds of flaky tests, I found they all come down to racing against the UI:
| What You Do | What's Actually Happening |
|--------------------------|-------------------------------------|
| Click a button | DOM is being mutated by framework |
| Assert text content | AJAX response still in flight |
| Interact with modal | CSS transition still animating |
| Click navigation link | Layout shift moves element |
The Real Question
The question isnβt "is this element clickable?" The question is: "Is the entire page stable and ready for interaction?" Thatβs what I set out to answer with waitless.
β¨
Defining "Stability"
What does it mean for a UI to be "stable"? I identified four key signals:
1. DOM Stability
The DOM structure has stopped changing. No elements being added, removed, or modified. How to detect: MutationObserver watching the document root. Track time since last mutation.
2. Network Idle
All AJAX requests have completed. No pending API calls. How to detect: Intercept fetch() and XMLHttpRequest. Count pending requests.
3. Animation Complete
All CSS animations and transitions have finished. How to detect: Listen for animationstart, animationend, transitionstart, transitionend events.
4. Layout Stable
Elements have stopped moving. No more layout shifts. How to detect: Track bounding box positions of interactive elements. Compare over time.
ποΈ
The Architecture
Waitless has two parts:
JavaScript Instrumentation (runs in browser)
window.__waitless__ = {
pendingRequests: 0,
lastMutationTime: Date.now(),
activeAnimations: 0,
isStable() {
if (this.pendingRequests > 0) return false;
if (Date.now() - this.lastMutationTime < 100) return false;
return true;
}
};
This script is injected into the page via execute_script(). It monitors everything happening in the browser.
Python Engine (evaluates stability)
class StabilizationEngine:
def wait_for_stability(self):
"""Waits until all stability signals are satisfied."""
# Checks performed automatically:
# β DOM mutations have settled
# β Network requests completed
# β Animations finished
# β Layout is stable
The Python engine continuously evaluates browser state until all configured stability signals indicate the page is ready for interaction.
πͺ
The Magic: One-Line Integration
The key design goal was zero test modifications. Adding stability detection should require changing ONE line:
from waitless import stabilize
driver = webdriver.Chrome()
driver = stabilize(driver) # β This is the only change
# All your existing tests work as-is
driver.find_element(By.ID, "button").click() # Now auto-waits!
How does this work? The stabilize() function wraps the driver in a StabilizedWebDriver that intercepts find_element() calls. Retrieved elements are wrapped in StabilizedWebElement. When you call .click(), it first waits for stability, then clicks.
class StabilizedWebElement:
def click(self):
self._engine.wait_for_stability() # Auto-wait!
return self._element.click() # Then click
Zero Rewrites Required
Your tests donβt know theyβre waiting. They just... stop failing.
π§
Handling Edge Cases
Real apps arenβt simple. Hereβs how waitless handles the messy reality:
Problem: Infinite Animations
Some apps have spinners that rotate forever. Analytics scripts that poll constantly. WebSocket heartbeats that never stop.
Solution: Configurable thresholds
from waitless import StabilizationConfig
config = StabilizationConfig(
network_idle_threshold=2, # Allow 2 pending requests
animation_detection=False, # Ignore spinners
strictness='relaxed' # Only check DOM mutations
)
driver = stabilize(driver, config=config)
Problem: Navigation Destroys Instrumentation
Single-page apps remake the DOM on route changes. The injected JavaScript disappears.
Solution: Re-validation before each wait
def wait_for_stability(self):
if not self._is_instrumentation_alive():
self._inject_instrumentation() # Re-inject if gone
# Then wait...
π
Diagnostics: The Secret Weapon
When tests still fail, understanding why is half the battle. Waitless includes a diagnostic system that explains exactly whatβs blocking stability:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β WAITLESS STABILITY REPORT β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ£
β Timeout: 10.0s β
β β
β BLOCKING FACTORS: β
β β NETWORK: 2 request(s) still pending β
β β GET /api/users (started 2.3s ago) β
β β POST /analytics (started 1.1s ago) β
β β
β β ANIMATIONS: 1 active animation(s) β
β β .spinner { animation: rotate 1s infinite } β
β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ£
β SUGGESTIONS: β
β 1. /api/users is slow. Consider mocking in tests. β
β 2. Spinner has infinite animation. Set β
β animation_detection=False β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
This isnβt just "test failed." Itβs "test failed because your analytics endpoint is slow, and hereβs exactly how to fix it."
π
The Results
Hereβs what changes when you adopt waitless:
Before
driver.get("https://myapp.com")
time.sleep(2)
WebDriverWait(driver, 10).until(
EC.element_to_be_clickable((By.ID, "login-btn"))
)
driver.find_element(By.ID, "login-btn").click()
time.sleep(1)
driver.find_element(By.ID, "username").send_keys("user")
After
driver = stabilize(driver)
driver.get("https://myapp.com")
driver.find_element(By.ID, "login-btn").click()
driver.find_element(By.ID, "username").send_keys("user")
| Metric | Before | After |
|----------------------|----------------------|-----------------|
| Lines of wait code | 4+ per test | 1 total |
| Arbitrary delays | 3+ seconds | 0 |
| Flaky failures | Common | Rare |
| Debug information | "Element not found" | Full stability report |
π
Why Not Just Use Playwright?
Playwright has auto-waiting built in. Itβs great! But:
- Migration cost β You have 10,000 Selenium tests. Rewriting isnβt an option.
- Framework lock-in β Playwright auto-wait is Playwright-only.
- Different approach β Playwright waits for element actionability. Waitless waits for page-wide stability.
The Best of Both Worlds
Waitless gives Selenium users the reliability of Playwright without the rewrite.
β οΈ
Current Limitations (v0.2.0)
Being honest about what doesnβt work yet:
- Selenium only β Playwright integration planned for v1
- Sync only β No async/await support
- Main frame only β iframes not monitored
- No Shadow DOM β MutationObserver canβt see shadow roots
- Chrome-focused β Tested primarily on Chromium
These will be addressed in future versions β contributions welcome!
π
Try It Yourself
pip install waitless
from selenium import webdriver
from waitless import stabilize
driver = webdriver.Chrome()
driver = stabilize(driver)
# Your tests are now stable
driver.get("https://your-app.com")
driver.find_element(By.ID, "button").click()
One line. Zero test rewrites. No more flaky failures.
β
Conclusion
Flaky tests are a symptom of racing against UI state. The solution isnβt longer sleeps or more retriesβitβs understanding when the UI is truly stable.
Waitless monitors DOM mutations, network requests, animations, and layout shifts to answer one question: "Is this page ready for interaction?"
The Bottom Line
Your tests should be deterministic. Your CI should be green. And you should never write time.sleep() again.
- PyPI: pypi.org/project/waitless
- GitHub: github.com/godhiraj-code/waitless
Built by Dhiraj Das
Automation Architect. Making Selenium tests deterministic, one at a time.