Selenium automation testing means writing code that drives a real browser the way a user would: open a page, type, click and check what appears. The idea has not changed since Selenium began. The machinery has. Selenium RC, the centre of older guides, is retired. Drivers now download themselves. And tests can listen to what happens inside the browser while they run.
This guide shows how Selenium works today using one small Python project that we ran on 29 October 2026 with Selenium 4.51.0, in Chrome 156 and Firefox 159. Every result quoted here comes from that run.
How Selenium automation works, in four parts
A Selenium test is a normal program. Your code calls the Selenium library, the library sends commands to a browser driver, and the driver controls the browser through support the browser vendor builds in. Selenium's overview puts it simply: WebDriver "uses browser automation APIs provided by browser vendors to control the browser and run tests. This is as if a real user is operating the browser."
Four parts matter for day-to-day work:
- WebDriver, the API your tests call. It is a W3C standard, so the same commands work across Chrome, Firefox, Edge and Safari.
- Selenium Manager, which finds the browser and the matching driver for you. It ships with Selenium "as of version 4.6", according to the Selenium Manager documentation.
- WebDriver BiDi, a newer channel that lets the browser send events back to your test: console messages, JavaScript errors and network activity.
- Selenium Grid, which runs the same tests on remote browsers so a suite can spread across machines.
Selenium IDE still exists as a Chrome and Firefox extension that records clicks into a test. It is a quick way to learn the commands, but most teams write tests in code for the reasons below: code can be reviewed, versioned and run in a pipeline.
Many teams start the move from manual testing by automating the checks they repeat on every release. Selenium suits that move because it works with the languages your developers already use.
Set up a Selenium project in Python
You need Python, a browser and two packages. We used Selenium 4.51.0 and pytest, run with uv:
uv run --with selenium==4.51.0 --with pytest python -m pytest -v test_selenium_basics.pyWith pip, pip install selenium==4.51.0 pytest followed by python -m pytest -v test_selenium_basics.py does the same job.
There is no driver download step. That is the biggest practical change from older Selenium guides. When the test asks for Chrome, Selenium Manager checks which Chrome is installed and fetches the matching ChromeDriver into a local cache. We asked Selenium Manager directly what it had resolved on our machine:
chrome driver_path: C:\Users\hello\.cache\selenium\chromedriver\win64\156.0.8078.73\chromedriver.exe
firefox driver_path: C:\Users\hello\.cache\selenium\geckodriver\win64\0.37.1\geckodriver.exe
selenium-manager 0.4.51That matters more than it looks. Chrome updates itself, and a driver you downloaded by hand stops working when the browser moves on. Selenium's own documentation describes the failure: "session not created: This version of ChromeDriver only supports Chrome version 113".
The fixture: one fresh browser per test
Every test gets its own browser, in Chrome and then in Firefox, and closes it afterwards. This fixture also switches on BiDi with enable_bidi, which we use later:
"""Shared fixtures: one fresh browser per test, Chrome or Firefox, local or Grid (GRID_URL env var).
Selenium Manager resolves drivers; no driver downloads by hand."""
import os
import pytest
from selenium import webdriver
def make_options(name: str):
opts = webdriver.ChromeOptions() if name == "chrome" else webdriver.FirefoxOptions()
opts.add_argument("--headless=new" if name == "chrome" else "-headless")
opts.enable_bidi = True
return opts
@pytest.fixture(params=["chrome", "firefox"])
def driver(request):
name = request.param
grid = os.getenv("GRID_URL")
if grid:
d = webdriver.Remote(command_executor=grid, options=make_options(name))
elif name == "chrome":
d = webdriver.Chrome(options=make_options(name))
else:
d = webdriver.Firefox(options=make_options(name))
yield d
d.quit()A fresh browser per test costs a few seconds each time, but it means no test can leave cookies or state behind for the next one. In our run, starting Chrome took 1.36 to 1.60 seconds once the driver was cached; the very first Chrome start took 6.02 seconds. Firefox starts took 5.51 to 5.67 seconds.
Running headless in a pipeline
The fixture starts both browsers headless: --headless=new for Chrome and -headless for Firefox. A headless browser renders the page in full but draws no window, which is what a build server without a screen needs. Every run in this guide was headless, so the same commands work unchanged in a CI job.
Keep one habit from desktop testing, though. When a headless test fails, save a screenshot with driver.save_screenshot("failure.png") before the browser closes. Without it you are debugging a page nobody saw.
Write and run your first tests
We tested against the-internet.herokuapp.com, a practice site built for automation. Four tests cover the jobs most suites start with: a login, a page that loads late, and an error the user never sees.
from selenium.common.exceptions import NoSuchElementException
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait
BASE = "https://the-internet.herokuapp.com"
def test_login_shows_secure_area(driver):
driver.get(f"{BASE}/login")
driver.find_element(By.ID, "username").send_keys("tomsmith")
driver.find_element(By.ID, "password").send_keys("SuperSecretPassword!")
driver.find_element(By.CSS_SELECTOR, "button[type='submit']").click()
flash = WebDriverWait(driver, 10).until(EC.visibility_of_element_located((By.ID, "flash")))
assert "You logged into a secure area!" in flash.text
def test_dynamic_text_without_a_wait_fails(driver):
# The text is added to the page about five seconds after Start is clicked.
driver.get(f"{BASE}/dynamic_loading/2")
driver.find_element(By.CSS_SELECTOR, "#start button").click()
try:
driver.find_element(By.CSS_SELECTOR, "#finish h4")
found = True
except NoSuchElementException:
found = False
assert not found, "expected the element to be missing without a wait"
def test_dynamic_text_with_an_explicit_wait(driver):
driver.get(f"{BASE}/dynamic_loading/2")
driver.find_element(By.CSS_SELECTOR, "#start button").click()
text = WebDriverWait(driver, 10).until(
EC.visibility_of_element_located((By.CSS_SELECTOR, "#finish h4"))).text
assert text == "Hello World!"
def test_javascript_errors_are_captured_with_bidi(driver):
errors = []
driver.script.add_javascript_error_handler(errors.append)
driver.get(f"{BASE}/javascript_error")
WebDriverWait(driver, 5).until(lambda d: errors)
assert "Cannot read properties of undefined" in errors[0].text or "undefined" in errors[0].text
print("\nJS error captured:", errors[0].text)All eight runs passed, four tests in each browser, in 89.36 seconds in total.
The login test is the pattern you will repeat most: find elements by a stable locator such as an ID, act on them, then wait for the result before you assert. The wait matters even here, because the message appears after a page load.
Where the time went
Most of the 89.36 seconds was not spent testing. The two explicit-wait tests took 7.68 s and 8.07 s each, because the page itself takes about five seconds to show its text. Each Firefox start took over 5 s, against about 1.5 s for Chrome once its driver was cached. The login checks themselves took a few seconds each.
That pattern is typical of browser tests: start-up and waiting dominate. Keep browser tests for journeys only a browser can check, and push rules and calculations down to faster unit or API tests.
Why the explicit wait is not optional
The second and third tests use the same page, where the text "Hello World!" appears about five seconds after you click Start. The second test looks for it straight away and, by design, finds nothing: Selenium raised NoSuchElementException. The third test waits up to ten seconds for the text to become visible, and passes.
The test without a wait was faster and wrong. In a real suite that becomes a flaky test: it passes on a fast machine and fails on a slow one. Use an explicit wait on the condition you need, such as visibility, rather than a fixed sleep. A sleep long enough for the slow day wastes time on every fast one.
Catching JavaScript errors with BiDi
The fourth test listens for JavaScript errors while the page loads, through WebDriver BiDi. Selenium's BiDi documentation describes BiDi as "the W3C standard bidirectional protocol for browser automation", and in Python you switch it on with options.enable_bidi = True, as our fixture does.
Both browsers reported the page's error. They worded it differently.
That is why our assertion checks for "undefined" rather than one browser's full sentence. An exact-text assertion would pass in Chrome and fail in Firefox, and the failure would say nothing about your product. When a check runs across browsers, assert on what the browsers agree on.
Catching errors like this is useful well beyond one demo page. A test that fails when the page throws an uncaught error finds broken scripts that a user would only notice as a button that does nothing.
Run the same tests on Selenium Grid
The Grid documentation describes Grid as a way to execute WebDriver scripts "on remote machines by routing commands sent by the client to remote browser instances." You do not need a cluster to try it. The standalone mode runs the whole Grid in one Java process on your own machine, which is the quickest way to check that a suite is ready for remote browsers.
We started Grid 4.51.0 on Java 21 and pointed the same tests at it. The only change is the GRID_URL variable our fixture reads; the tests themselves did not change.
java -jar selenium-server-4.51.0.jar standalone --port 4444
GRID_URL=http://localhost:4444 uv run --with selenium==4.51.0 --with pytest python -m pytest -v test_selenium_basics.pyGrid reported "Selenium Grid ready." with Chrome, Firefox and Edge slots on our Windows machine. All eight tests passed again, in 48.38 seconds, including the BiDi error capture in both browsers.
That total was lower than the 89.36 seconds of our local run, mostly because browser start-up and shutdown were quicker in this run. One run each is not a benchmark, so do not read it as Grid being faster. Grid earns its place when it runs tests in parallel across several machines or browser versions, which a single local run cannot show.
A practical check before you move a suite to Grid: make sure nothing in your tests depends on the local machine, such as file paths, local downloads or a browser profile. Our fixture created every browser from options alone, which is why the same code ran in both places.
Choose locators that survive a redesign
Our tests use IDs wherever the page offers them, then short CSS selectors. That follows Selenium's advice on locators: if IDs are "available, unique, and consistently predictable, they are the preferred method", and otherwise "a well-written CSS selector is the preferred method". The same page warns that XPath selectors "tend to be quite slow".
Avoid locators that depend on styling or position, such as a class used for colour or the third button in a row. A designer can change both without touching behaviour, and your tests fail for no product reason. If the page has no stable hooks, ask the developers to add test IDs. It is a small change on their side, and it removes a whole class of repairs on yours.
Move page details into page objects as the suite grows
Once two tests touch the same page, the locators start to repeat. Selenium's documentation on page object models gives the reason to fix this early: "if the UI changes, the fix needs only to be applied in one place."
We moved our login test into a page object and added a wrong-password case. Both tests passed in Chrome and Firefox, 4 passed in 39.00 s:
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait
class LoginPage:
URL = "https://the-internet.herokuapp.com/login"
USERNAME = (By.ID, "username")
PASSWORD = (By.ID, "password")
SUBMIT = (By.CSS_SELECTOR, "button[type='submit']")
FLASH = (By.ID, "flash")
def __init__(self, driver):
self.driver = driver
def open(self):
self.driver.get(self.URL)
return self
def log_in_as(self, username, password):
self.driver.find_element(*self.USERNAME).send_keys(username)
self.driver.find_element(*self.PASSWORD).send_keys(password)
self.driver.find_element(*self.SUBMIT).click()
return WebDriverWait(self.driver, 10).until(EC.visibility_of_element_located(self.FLASH)).text
def test_valid_login(driver):
assert "You logged into a secure area!" in LoginPage(driver).open().log_in_as("tomsmith", "SuperSecretPassword!")
def test_wrong_password(driver):
assert "Your password is invalid!" in LoginPage(driver).open().log_in_as("tomsmith", "wrong")The tests now read as behaviour: log in as this user, expect this message. If the login form changes, only the class changes.
Common Selenium errors and what the message tells you
Most failures in a new Selenium suite come from four errors. We triggered each one on purpose in Chrome 156 and Firefox 159, so you can recognise the real messages. The test file is in the same project, and all eight runs behaved as expected in 88.55 s.
NoSuchElementException: the locator found nothing
We asked for an element with the ID user-name, where the page uses username. Chrome replied "no such element: Unable to locate element" and showed the locator it had actually used: a CSS selector, [id="user-name"]. That detail is useful. Selenium translates By.ID into CSS behind the scenes, so the message shows exactly what was searched for.
Before you add a wait, check the locator. A wait cannot find an element that is not there; it just fails more slowly.
TimeoutException: the condition never came true
On a page where the text exists but stays hidden, we waited 2 s for it to become visible. Both browsers raised TimeoutException, and in both the message was empty. The exception tells you that the wait ran out, not why. Name your waits in the test, for example with a helper that says what it was waiting for, so the failure report explains itself.
StaleElementReferenceException: the page replaced the element
We found a checkbox, clicked the page's Remove button, then clicked the checkbox we had stored. Chrome said "stale element reference: stale element not found in the current frame". Firefox was more specific: the element "is no longer connected to the DOM".
The fix is to find the element again after the page changes, rather than reusing the old reference. This version passed in both browsers:
def test_stale_element_fixed_by_finding_again(driver):
driver.get(f"{BASE}/dynamic_controls")
button = driver.find_element(By.CSS_SELECTOR, "#checkbox-example button")
button.click() # Remove
WebDriverWait(driver, 10).until(EC.text_to_be_present_in_element((By.ID, "message"), "It's gone!"))
driver.find_element(By.CSS_SELECTOR, "#checkbox-example button").click() # Add, found again
box = WebDriverWait(driver, 10).until(EC.element_to_be_clickable((By.ID, "checkbox")))
box.click()
assert box.is_selected()SessionNotCreatedException: the driver and browser disagree
We did not hit this one, because Selenium Manager matched the drivers. It is the classic error of a pinned driver after a browser update, and the Selenium Manager documentation quotes it: "session not created: This version of ChromeDriver only supports Chrome version 113". If you see it, remove the hard-coded driver path and let Selenium Manager choose. Selenium's error troubleshooting page covers the rest, and Firefox's messages link straight to it.
Where Selenium fits, and where it does not
Selenium tests what a user does in a web browser: forms, navigation, sign-in, checkout and the scripts that run on the page. It suits checks you repeat on every release across several browsers, and teams that want tests in the language their developers use.
It is the wrong tool for some jobs. Selenium does not test native mobile apps; Appium, which builds on the WebDriver protocol, does that. It is a slow way to check business rules that an API test can reach without starting a browser. And it is no substitute for a person exploring a new feature.
The ISTQB Foundation Level syllabus (v4.0.1, section 6.2) lists the risks every automation tool carries. Two apply directly to Selenium suites:
- "Using a test tool when manual testing is more appropriate."
- "Using an open-source software which may be abandoned, meaning that no further updates are available, or its internal components may require quite frequent updates as a further development."
The second risk is the driver problem in figure 2. Browser drivers change with every browser release. Selenium Manager now absorbs that churn for you, which is one of the strongest practical reasons to stay on a current Selenium 4 release.
Selenium components at a glance
Where Selenium stands today
Selenium is not getting outdated. It is still released and still changing: we used version 4.51.0, BiDi support is being added across the project, and Selenium Manager removed the most common setup failure. Its official bindings cover Java, Python, C#, Ruby and JavaScript, so most teams can write tests in a language they already use.
Newer tools such as Playwright bundle their own browsers and auto-waiting, and for some teams that is the better fit. We compare the three main options in Selenium vs Playwright vs Cypress. Selenium's strengths are the W3C standard behind it, the widest language support and Grid for running at scale.
It is not hard to start. The project above is 71 lines, blank lines included, and the hardest lessons, waiting for conditions and asserting on what matters, apply to every automation tool.
Start small, then scale
A good first Selenium suite is small: a fresh browser per test, explicit waits, locators that do not change with styling, and assertions that hold in every browser you test. Add BiDi checks for errors early, because they catch failures no assertion was written for. Move to Grid once the suite is too slow on one machine.
If you want a team to build or rescue a Selenium suite for your product, QAble's engineers use Selenium for the automation testing process.