selenium-expect
Playwright-style expect() assertions with auto-retry for Selenium Python. Standalone, no framework required.
Features
- Auto-retry — no explicit
WebDriverWaitneeded; assertions poll until timeout - Fluent API —
expect(el).to_be_visible().not_.to_be_disabled() - Negation — every assertion supports
.not_out of the box - Soft assertions — accumulate failures, assert all at once
- Custom matchers — extend
expect()with your own assertions via@extend - Locator re-find —
expect(driver, by=By.ID, value="x")re-finds on each poll - Polling — fixed interval or backoff schedule
- 150+ assertions — elements, lists, cookies, alerts, JS state, shadow DOM, select, iframe, window
- Descriptive errors — timeline, HTML snippet, poll count, custom messages
- Zero dependencies — only requires Selenium 4.10+
- Fully typed —
py.typedincluded, strict mypy clean
Why?
Selenium's WebDriverWait + expected_conditions is verbose and inconsistent. Playwright's expect() is ergonomic and auto-retries. selenium-expect brings that experience to Selenium Python:
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium_expect import expect
driver = webdriver.Chrome()
# Auto-retry — no explicit WebDriverWait needed
expect(driver).to_have_title("Dashboard")
expect(driver).to_have_url_contains("/dashboard")
button = driver.find_element(By.ID, "submit")
expect(button).to_be_visible()
expect(button).to_be_enabled()
expect(button).to_have_text("Submit")
# Negation
expect(button).not_.to_be_disabled()
# Lists
items = driver.find_elements(By.CSS_SELECTOR, ".item")
expect(items).to_have_count(5)
expect(items).to_have_texts(["Apple", "Banana", "Cherry", "Date", "Elderberry"])
# Locator-based (re-finds on each poll — no StaleElementReferenceException)
expect(driver, by=By.ID, value="dynamic-content").to_have_text("Loaded!")
Installation
pip install selenium-expect
Requires:
- Python >= 3.11
- selenium >= 4.10
Quickstart
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium_expect import expect, set_default_timeout
# Optional: configure global defaults
set_default_timeout(10) # 10s timeout for all assertions
driver = webdriver.Chrome()
driver.get("https://example.com")
# Page-level assertions
expect(driver).to_have_title("Example Domain")
expect(driver).to_have_url("https://example.com/")
expect(driver).to_have_page_source_contains("This domain is for use in illustrative examples")
# Element assertions
heading = driver.find_element(By.TAG_NAME, "h1")
expect(heading).to_have_text("Example Domain")
expect(heading).to_have_tag("h1")
expect(heading).to_be_visible()
# Attribute assertions
link = driver.find_element(By.TAG_NAME, "a")
expect(link).to_have_attribute("href", "https://www.iana.org/domains/example")
expect(link).to_have_attribute_contains("href", "iana.org")
# CSS property assertions
expect(heading).to_have_css_property("color", "rgba(0, 0, 0, 1)")
# Negation
expect(link).not_.to_have_text("Click here")
driver.quit()
Configuration
Global defaults
from selenium_expect import (
set_default_timeout,
set_default_polling_interval,
set_default_polling_intervals,
set_screenshot_on_failure,
set_debug_mode,
)
set_default_timeout(10) # 10s timeout (default: 5s)
set_default_polling_interval(0.2) # 200ms between polls (default: 500ms)
set_default_polling_intervals([0.1, 0.2, 0.5, 1.0]) # Backoff schedule
set_screenshot_on_failure(True, "/tmp/screenshots")
set_debug_mode(True) # Log each poll attempt
Per-assertion override
# Override timeout for a single assertion
expect(element).to_be_visible(timeout=15)
# Override polling interval
expect(element).to_have_text("Ready", polling=0.1)
# Backoff schedule
expect(element).to_be_clickable(polling=[0.05, 0.1, 0.5, 1.0])
# Custom error message
expect(element).to_have_text("Hello", message="Greeting should say Hello")
# Soft assertion (accumulate failures, check later)
expect(element, soft=True).to_be_visible()
expect(element, soft=True).to_have_text("Hello")
assert_all() # Raises if any soft assertions failed
Pre-configured expect
from selenium_expect import expect
# Create a variant with pre-applied config
slow_expect = expect.configure(timeout=30, polling=1.0)
slow_expect(driver).to_have_title("Slow Page")
Assertions reference
Driver / Page
| Assertion | Selenium API |
|---|---|
to_have_title(text) |
driver.title |
to_have_title_contains(text) |
text in driver.title |
to_have_title_matches(pattern) |
re.search(pattern, driver.title) |
to_have_url(url) |
driver.current_url |
to_have_url_contains(url) |
url in driver.current_url |
to_have_url_matches(pattern) |
re.search(pattern, driver.current_url) |
to_have_url_changes(url) |
driver.current_url != url |
to_have_ready_state(state) |
driver.execute_script('return document.readyState') |
to_have_window_count(n) |
len(driver.window_handles) |
to_have_window_count_greater_than(n) |
len(driver.window_handles) > n |
to_have_window_count_less_than(n) |
len(driver.window_handles) < n |
to_have_window_handle(handle) |
driver.current_window_handle |
to_have_browser_name(name) |
driver.name |
to_have_orientation(orientation) |
driver.orientation |
to_have_capability(key, value) |
driver.capabilities[key] |
to_have_capability_contains(key, value) |
value in str(driver.capabilities[key]) |
to_have_page_source_contains(text) |
text in driver.page_source |
to_have_page_source_matches(pattern) |
re.search(pattern, driver.page_source) |
to_have_page_source_not_contains(text) |
text not in driver.page_source |
to_have_window_position(x, y) |
driver.get_window_position() |
to_have_window_size(w, h) |
driver.get_window_size() |
to_have_window_rect(x, y, w, h) |
driver.get_window_rect() |
to_have_active_element_tag(tag) |
driver.switch_to.active_element.tag_name |
to_have_active_element_id(id) |
driver.switch_to.active_element.get_attribute('id') |
to_have_active_element_class(class_name) |
driver.switch_to.active_element.get_attribute('class') |
to_have_new_window_opened(previous_handles) |
new handle not in previous_handles |
Element — State
| Assertion | Selenium API |
|---|---|
to_be_visible() |
element.is_displayed() |
to_be_hidden() |
not element.is_displayed() |
to_be_enabled() |
element.is_enabled() |
to_be_disabled() |
not element.is_enabled() |
to_be_checked() |
element.is_selected() |
to_be_selected() |
element.is_selected() |
to_be_present() |
element.tag_name (no exception) |
to_be_absent() |
StaleElementReferenceException / NoSuchElementException |
to_be_clickable() |
is_displayed() and is_enabled() |
to_be_stale() |
StaleElementReferenceException on access |
to_be_unselected() |
not element.is_selected() |
to_be_unchecked() |
not element.is_selected() |
to_be_focused() |
element == driver.switch_to.active_element |
to_be_editable() |
element.is_enabled() and not element.get_attribute('readonly') |
to_be_readonly() |
element.get_attribute('readonly') is not None |
to_be_empty() |
element.text.strip() == '' |
Element — Text
| Assertion | Selenium API |
|---|---|
to_have_text(text) |
element.text |
to_have_text_contains(text) |
text in element.text |
to_have_text_matches(pattern) |
re.search(pattern, element.text) |
to_have_text_empty() |
element.text == '' |
to_have_text_not_empty() |
element.text != '' |
to_have_text_starting_with(prefix) |
element.text.startswith(prefix) |
to_have_text_ending_with(suffix) |
element.text.endswith(suffix) |
to_have_text_in_list(texts) |
element.text in texts |
to_have_value(value) |
element.get_attribute('value') |
to_have_value_contains(value) |
value in element.get_attribute('value') |
to_have_value_matches(pattern) |
re.search(pattern, element.get_attribute('value')) |
to_have_value_in_list(values) |
element.get_attribute('value') in values |
Element — Attributes
| Assertion | Selenium API |
|---|---|
to_have_attribute(name, value) |
element.get_attribute(name) |
to_have_attribute_contains(name, value) |
value in element.get_attribute(name) |
to_have_attribute_matches(name, pattern) |
re.search(pattern, element.get_attribute(name)) |
to_have_attribute_empty(name) |
element.get_attribute(name) in ('', None) |
to_have_attribute_present(name) |
element.get_attribute(name) is not None |
to_have_attribute_absent(name) |
element.get_attribute(name) is None |
to_have_attribute_in_list(name, values) |
element.get_attribute(name) in values |
to_have_dom_attribute(name, value) |
element.get_dom_attribute(name) |
to_have_dom_attribute_contains(name, value) |
value in element.get_dom_attribute(name) |
to_have_property(name, value) |
element.get_property(name) |
to_have_property_contains(name, value) |
value in str(element.get_property(name)) |
Element — CSS
| Assertion | Selenium API |
|---|---|
to_have_css_property(name, value) |
element.value_of_css_property(name) |
to_have_css_property_contains(name, value) |
value in element.value_of_css_property(name) |
to_have_css_property_matches(name, pattern) |
re.search(pattern, element.value_of_css_property(name)) |
Element — Identity
| Assertion | Selenium API |
|---|---|
to_have_tag(tag) |
element.tag_name |
to_have_id(id) |
element.get_attribute('id') |
to_have_class(class_name) |
class_name in element.get_attribute('class').split() |
to_have_class_contains(class_name) |
class_name in element.get_attribute('class') |
to_contain_class(class_name) |
class_name in element.get_attribute('class') |
to_have_class_matching(pattern) |
re.search(pattern, class) for class in classes |
to_have_all_classes(classes) |
set(classes).issubset(elem_classes) |
to_have_class_in_list(classes) |
any(class in elem_classes for class in classes) |
Element — Position / Dimensions
| Assertion | Selenium API |
|---|---|
to_have_location(x, y) |
element.location |
to_have_location_x(x) |
element.location['x'] |
to_have_location_y(y) |
element.location['y'] |
to_have_size(width, height) |
element.size |
to_have_size_width(width) |
element.size['width'] |
to_have_size_height(height) |
element.size['height'] |
to_have_rect(x, y, w, h) |
element.rect |
to_have_location_greater_than(x, y) |
element.location > (x, y) |
to_have_location_less_than(x, y) |
element.location < (x, y) |
to_have_size_greater_than(w, h) |
element.size > (w, h) |
to_have_size_less_than(w, h) |
element.size < (w, h) |
to_have_location_once_scrolled_into_view(x, y) |
element.location after scroll |
Element — Accessibility (Selenium 4+)
| Assertion | Selenium API |
|---|---|
to_have_aria_role(role) |
element.aria_role |
to_have_aria_role_contains(role) |
role in element.aria_role |
to_have_aria_role_in_list(roles) |
element.aria_role in roles |
to_have_accessible_name(name) |
element.accessible_name |
to_have_accessible_name_contains(name) |
name in element.accessible_name |
to_have_js_property(name, value) |
element.get_property(name) via JS |
to_have_shadow_root() |
element.shadow_root is not None |
to_have_shadow_root_absent() |
element.shadow_root is None |
List
| Assertion | Selenium API |
|---|---|
to_have_count(n) |
len(elements) |
to_have_count_greater_than(n) |
len(elements) > n |
to_have_count_less_than(n) |
len(elements) < n |
to_have_count_greater_than_or_equal(n) |
len(elements) >= n |
to_have_count_less_than_or_equal(n) |
len(elements) <= n |
to_be_empty() |
len(elements) == 0 |
to_be_not_empty() |
len(elements) > 0 |
to_have_texts(texts) |
[el.text for el in elements] |
to_have_texts_contains(texts) |
substring per element |
to_have_exact_texts(texts) |
exact text per element |
to_have_texts_containing(texts) |
each text contains substring |
to_have_texts_in_any_order(texts) |
same texts, any order |
to_have_text_at(index, text) |
elements[index].text |
to_have_first_text(text) |
elements[0].text |
to_have_last_text(text) |
elements[-1].text |
to_have_nth_text_contains(index, text) |
text in elements[index].text |
to_have_any_text(text) |
any element has text |
to_have_all_texts_contain(text) |
all elements contain text |
to_have_any_text_contain(text) |
any element contains text |
to_have_none_text_contain(text) |
no element contains text |
to_have_values(values) |
[el.get_attribute('value') for el in elements] |
to_have_value_at(index, value) |
elements[index].get_attribute('value') |
to_have_all_visible() |
all is_displayed() |
to_have_any_visible() |
any is_displayed() |
to_have_none_visible() |
none is_displayed() |
to_have_all_enabled() |
all is_enabled() |
to_have_all_selected() |
all is_selected() |
to_have_attribute_at(index, name, value) |
elements[index].get_attribute(name) |
to_have_all_attribute(name, value) |
all elements have attribute |
to_have_any_attribute(name, value) |
any element has attribute |
Alert
| Assertion | Selenium API |
|---|---|
to_be_present() |
driver.switch_to.alert (no exception) |
to_have_text(text) |
alert.text |
to_have_text_contains(text) |
text in alert.text |
to_have_text_matches(pattern) |
re.search(pattern, alert.text) |
Cookie
| Assertion | Selenium API |
|---|---|
to_have_cookie(name) |
driver.get_cookie(name) is not None |
to_have_cookie_value(name, value) |
driver.get_cookie(name)['value'] |
to_have_cookie_value_contains(name, value) |
value in driver.get_cookie(name)['value'] |
to_have_cookie_domain(name, domain) |
driver.get_cookie(name)['domain'] |
to_have_cookie_path(name, path) |
driver.get_cookie(name)['path'] |
to_have_cookie_http_only(name) |
driver.get_cookie(name)['httpOnly'] |
to_have_cookie_secure(name) |
driver.get_cookie(name)['secure'] |
to_have_cookie_same_site(name, same_site) |
driver.get_cookie(name)['sameSite'] |
to_have_cookie_count(n) |
len(driver.get_cookies()) |
to_have_no_cookies() |
len(driver.get_cookies()) == 0 |
to_have_cookie_count_greater_than(n) |
len(driver.get_cookies()) > n |
to_have_cookie_expiry(name, expiry) |
driver.get_cookie(name)['expiry'] |
JavaScript / Browser state
| Assertion | Selenium API |
|---|---|
to_have_js_result(script, expected) |
driver.execute_script(script) |
to_have_js_result_contains(script, expected) |
expected in driver.execute_script(script) |
to_have_js_result_matches(script, pattern) |
re.search(pattern, str(driver.execute_script(script))) |
to_have_async_js_result(script, expected) |
driver.execute_async_script(script) |
to_have_js_variable(name, value) |
window[name] via JS |
to_have_local_storage_item(key, value) |
localStorage.getItem(key) via JS |
to_have_local_storage_item_present(key) |
localStorage.getItem(key) is not None |
to_have_local_storage_item_absent(key) |
localStorage.getItem(key) is None |
to_have_local_storage_length(n) |
localStorage.length via JS |
to_have_session_storage_item(key, value) |
sessionStorage.getItem(key) via JS |
to_have_session_storage_item_present(key) |
sessionStorage.getItem(key) is not None |
to_have_session_storage_item_absent(key) |
sessionStorage.getItem(key) is None |
to_have_session_storage_length(n) |
sessionStorage.length via JS |
Shadow DOM
| Assertion | Selenium API |
|---|---|
to_have_element(by, value) |
shadow_root.find_element(by, value) |
to_have_element_count(by, value, n) |
len(shadow_root.find_elements(by, value)) |
to_have_element_text(by, value, text) |
shadow_root.find_element(by, value).text |
to_have_element_attribute(by, value, attr, value) |
.find_element().get_attribute(attr) |
to_have_element_visible(by, value) |
shadow_root.find_element(by, value).is_displayed() |
Select / Dropdown
| Assertion | Selenium API |
|---|---|
to_have_value(value) |
select.first_selected_option.get_attribute('value') |
to_have_first_selected_value(value) |
select.first_selected_option.get_attribute('value') |
to_have_selected_text(text) |
select.first_selected_option.text |
to_have_selected_values(values) |
[opt.get_attribute('value') for opt in select.all_selected_options] |
to_have_selected_texts(texts) |
[opt.text for opt in select.all_selected_options] |
to_have_selected_count(n) |
len(select.all_selected_options) |
to_have_option_count(n) |
len(select.options) |
to_have_option_count_greater_than(n) |
len(select.options) > n |
to_have_option_at_index(index, text) |
select.options[index].text |
to_have_option(value) |
value exists in options |
to_have_option_text(text) |
text exists in options |
to_be_multiple() |
select.is_multiple |
to_be_single_select() |
not select.is_multiple |
to_have_selected_index(index) |
select.options[index].is_selected() |
to_have_no_selection() |
len(select.all_selected_options) == 0 |
Iframe
| Assertion | Selenium API |
|---|---|
to_have_frame_available(frame_id) |
driver.switch_to.frame(frame_id) |
to_have_frame_count(n) |
len(driver.find_elements(By.TAG_NAME, 'iframe')) |
to_have_frame_count_greater_than(n) |
len(driver.find_elements(By.TAG_NAME, 'iframe')) > n |
to_have_frame_text(frame_id, text) |
switch to frame, check page_source |
to_be_in_frame(frame_id) |
driver.switch_to.frame(frame_id) succeeds |
to_be_in_default_content() |
driver.switch_to.default_content() succeeds |
Window
| Assertion | Selenium API |
|---|---|
to_have_position(x, y) |
driver.get_window_position() |
to_have_size(w, h) |
driver.get_window_size() |
to_have_rect(x, y, w, h) |
driver.get_window_rect() |
Advanced features
Soft assertions
Accumulate failures and check them all at once:
from selenium_expect import expect, assert_all
form = driver.find_element(By.ID, "form")
# These won't raise immediately
expect(form, soft=True).to_be_visible()
expect(form, soft=True).to_have_attribute("method", "POST")
expect(form, soft=True).to_have_attribute("action", "/submit")
# Raises AssertionError with all failures if any failed
assert_all()
Custom matchers
Extend expect() with your own assertions:
from selenium_expect import expect, extend
@extend("to_be_in_viewport")
def check_in_viewport(element):
script = """
var r = arguments[0].getBoundingClientRect();
return r.top >= 0 && r.left >= 0 &&
r.bottom <= window.innerHeight &&
r.right <= window.innerWidth;
"""
result = element.parent.execute_script(script, element)
return result, result
# Use it like any built-in assertion
expect(element).to_be_in_viewport()
expect(element).not_.to_be_in_viewport()
Locator-based expect
Re-finds the element on each poll cycle — eliminates StaleElementReferenceException:
# Instead of:
# element = driver.find_element(By.ID, "dynamic")
# expect(element).to_have_text("Loaded") # element might go stale
# Use locator-based:
expect(driver, by=By.ID, value="dynamic").to_have_text("Loaded!")
expect.poll()
Retry arbitrary functions:
from selenium_expect import expect, poll
# Poll a JavaScript result
expect.poll(lambda: driver.execute_script("return localStorage.getItem('token')")).to_equal(
"abc123"
)
# Poll with timeout
expect.poll(
lambda: driver.execute_script("return document.readyState"),
timeout=30,
).to_equal("complete")
expect.configure()
Create pre-configured expect variants:
# Fast assertions for frequent checks
fast_expect = expect.configure(timeout=1.0, polling=0.1)
fast_expect(button).to_be_visible()
# Strict assertions for critical paths
strict_expect = expect.configure(timeout=30.0, soft=True)
strict_expect(form).to_be_visible()
strict_expect(form).to_have_attribute("method", "POST")
assert_all() # Check all soft assertions
Composition
Combine multiple assertions:
from selenium_expect import expect
# All must pass (AND)
expect(element).to_satisfy_all(
lambda e: expect(e).to_be_visible(),
lambda e: expect(e).to_be_enabled(),
lambda e: expect(e).to_have_text("Submit"),
)
# At least one must pass (OR)
expect(element).to_satisfy_any(
lambda e: expect(e).to_have_text("Save"),
lambda e: expect(e).to_have_text("Submit"),
)
# None must pass (NOT)
expect(element).to_satisfy_none(
lambda e: expect(e).to_be_disabled(),
lambda e: expect(e).to_be_hidden(),
)
Comparison
| Feature | selenium-expect | WebDriverWait + EC | Playwright expect | Selenium IDE |
|---|---|---|---|---|
| Auto-retry | Yes | Yes (explicit) | Yes | No |
| Fluent API | Yes | No | Yes | No |
| Negation | Yes (.not_) |
Manual | Yes (.not_) |
No |
| Soft assertions | Yes | No | No | No |
| Custom matchers | Yes | No | Yes | No |
| Locator re-find | Yes | Manual | Built-in | No |
| Lists | Yes | Manual | Yes | No |
| Cookies | Yes | No | No | No |
| JS state | Yes | No | No | No |
| Shadow DOM | Yes | No | Yes | No |
| Select/dropdown | Yes | No | Yes | No |
| Alerts | Yes | Yes | Yes | No |
| Configurable polling | Yes (fixed + backoff) | Fixed | Fixed | No |
| Descriptive errors | Yes (timeline + HTML) | Basic | Good | Basic |
| Framework dependency | None | Selenium | Playwright | IDE |
| Python support | >= 3.11 | All | N/A | N/A |
Error messages
selenium-expect produces descriptive, multi-line error messages:
AssertionError: Expected element to have text "Loaded!", but got "Loading..."
Expected: Loaded!
Actual: Loading...
Element: <div id="status" class="loading">Loading...</div>
Waited: 5001ms (10 polls at 0.5s interval)
Message: Status should show Loaded! after AJAX completes
Timeline: [poll 1: Loading..., poll 2: Loading..., ..., poll 10: Loading...]
License
Contributing
Contributions are welcome! See CONTRIBUTING.md for development setup and guidelines.
Please report security vulnerabilities privately — see SECURITY.md.
Links
Acknowledgements
Inspired by Playwright's assertion API and @playwright/test's expect(). Built on Selenium Python bindings.
Release files for selenium-expect 1.0.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| selenium_expect-1.0.0.tar.gz | 99.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| selenium_expect-1.0.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 141.2 kB
Release files / selenium_expect-1.0.0.tar.gz
| Download URL | selenium_expect-1.0.0.tar.gz |
|---|---|
| Size | 99.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
bfdbcae6d16c4f39de8f783db83484c9d578aaf5971e679e3fe7ed17d15f897c
|
|
BLAKE2b-256 checksum How to use checksums |
633254a74465a0f803b504cc8ab1c8c8d1b1c9109116223964e8553bc5e0bba5
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 27, 2026.
Transparency logRelease files / selenium_expect-1.0.0-py3-none-any.whl
| Download URL | selenium_expect-1.0.0-py3-none-any.whl |
|---|---|
| Size | 42.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
d17d9295092d548c7a3bd96b0622075000a78ab035d30b98942f7da7c9a1e2d4
|
|
BLAKE2b-256 checksum How to use checksums |
b96e913a5377e47f241b0bcd99cd2b0a60a55a3e032ade91aab365c7cfc76e53
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 27, 2026.
Transparency log