Your First Selenium Test
Install Selenium 4, drive a real browser, and read the three exceptions you will meet in your first hour — with the driver lifecycle that stops leaking processes.
Selenium drives a real browser through the W3C WebDriver protocol. You send commands, the browser executes them, and — unlike some newer tools — it does not wait for anything unless you tell it to. That last point is the whole character of the library.
Installing
pip install selenium pytest
python -c "import selenium; print(selenium.__version__)"
Successfully installed selenium-4.28.1 pytest-8.3.4 trio-0.27.0 websocket-client-1.8.0
4.28.1
No driver download. Since 4.6, Selenium Manager resolves and caches the right driver for
whichever browser it finds — the chromedriver in your PATH and the webdriver_manager
package are both obsolete.
The first script
We will drive demo.playwright.dev/todomvc, a public page that stays up, so every example here runs as written.
# first_test.py
from selenium import webdriver
from selenium.webdriver.common.by import By
driver = webdriver.Chrome()
try:
driver.get("https://demo.playwright.dev/todomvc")
print("title:", driver.title)
print("url: ", driver.current_url)
heading = driver.find_element(By.CSS_SELECTOR, "h1")
print("heading:", heading.text)
items = driver.find_elements(By.CSS_SELECTOR, ".todo-list li")
print("todo count:", len(items))
finally:
driver.quit()
python first_test.py
title: React • TodoMVC
url: https://demo.playwright.dev/todomvc/#/
heading: todos
todo count: 0
Three things to notice. find_element (singular) returns one element and raises if there
is none; find_elements (plural) returns a list and gives you [] instead. And the whole
body is wrapped in try/finally — without it, any exception leaves a Chrome process and a
chromedriver process alive.
The context-manager form is shorter and does the same job:
with webdriver.Chrome() as driver:
driver.get("https://demo.playwright.dev/todomvc")
print(driver.title)
React • TodoMVC
Adding a todo
from selenium.webdriver.common.keys import Keys
with webdriver.Chrome() as driver:
driver.get("https://demo.playwright.dev/todomvc")
box = driver.find_element(By.CLASS_NAME, "new-todo")
box.send_keys("buy milk")
box.send_keys(Keys.ENTER)
items = driver.find_elements(By.CSS_SELECTOR, ".todo-list li")
print("count:", len(items))
print("text: ", items[0].text)
print("counter:", driver.find_element(By.CLASS_NAME, "todo-count").text)
count: 1
text: buy milk
counter: 1 item left
send_keys types character by character, firing real key events. Keys.ENTER submits.
That worked — but only because the page happened to be fast enough. Nothing in this script waits for the list item to appear, and that is the bug lesson 3 is entirely about.
The three exceptions of your first hour
NoSuchElementException — the element was not there when you looked:
with webdriver.Chrome() as driver:
driver.get("https://demo.playwright.dev/todomvc")
driver.find_element(By.ID, "does-not-exist")
Traceback (most recent call last):
File "first_test.py", line 5, in <module>
driver.find_element(By.ID, "does-not-exist")
selenium.common.exceptions.NoSuchElementException: Message: no such element: Unable to
locate element: {"method":"css selector","selector":"[id="does-not-exist"]"}
(Session info: chrome=133.0.6943.16); For documentation on this error, please visit:
https://www.selenium.dev/documentation/webdriver/troubleshooting/errors#no-such-element-exception
Stacktrace:
GetHandleVerifier [0x00007FF6...]
Read it as “not there yet” rather than “not there”. Nine times out of ten the selector is right and the page had not finished rendering.
ElementNotInteractableException — found, but not usable:
driver.find_element(By.CSS_SELECTOR, ".todo-list li .destroy").click()
selenium.common.exceptions.ElementNotInteractableException: Message: element not
interactable
(Session info: chrome=133.0.6943.16)
TodoMVC’s delete button is hidden until you hover the row. The element exists in the DOM and cannot be clicked, which is a different problem from not existing.
StaleElementReferenceException — found, then the page replaced it:
with webdriver.Chrome() as driver:
driver.get("https://demo.playwright.dev/todomvc")
box = driver.find_element(By.CLASS_NAME, "new-todo")
box.send_keys("buy milk", Keys.ENTER)
item = driver.find_element(By.CSS_SELECTOR, ".todo-list li")
box.send_keys("walk the dog", Keys.ENTER) # React re-renders the list
print(item.text)
selenium.common.exceptions.StaleElementReferenceException: Message: stale element
reference: stale element not found in the current frame
(Session info: chrome=133.0.6943.16)
item is a handle to a specific DOM node. React discarded that node when it re-rendered, so
the handle points at nothing. This one has no equivalent in Playwright, where a locator is
re-resolved on every use — lesson 7 covers living with it.
Browser options
from selenium.webdriver.chrome.options import Options
options = Options()
options.add_argument("--headless=new")
options.add_argument("--window-size=1280,900")
options.add_argument("--lang=en-GB")
options.set_capability("pageLoadStrategy", "eager")
with webdriver.Chrome(options=options) as driver:
driver.get("https://demo.playwright.dev/todomvc")
print("headless run, title:", driver.title)
print("window:", driver.get_window_size())
headless run, title: React • TodoMVC
window: {'width': 1280, 'height': 900}
--headless=new is the modern Chrome headless mode; the old --headless behaved differently
enough to cause its own bugs. pageLoadStrategy is worth knowing:
| Strategy | get() returns when |
|---|---|
normal (default) | the load event fires — all subresources done |
eager | DOMContentLoaded — HTML parsed, images may still load |
none | as soon as the initial HTML response arrives |
eager often takes seconds off a test suite on image-heavy pages, at no cost to correctness
if you are waiting for elements properly.
Firefox and Edge are the same shape:
from selenium.webdriver.firefox.options import Options as FirefoxOptions
options = FirefoxOptions()
options.add_argument("-headless")
with webdriver.Firefox(options=options) as driver:
driver.get("https://demo.playwright.dev/todomvc")
print(driver.title)
React • TodoMVC
As a test
# test_todos.py
import pytest
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
@pytest.fixture
def driver():
options = webdriver.ChromeOptions()
options.add_argument("--headless=new")
drv = webdriver.Chrome(options=options)
drv.implicitly_wait(5)
yield drv
drv.quit()
def test_page_starts_empty(driver):
driver.get("https://demo.playwright.dev/todomvc")
assert driver.find_element(By.CSS_SELECTOR, "h1").text == "todos"
assert driver.find_elements(By.CSS_SELECTOR, ".todo-list li") == []
def test_adds_a_todo(driver):
driver.get("https://demo.playwright.dev/todomvc")
driver.find_element(By.CLASS_NAME, "new-todo").send_keys("buy milk", Keys.ENTER)
items = driver.find_elements(By.CSS_SELECTOR, ".todo-list li")
assert len(items) == 1
assert items[0].text == "buy milk"
pytest test_todos.py -v
========================= test session starts =========================
platform linux -- Python 3.11.9, pytest-8.3.4, pluggy-1.5.0
collected 2 items
test_todos.py::test_page_starts_empty PASSED [ 50%]
test_todos.py::test_adds_a_todo PASSED [100%]
========================== 2 passed in 4.82s ==========================
The fixture is the important part: yield gives the driver to the test, and drv.quit() runs
afterwards whether or not the test passed. A failing test that leaks a browser is how CI
runners run out of memory overnight.
A fresh driver per test also means a fresh profile — no cookies or localStorage carried between tests. That isolation is worth the two seconds of startup; lesson 8 covers making it cheaper.
Where the time goes
2 passed in 4.82s
Nearly all of that is browser startup, not the test. Selenium is slower per test than a unit-test framework by an order of magnitude, which shapes how you use it: cover the critical journeys end to end, and push everything else down to faster layers.
Practice
1. Add two todos and assert the counter.
def test_counter_tracks_items(driver):
driver.get("https://demo.playwright.dev/todomvc")
box = driver.find_element(By.CLASS_NAME, "new-todo")
for item in ("buy milk", "walk the dog"):
box.send_keys(item, Keys.ENTER)
assert driver.find_element(By.CLASS_NAME, "todo-count").text == "2 items left"
test_todos.py::test_counter_tracks_items PASSED [100%]
========================== 1 passed in 3.41s ==========================
The same box handle is reused across both entries — the input is not re-rendered, so it
does not go stale. The list items would.
2. Look for an element that does not exist, with find_element then find_elements.
print(driver.find_elements(By.ID, "nope"))
driver.find_element(By.ID, "nope")
[]
selenium.common.exceptions.NoSuchElementException: Message: no such element: Unable to
locate element: {"method":"css selector","selector":"[id="nope"]"}
The plural form is how you assert absence — assert driver.find_elements(...) == []. Using
the singular inside a try/except NoSuchElementException works but reads badly and hides
real failures.
3. Remove driver.quit() and check for leftover processes.
pgrep -c chromedriver
4
Four orphaned drivers from four runs, each holding a Chrome process. On a CI runner this exhausts memory within an hour — which is why the fixture teardown is not optional.
4. Compare pageLoadStrategy normal and eager.
import time
for strategy in ("normal", "eager"):
opts = webdriver.ChromeOptions()
opts.add_argument("--headless=new")
opts.set_capability("pageLoadStrategy", strategy)
with webdriver.Chrome(options=opts) as d:
t0 = time.perf_counter()
d.get("https://demo.playwright.dev/todomvc")
print(f"{strategy:<7} {time.perf_counter() - t0:.2f}s")
normal 1.42s
eager 0.88s
Half a second per navigation, which on a 200-test suite is nearly two minutes. Safe as long
as you wait for elements rather than relying on get() to mean “ready”.
Next: locators — the eight strategies, and which two you should actually use.