Selenium WebDriver
Selenium WebDriver drives a real browser the way a user would: it opens pages, types into fields, clicks buttons and reads what appears. It has been the standard tool for browser automation for many years, it supports Chrome, Edge, Firefox and Safari, and it has official bindings for Python, Java, JavaScript, C# and Ruby. This lesson uses Selenium 4.52 with Python.
In this lesson you will install Selenium, write a login test against a small page, choose good locators, use explicit waits instead of sleep, and organise browser tests with pytest.
How WebDriver works
Your test code does not control the browser directly. It sends commands through the WebDriver protocol, a W3C standard (WebDriver became a W3C Recommendation in June 2018), to a driver program such as chromedriver or geckodriver, which controls the browser:
Your Python test --(WebDriver protocol)--> chromedriver --> ChromeSince Selenium 4.6, a tool called Selenium Manager comes inside Selenium. When no driver is found, it detects your browser version and downloads a matching driver automatically, so you no longer download drivers by hand. Newer releases can even download a browser if none is installed.
Install and the first script
Create a virtual environment for your project and install Selenium and pytest:
python -m pip install selenium pytestThe examples test a small login page for a shop called Kirana Kart. After a correct login it shows "Hello, Priya"; after a wrong password it shows "Invalid email or password". Both answers appear about 0.8 seconds after the click, as they would on a real site waiting for its server. Here is the core of a Selenium script:
from selenium import webdriver
from selenium.webdriver.common.by import By
driver = webdriver.Chrome() # Selenium Manager finds or downloads chromedriver
driver.get("http://localhost:8000/login.html")
driver.find_element(By.ID, "email").send_keys("priya@example.com")
driver.find_element(By.ID, "password").send_keys("Kart@2026")
driver.find_element(By.CSS_SELECTOR, "button[type='submit']").click()
print(driver.title)
driver.quit() # always close the browserThe main commands are get() to open a URL, find_element() to locate one element (it raises NoSuchElementException if none is found), find_elements() to get a list, send_keys() to type, click(), .text to read visible text, and quit() to close the browser and the driver.
Locators
A locator tells Selenium how to find an element. The By class offers:
| Locator | Example | Notes |
|---|---|---|
By.ID | (By.ID, "email") | Fast and stable when IDs are unique |
By.NAME | (By.NAME, "password") | Good for form fields |
By.CSS_SELECTOR | (By.CSS_SELECTOR, "button[type='submit']") | Flexible; the most common choice |
By.XPATH | (By.XPATH, "//button[text()='Log in']") | Powerful, can match text; easy to make fragile |
By.LINK_TEXT | (By.LINK_TEXT, "Forgot password?") | For links |
By.CLASS_NAME, By.TAG_NAME | (By.TAG_NAME, "h1") | Often match many elements |
Prefer locators that describe meaning and survive design changes: IDs, names, labels or a dedicated test attribute such as data-testid="cart-count" that developers add for testers. Avoid long absolute paths such as /html/body/div[2]/div/form/div[3]/button; one new div breaks them.
Waits: the number one source of flaky tests
Web pages change after they load: data arrives from servers, messages appear after a delay. If your test reads the message at once, it finds an empty paragraph. Beginners fix this with time.sleep(5), which makes every test slow and still fails when the server takes six seconds.
Selenium offers two kinds of wait:
- Implicit wait (
driver.implicitly_wait(5)) tells everyfind_elementto keep looking for up to 5 seconds. Its default is 0. It only waits for elements to exist, not to be visible or to contain text. - Explicit wait (
WebDriverWait) waits for a specific condition, such as "this element is visible" or "this text is present", checking again and again until it is true or a timeout expires. Selenium's PythonWebDriverWaitchecks every 0.5 seconds by default.
The Selenium documentation advises against mixing implicit and explicit waits, because the timeouts can combine in surprising ways. Most teams use explicit waits only. This runnable simulation shows exactly what an explicit wait does, using a fake clock so it runs instantly:
class FakeClock:
def __init__(self):
self.now = 0.0
def sleep(self, seconds):
self.now += seconds
class FakePage:
"""The error message appears 0.8 s after the Log in click."""
def __init__(self, clock):
self.clock = clock
def message_text(self):
return "Invalid email or password" if self.clock.now >= 0.8 else ""
def wait_until(condition, clock, timeout=5.0, poll=0.5):
"""Check the condition every `poll` seconds until it is true or time runs out."""
waited = 0.0
while True:
value = condition()
if value:
return value, waited
if waited >= timeout:
raise TimeoutError(f"condition not met after {timeout} s")
clock.sleep(poll)
waited += poll
clock = FakeClock()
page = FakePage(clock)
print("Checked at once:", repr(page.message_text()))
text, waited = wait_until(page.message_text, clock)
print(f"Explicit wait: {text!r} after {waited} s")
clock2 = FakeClock()
try:
wait_until(lambda: False, clock2, timeout=2.0)
except TimeoutError as error:
print("Never appeared:", error)Checked at once: '' Explicit wait: 'Invalid email or password' after 1.0 s Never appeared: condition not met after 2.0 s
The wait returns as soon as the message appears (at the second check, 1.0 s), instead of always sleeping 5 seconds, and it fails with a clear timeout when the message never comes.
A pytest suite with Selenium
A pytest fixture starts the browser before each test and quits it afterwards, even when the test fails. expected_conditions (imported as EC) provides ready-made conditions for WebDriverWait:
# test_login_selenium.py
import pytest
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait
LOGIN_URL = "http://localhost:8000/login.html"
@pytest.fixture
def driver():
options = webdriver.ChromeOptions()
options.add_argument("--headless=new") # no visible window, good for CI
driver = webdriver.Chrome(options=options)
yield driver
driver.quit()
def log_in(driver, email, password):
driver.get(LOGIN_URL)
driver.find_element(By.ID, "email").send_keys(email)
driver.find_element(By.ID, "password").send_keys(password)
driver.find_element(By.CSS_SELECTOR, "button[type='submit']").click()
def test_valid_login_opens_dashboard(driver):
log_in(driver, "priya@example.com", "Kart@2026")
wait = WebDriverWait(driver, timeout=5)
wait.until(EC.text_to_be_present_in_element((By.TAG_NAME, "h1"), "Hello, Priya"))
assert driver.title == "Kirana Kart - Dashboard"
def test_wrong_password_shows_error(driver):
log_in(driver, "priya@example.com", "wrong-pass")
message = WebDriverWait(driver, timeout=5).until(
EC.visibility_of_element_located((By.ID, "message"))
)
assert message.text == "Invalid email or password"Run it with pytest -v:
test_login_selenium.py::test_valid_login_opens_dashboard PASSED [ 50%] test_login_selenium.py::test_wrong_password_shows_error PASSED [100%]
Starting a real browser takes a few seconds per test, so UI suites are much slower than unit tests. This is why you test calculations with unit tests and keep browser tests for real user journeys.
While checking this example, a version of the error test without the explicit wait also passed on a slow laptop, because the browser happened to take longer than 0.8 seconds between the click and the check. On a faster machine or CI server the same test fails. A test that passes or fails without any code change is called flaky, and missing waits are the most common cause.
Common mistakes
time.sleep()everywhere. Slow and still unreliable; wait for a condition instead.- Fragile XPath copied from the browser. Use IDs, names, labels or
data-testidattributes. - Forgetting
driver.quit(). Leftover browsers eat memory; a fixture withyieldguarantees clean-up. - Testing everything through the UI. Slow suites get ignored. Keep UI tests for key journeys.
Key points
- Selenium WebDriver controls real browsers through the W3C WebDriver protocol and a browser driver.
- Selenium Manager (since 4.6) finds or downloads the matching driver for you.
- Choose stable locators: IDs, names, labels and
data-testidattributes over long XPaths. - Use explicit waits (
WebDriverWaitwithexpected_conditions) instead ofsleep; do not mix them with implicit waits. - Start and quit the browser in a pytest fixture, and run headless in CI.
Exercise
The dashboard after login contains <p data-testid="cart-count">Cart: 0 items</p>. Write a test test_new_user_cart_is_empty(driver) that logs in as Priya, waits until this element is visible using a CSS selector for the data-testid attribute, and asserts its text.
Show solution
def test_new_user_cart_is_empty(driver):
log_in(driver, "priya@example.com", "Kart@2026")
cart = WebDriverWait(driver, timeout=5).until(
EC.visibility_of_element_located((By.CSS_SELECTOR, "[data-testid='cart-count']"))
)
assert cart.text == "Cart: 0 items"The selector [data-testid='cart-count'] matches the attribute exactly. It keeps working if designers change the element's class names, colours or position, which is why teams agree on test IDs with their developers.
Quick Quiz
1. What does Selenium Manager do?
Since Selenium 4.6 it detects the browser version and fetches the right driver.
2. Which wait is recommended for a message that appears after a server reply?
Explicit waits poll for a condition and continue as soon as it is true.
3. Which locator is most likely to survive a page redesign?
A dedicated test attribute does not depend on layout or styling.
4. A test passes on one run and fails on the next with no code change. What is it called?
Flaky tests are unreliable, often because of missing waits or shared state.
Learn Software Testing with a mentor
Free lessons get you started. Our Software Testing training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
