I’m working on a Python project using Selenium and pytest. All tests pass locally in normal (non-headless) mode, but when I run them in GitHub Actions or even locally in headless mode, many of them start failing. I’ve already added several JavaScript-based workarounds, but they don’t help. Could you please advise what I should try next?
Test Results
============================= test session starts =============================
collecting ... collected 1 item
tests/test_header_click.py::test_header_clicks
============================= 1 failed in 38.27s ==============================
FAILED [100%]
tests\test_header_click.py:6 (test_header_clicks)
driver = <selenium.webdriver.chrome.webdriver.WebDriver (session="c4abb5b393cb24809aa039173f966654")>
@allure.feature("Header")
@allure.story("Top Navigation")
@allure.title("Verify all header menu links are clickable and open correct pages")
@pytest.mark.smoke
def test_header_clicks(driver):
header = HeaderPage(driver)
items = [
(header.click_magazine, "magazine", True),
(header.click_reviews, "reviews", True),
(header.click_support, "support", True),
(header.click_profile_icon, "my-account", True)
]
for action, expected, need_back in items:
with allure.step(f"Check header link: {expected}"):
> action()
tests\test_header_click.py:24:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
pages\header_page.py:28: in click_magazine
self.click(self.MAGAZINE)
pages\base_page.py:31: in click
element = self.wait.until(EC.element_to_be_clickable(locator))
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
self = <selenium.webdriver.support.wait.WebDriverWait (session="c4abb5b393cb24809aa039173f966654")>
method = <function element_to_be_clickable.<locals>._predicate at 0x000002235C2B07D0>
message = ''
def until(self, method: Callable[[D], Union[Literal[False], T]], message: str = "") -> T:
"""Wait until the method returns a value that is not False.
Calls the method provided with the driver as an argument until the
return value does not evaluate to ``False``.
Parameters:
-----------
method: callable(WebDriver)
- A callable object that takes a WebDriver instance as an argument.
message: str
- Optional message for :exc:`TimeoutException`
Return:
-------
object: T
- The result of the last call to `method`
Raises:
-------
TimeoutException
- If 'method' does not return a truthy value within the WebDriverWait
object's timeout
Example:
--------
>>> from selenium.webdriver.common.by import By
>>> from selenium.webdriver.support.ui import WebDriverWait
>>> from selenium.webdriver.support import expected_conditions as EC
# Wait until an element is visible on the page
>>> wait = WebDriverWait(driver, 10)
>>> element = wait.until(EC.visibility_of_element_located((By.ID, "exampleId")))
>>> print(element.text)
"""
screen = None
stacktrace = None
end_time = time.monotonic() + self._timeout
while True:
try:
value = method(self._driver)
if value:
return value
except self._ignored_exceptions as exc:
screen = getattr(exc, "screen", None)
stacktrace = getattr(exc, "stacktrace", None)
if time.monotonic() > end_time:
break
time.sleep(self._poll)
> raise TimeoutException(message, screen, stacktrace)
E selenium.common.exceptions.TimeoutException: Message:
.venv\Lib\site-packages\selenium\webdriver\support\wait.py:138: TimeoutException
Process finished with exit code 1
helpers/browser.py
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
def get_driver():
options = Options()
options.add_argument("--window-size=1920,1080")
options.add_argument("--no-sandbox")
options.add_argument("--disable-gpu")
options.add_argument("--disable-dev-shm-usage")
options.add_argument("--headless=new")
driver = webdriver.Chrome(options=options)
driver.implicitly_wait(5)
return driver
pages/base_page.py
import time
from selenium.webdriver import ActionChains
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from utils.popup_handler import PopupHandler
class BasePage:
def __init__(self, driver, timeout=15):
self.driver = driver
self.wait = WebDriverWait(driver, timeout)
self.popups = PopupHandler(driver)
#locators
def pause(self, seconds):
time.sleep(seconds)
def open(self, url):
self.driver.get(url)
def click(self, locator):
element = self.wait.until(EC.presence_of_element_located(locator))
self.driver.execute_script("arguments[0].scrollIntoView({block: 'center'});", element)
element = self.wait.until(EC.element_to_be_clickable(locator))
element.click()
def scroll_to(self, locator):
element = self.wait.until(EC.presence_of_element_located(locator))
self.driver.execute_script("""
const rect = arguments[0].getBoundingClientRect();
window.scrollBy({top: rect.top - window.innerHeight/3, behavior: 'smooth'});
""", element)
def type(self, locator, text):
element = self.wait.until(EC.visibility_of_element_located(locator))
element.clear()
element.send_keys(text)
def get_text(self, locator):
element = self.wait.until(EC.visibility_of_element_located(locator))
return element.text
def is_visible(self, locator):
try:
self.wait.until(EC.visibility_of_element_located(locator))
return True
except:
return False
def hover(self, locator):
# Ensure element is visible and scrolled into view before hovering
element = self.wait.until(EC.presence_of_element_located(locator))
self.driver.execute_script("arguments[0].scrollIntoView({block: 'center'});", element)
element = self.wait.until(EC.visibility_of_element_located(locator))
# Use JavaScript hover for better headless mode compatibility
# Trigger both mouseover and mouseenter events to ensure dropdowns appear
self.driver.execute_script("""
var element = arguments[0];
var mouseover = new MouseEvent('mouseover', {bubbles: true, cancelable: true});
var mouseenter = new MouseEvent('mouseenter', {bubbles: true, cancelable: true});
element.dispatchEvent(mouseover);
element.dispatchEvent(mouseenter);
""", element)
# Also try ActionChains as backup
try:
ActionChains(self.driver).move_to_element(element).perform()
except Exception:
pass # JavaScript hover already executed
def check_availability(self):
self.click(self.AVAILABILITY_BUTTON)
def add_to_cart(self):
element = self.wait.until(EC.presence_of_element_located(self.ADD_TO_CART_BUTTON))
self.driver.execute_script("arguments[0].scrollIntoView({block: 'center'});", element)
element = self.wait.until(EC.element_to_be_clickable(self.ADD_TO_CART_BUTTON))
self.driver.execute_script("arguments[0].click();", element)
self.wait_for_cart_popup()
def close_cart_popup(self):
self.pause(0.5)
self.click(self.CART_CLOSE_BUTTON)
def open_cart(self):
self.pause(1.5)
element = self.wait.until(EC.presence_of_element_located(self.VIEW_CART_POPUP))
self.driver.execute_script("arguments[0].scrollIntoView({block: 'center'});", element)
self.wait.until(EC.element_to_be_clickable(self.VIEW_CART_POPUP))
self.driver.execute_script("arguments[0].click();", element)
def clean_popups(self):
try:
self.popups.clean()
except Exception as e:
print(f"[Popups] clean() failed but ignored: {e}")
def wait_for_cart_popup(self, timeout=10):
WebDriverWait(self.driver, timeout).until(
EC.visibility_of_element_located(self.VIEW_CART_POPUP)
)
tests/test_header_click.py
import allure
from pages.header_page import HeaderPage
import pytest
@allure.feature("Header")
@allure.story("Top Navigation")
@allure.title("Verify all header menu links are clickable and open correct pages")
@pytest.mark.smoke
def test_header_clicks(driver):
header = HeaderPage(driver)
items = [
(header.click_magazine, "magazine", True),
(header.click_reviews, "reviews", True),
(header.click_support, "support", True),
(header.click_profile_icon, "my-account", True)
]
for action, expected, need_back in items:
with allure.step(f"Check header link: {expected}"):
action()
assert expected in driver.current_url.lower()
if need_back:
driver.back()
with allure.step("Click logo to return to homepage"):
header.click_logo()
assert "example.com" in driver.current_url.lower()
pages/header_page.py
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.common.by import By
from pages.base_page import BasePage
class HeaderPage(BasePage):
#HEADER
LOGO = (By.XPATH, "//a[@class='logo']")
PRODUCTS = (By.XPATH, "//*[@id='menu-item-112235']")
MAGAZINE = (By.CSS_SELECTOR, "header a[href*='/category/particle-magazine/']")
REVIEWS = (By.CSS_SELECTOR, "header a[href*='/particle-reviews/']")
SUPPORT = (By.CSS_SELECTOR, "header a[href*='/faq-support']")
PROFILE_ICON = (By.XPATH, "//*[@class='btn btn-transparent login ']")
CART_ICON = (By.XPATH, "//*[@class='shopping-basket']")
# METHODS CLICK HEADER
def click_logo(self):
self.click(self.LOGO)
def click_magazine(self):
self.click(self.MAGAZINE)
def click_reviews(self):
self.click(self.REVIEWS)
def click_support(self):
self.click(self.SUPPORT)
def click_profile_icon(self):
self.click(self.PROFILE_ICON)
def click_cart_icon(self):
self.click(self.CART_ICON)
# HOVERs
def hover_support(self):
self.hover(self.SUPPORT)
try:
submenu = (By.CSS_SELECTOR, "#menu-item-534292 .sub-menu, #menu-item-534292 ul")
self.wait.until(EC.visibility_of_element_located(submenu))
except Exception:
self.pause(0.5)
def hover_products(self):
self.hover(self.PRODUCTS)
try:
submenu = (By.CSS_SELECTOR, "#menu-item-112235 .sub-menu, #menu-item-112235 ul")
self.wait.until(EC.visibility_of_element_located(submenu))
except Exception:
self.pause(0.5)
GitHub Actions workflow
name: UI tests - regression
on:
schedule:
- cron: "0 5 * * *"
workflow_dispatch:
jobs:
regression-tests:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Cache pip
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-
- name: Install Chrome
uses: browser-actions/setup-chrome@v2
with:
chrome-version: stable
install-chromedriver: false
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run pytest (regression)
run: |
pytest -m "regression"
- name: Upload Allure results (regression)
if: always()
uses: actions/upload-artifact@v4
with:
name: allure-results-regression
path: allure-results
Tests run fine locally in normal mode on Windows. On Ubuntu or in headless mode, they usually fail with different exceptions like TimeoutException or ElementClickInterceptedException: element click intercepted.
In normal mode, everything works well and all tests pass.
It's most likely because the -window-size=1920,1080 no longer works in Chrome when in headless mode, so it's running at a different size than when not in headless mode. You should use --screen-info={0,0 1920x1080} or else resize the Window after you launch the browser. See: https://issues.chromium.org/issues/423334494
Also, you should remove all the JavaScript click workarounds and driver.implicitly_wait(5) (you are overriding the explicit waits you are trying to use).