Skip to main content
Selenium beginner Lesson 1 of 10

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:

Strategyget() returns when
normal (default)the load event fires — all subresources done
eagerDOMContentLoaded — HTML parsed, images may still load
noneas 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.

Frequently Asked Questions

Do I still need to download ChromeDriver manually?
No. Since Selenium 4.6 the built-in Selenium Manager downloads and matches the right driver for the browser you have, so `webdriver.Chrome()` just works. The old `webdriver_manager` package and manual PATH setup are no longer needed.
What is the difference between Selenium and Playwright?
Selenium drives browsers through the W3C WebDriver protocol, which every major browser vendor implements — that is its portability and its language spread. Playwright talks to browsers over their own debug protocols, which is what lets it auto-wait and intercept network traffic. Selenium makes waiting your responsibility.
Why does my Selenium script leave browser processes running?
Because `driver.quit()` never ran — usually an exception was raised first. Use a `try/finally`, a context manager, or a pytest fixture with teardown, or every failing test leaves a browser and a driver process behind.
What is the difference between driver.close() and driver.quit()?
`close()` closes the current window and leaves the session alive; `quit()` ends the session and shuts down the driver process. Use `quit()` at the end of a test — `close()` on the last window leaves an orphaned driver.