02 · Selenium Advanced¶
Level 1 covered find_element, click, send_keys, and explicit waits — enough
to automate a static form. Real applications are not static. They render content
after an XHR resolves, bury widgets in iframes, open new tabs, throw alerts, and
rebuild the DOM underneath you while a test is mid-click. This module covers the
Selenium APIs that exist specifically to survive that.
Every snippet below was run against the public Selenium practice pages at
https://www.selenium.dev/selenium/web/ with headless Chrome 150.
1. The fixture set-up used throughout¶
# conftest.py
import pytest
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.support.ui import WebDriverWait
@pytest.fixture(scope="session")
def base_url():
return "https://www.selenium.dev/selenium/web"
@pytest.fixture
def driver():
options = Options()
options.add_argument("--headless=new")
options.add_argument("--window-size=1400,1000")
drv = webdriver.Chrome(options=options)
yield drv
drv.quit()
@pytest.fixture
def wait(driver):
return WebDriverWait(driver, timeout=10)
--window-size is not cosmetic. Headless Chrome defaults to a small viewport,
and elements below the fold can report is_displayed() == False or refuse
clicks. A missing window size is one of the most common "passes locally, fails
in CI" causes.
2. Waiting for things that do not exist yet¶
presence_of_element_located returns as soon as the node is in the DOM.
visibility_of_element_located also requires it to be rendered with non-zero
size. Choosing the wrong one is the single biggest source of flake.
def test_dynamic_element_appears(driver, wait, base_url):
driver.get(f"{base_url}/dynamic.html")
driver.find_element(By.ID, "adder").click()
box = wait.until(EC.visibility_of_element_located((By.ID, "box0")))
assert "redbox" in box.get_attribute("class")
Use presence when you only need to read an attribute or check existence.
Use visibility before any interaction. Use element_to_be_clickable when
the element can be present and visible but still disabled or covered by an
overlay — a modal backdrop that has not finished fading out will pass a
visibility check and still swallow your click.
Custom expected conditions¶
expected_conditions is just a module of callables that take the driver and
return either a falsy value (keep polling) or a truthy value (which becomes the
wait.until return value). Anything matching that contract works:
def element_count_is(locator, expected):
def predicate(drv):
found = drv.find_elements(*locator)
return found if len(found) == expected else False
return predicate
def test_custom_expected_condition(driver, wait, base_url):
driver.get(f"{base_url}/dynamic.html")
driver.find_element(By.ID, "adder").click()
boxes = wait.until(element_count_is((By.CSS_SELECTOR, ".redbox"), 1))
assert len(boxes) == 1
This is how you wait for "the results table has finished loading all 20 rows" or "the spinner is gone AND the total is non-zero" — conditions no built-in covers.
Trap — the flaky wait
time.sleep(3) # ✗ slow when it works, flaky when it doesn't
driver.implicitly_wait(10) # ✗ interacts badly with explicit waits
wait.until(EC.presence_of_element_located(BUTTON)).click() # ✗ present ≠ clickable
time.sleep guesses. An implicit wait applies to every find_element
and, when mixed with WebDriverWait, can multiply timeouts unpredictably —
pick one strategy (explicit) and never set an implicit wait at all.
Always pass message= so a timeout tells you what was expected:
short = WebDriverWait(driver, timeout=2)
short.until(EC.visibility_of_element_located((By.ID, "never-exists")),
message="box0 never became visible")
Without message, a TimeoutException reports an empty message and you are
left reading a chromedriver stack trace to guess which locator failed.
3. StaleElementReferenceException¶
A WebElement is a handle to a specific DOM node, not a live query. Re-render
that part of the page and the handle dies.
def test_stale_element_reference(driver, wait, base_url):
driver.get(f"{base_url}/dynamic.html")
driver.find_element(By.ID, "adder").click()
box = wait.until(EC.presence_of_element_located((By.ID, "box0")))
assert box.get_attribute("class") == "redbox"
driver.refresh() # the node `box` points at no longer exists
with pytest.raises(StaleElementReferenceException):
box.get_attribute("class")
The fix is never "retry the whole test". It is: do not hold an element across anything that can re-render. Re-find inside the wait predicate instead.
def test_retry_on_stale(driver, wait, base_url):
driver.get(f"{base_url}/dynamic.html")
driver.find_element(By.ID, "adder").click()
wait.until(EC.presence_of_element_located((By.ID, "box0")))
driver.refresh()
driver.find_element(By.ID, "adder").click()
# re-find on every poll — never cache the element across a reload
css = wait.until(lambda d: d.find_element(By.ID, "box0").get_attribute("class"))
assert css == "redbox"
This is also the argument for storing locator tuples in page objects rather
than resolved elements: a tuple can be re-resolved, a WebElement cannot.
4. Frames, windows, and tabs¶
The driver has exactly one "focus". Elements outside it are invisible to
find_element — it does not raise a helpful error, they simply are not found.
def test_iframe_switching(driver, base_url):
driver.get(f"{base_url}/iframes.html")
assert driver.find_elements(By.ID, "imageButton") == [] # inside the frame
driver.switch_to.frame(driver.find_element(By.ID, "iframe1"))
assert driver.find_element(By.ID, "imageButton").is_displayed()
driver.switch_to.default_content() # always switch back
assert driver.find_elements(By.ID, "imageButton") == []
Forgetting switch_to.default_content() leaves every later step in the test
scoped to the frame, producing "element exists in the browser but Selenium
cannot see it" — a bug that looks like a locator problem and is not.
def test_new_window(driver, base_url):
driver.get(f"{base_url}/xhtmlTest.html")
original = driver.current_window_handle
driver.switch_to.new_window("tab")
driver.get(f"{base_url}/formPage.html")
assert driver.title == "We Leave From Here"
driver.close() # closes the new tab only
driver.switch_to.window(original) # focus does NOT return automatically
assert driver.title == "XHTML Test Page"
driver.close() closes the focused window; driver.quit() ends the whole
session. After close(), the driver has no valid focus until you switch
explicitly.
5. Alerts¶
Native browser dialogs are not DOM. find_element will never reach them, and
an unhandled alert makes the next unrelated command fail with
UnexpectedAlertPresentException.
def test_alerts(driver, wait, base_url):
driver.get(f"{base_url}/alerts.html")
driver.find_element(By.ID, "prompt").click()
alert = wait.until(EC.alert_is_present())
assert alert.text == "Enter something"
alert.send_keys("automated")
alert.accept() # or .dismiss() for cancel
assert driver.find_element(By.ID, "text").text == "automated"
6. ActionChains — hover, drag, modifier keys¶
def test_action_chains_hover(driver, base_url):
driver.get(f"{base_url}/mouse_interaction.html")
hover = driver.find_element(By.ID, "hover")
ActionChains(driver).move_to_element(hover).perform()
assert driver.find_element(By.ID, "move-status").text == "hovered"
def test_action_chains_keys(driver, base_url):
driver.get(f"{base_url}/web-form.html")
field = driver.find_element(By.NAME, "my-text")
ActionChains(driver).click(field).send_keys("hello world").perform()
ActionChains(driver).key_down(Keys.COMMAND).send_keys("a").key_up(Keys.COMMAND).perform()
ActionChains(driver).send_keys("replaced").perform()
assert field.get_attribute("value") == "replaced"
Two rules: nothing happens until .perform(), and every key_down needs a
matching key_up — a leaked modifier turns every later keystroke in the session
into a shortcut. Note Keys.COMMAND is macOS; use Keys.CONTROL elsewhere, so
parameterise it if your suite runs on both.
7. The JavaScript escape hatch¶
def test_javascript_executor(driver, base_url):
driver.get(f"{base_url}/web-form.html")
height = driver.execute_script("return document.body.scrollHeight;")
assert height > 0
field = driver.find_element(By.NAME, "my-text")
driver.execute_script("arguments[0].value = arguments[1];", field, "set by JS")
assert field.get_attribute("value") == "set by JS"
Legitimate uses: scrolling an element into view, reading computed state, waiting
on an app-specific JS flag. Illegitimate use: execute_script("arguments[0].click()")
to "fix" a click Selenium refuses. Selenium refused because a real user could not
click it — an overlay was covering it, or it was off-screen. A JS click makes
the test pass and ships the bug.
8. Real run¶
test_adv.py::test_dynamic_element_appears PASSED [ 9%]
test_adv.py::test_custom_expected_condition PASSED [ 18%]
test_adv.py::test_stale_element_reference PASSED [ 27%]
test_adv.py::test_retry_on_stale PASSED [ 36%]
test_adv.py::test_iframe_switching PASSED [ 45%]
test_adv.py::test_new_window PASSED [ 54%]
test_adv.py::test_javascript_executor PASSED [ 63%]
test_adv.py::test_action_chains_hover PASSED [ 72%]
test_adv.py::test_action_chains_keys PASSED [ 81%]
test_adv.py::test_alerts PASSED [ 90%]
test_adv.py::test_timeout_message PASSED [100%]
============================= 11 passed in 23.99s ==============================
How It Actually Works¶
Advanced Selenium waits and actions expose more of the wire-protocol machinery from
Level 1. ActionChains doesn't perform a drag-and-drop as one atomic call — it builds
a queue of low-level pointer/keyboard events (pointerMove, pointerDown,
pointerMove, pointerUp) that get serialized into a single POST
/session/{id}/actions request per the W3C Actions API, then replayed by the driver
against the real input pipeline of the OS/browser — this is why ActionChains can
correctly simulate a native HTML5 drag event that a synthetic .click() cannot: it's
generating the same sequence of low-level pointer events a real mouse would.
Switching frames or windows (driver.switch_to.frame(...)) changes which "browsing
context" subsequent WebDriver commands target — internally the driver session tracks
a current context ID, and every find_element call after a switch_to is scoped to
that context's DOM, not the top-level document. This is why elements inside an
<iframe> are invisible to find_element until you explicitly switch into that
frame's context — Selenium isn't failing to find them, it's correctly searching the
wrong document.
JavaScript execution (driver.execute_script(...)) bypasses the WebDriver element
protocol entirely and hands a raw script string to the browser's JS engine for direct
evaluation in page context — useful for reading properties WebDriver's protocol
doesn't expose (like a custom JS object's internal state), but it also means anything
you do this way is invisible to WebDriver's own element-staleness tracking.
Cheat sheet¶
| Need | API |
|---|---|
| Element in DOM, may be hidden | EC.presence_of_element_located |
| Element rendered, about to read it | EC.visibility_of_element_located |
| About to click it | EC.element_to_be_clickable |
| Anything else | Custom predicate passed to wait.until |
| Timeout that names the failure | wait.until(cond, message="...") |
| Enter an iframe / leave it | switch_to.frame(el) / switch_to.default_content() |
| New tab | switch_to.new_window("tab"), then switch_to.window(handle) |
| Native dialog | EC.alert_is_present(), then .accept() / .dismiss() |
| Hover, drag, modifier keys | ActionChains(driver)...perform() |
| Read state JS knows and Selenium doesn't | driver.execute_script("return ...") |
| Stale element | Re-find it; never cache across a re-render |
Exercise¶
- Write a custom expected condition
text_is_not_empty(locator)and use it onmouse_interaction.htmlinstead of asserting immediately after the hover. Confirm it removes the need for any sleep. - Reproduce
StaleElementReferenceExceptionondynamic.html, then fix it two ways — re-finding the element, and re-finding inside thewait.untilpredicate. Explain in a comment which one belongs in a page object. - Take a test that uses
switch_to.frameand deliberately delete theswitch_to.default_content()call. Add a second assertion after it and record the exact exception you get, so you recognise it next time. - Write a test on
alerts.htmlthat clicks the prompt but never accepts it, then triesdriver.find_element. Record the exception name and message. - Replace one
element_to_be_clickablewait withexecute_script("arguments[0].click()")and describe one real bug that change would hide from you.