July 25, 2026
How to Run Selenium Tests in GitHub Actions
Learn how to run Selenium tests in GitHub Actions with a real browser setup, GitHub Actions workflow examples, artifact collection, and practical debugging tips for flaky CI browser tests.
If you want browser coverage to mean something, you need it to run in the same place your code lands. For many teams that place is GitHub Actions, because the repo, pull request checks, and deployment workflows already live there. The catch is simple: Selenium tests that feel stable on a laptop often expose weak assumptions once they run headlessly in CI, under limited resources, with a fresh browser session every time.
This tutorial shows how to run Selenium tests in GitHub Actions in a way that is actually useful to a development team. That means getting the browser and driver setup right, wiring the workflow to collect artifacts when tests fail, and avoiding the common CI traps that turn browser tests into a maintenance tax.
What you need before you start
This guide assumes you already have a Selenium test suite that runs locally. I am using Python in the examples because it is compact and easy to read, but the workflow structure applies to Java, JavaScript, C#, and Ruby too.
You also need:
- A GitHub repository
- A test runner, such as
pytest - Selenium 4 and a browser you want to run in CI
- A basic understanding of GitHub Actions workflows, which GitHub documents in the Actions guide
If you are still deciding whether Selenium is the right foundation for your team, the official Selenium documentation is a good baseline for what the tool supports and what it does not.
CI browser tests fail most often because the environment is different, not because the test logic is clever enough to be wrong.
The implementation path that scales
There are two common ways to run Selenium tests in GitHub Actions:
- Install a browser and driver directly on the runner
- Use a container or prebuilt service image that already includes the browser stack
For most teams starting out, the first option is easier to understand and debug. It is also enough for many test suites, especially if you are running only one browser and you do not need a heavily customized environment.
The second option becomes attractive when you need tighter reproducibility, multiple browsers, or an easier way to keep local and CI environments closer together. It can reduce setup drift, but it also adds another layer to maintain.
The rest of this article focuses on the direct runner approach first, then calls out where containerized execution makes sense.
A minimal Selenium test you can run in CI
Here is a small Python test that visits a page and checks the title. It is intentionally boring. Boring tests are easier to stabilize than over-abstracted page object layers that hide what is actually being asserted.
# tests/test_smoke.py
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
def test_homepage_title(): options = Options() options.add_argument(“–headless=new”) options.add_argument(“–no-sandbox”) options.add_argument(“–disable-dev-shm-usage”) driver = webdriver.Chrome(options=options)
try:
driver.get("https://example.com")
assert "Example Domain" in driver.title
finally:
driver.quit()
A few details matter here:
--headless=newuses modern headless mode in current Chromium-based browsers--no-sandboxis often needed in Linux CI environments--disable-dev-shm-usagereduces failures caused by limited shared memory in containers or constrained runners- The
try/finallyblock ensures the browser closes even if the assertion fails
For real suites, replace https://example.com with your app URL, or better, use an environment variable so you can point the same test at a preview environment, staging, or a local server.
GitHub Actions workflow for Selenium tests
A basic workflow for running Selenium tests in GitHub Actions can look like this:
name: selenium-tests
on: push: branches: [“main”] pull_request: workflow_dispatch:
jobs: test: runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run Selenium tests
run: pytest -q
That is enough to get started, but it is not enough for dependable browser tests. The missing piece is browser setup.
Install the browser in GitHub Actions
On ubuntu-latest, you still need to be explicit about the browser you want. Selenium 4 can manage drivers more smoothly than older setups, but your test still depends on the browser being present and usable.
A practical workflow adds browser installation before the test step:
name: selenium-tests
on: push: branches: [“main”] pull_request:
jobs: test: runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install Python dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Install Chrome
uses: browser-actions/setup-chrome@v1
- name: Run Selenium tests
run: pytest -q
You can also install Firefox with a similar action if your coverage depends on it. The important part is not the exact action name. It is that the browser install is deterministic and visible in the workflow file, rather than hidden in an image you do not control.
Why this matters
A frequent failure mode in CI is assuming that selenium alone is enough. It is not. Selenium drives browsers, it does not magically provide them. If the browser version changes underneath you, or if the runner image changes, your tests can fail in ways that are hard to explain from the test code alone.
Manage the browser and driver explicitly
There are two stability questions you should answer for every Selenium CI setup:
- Which browser version is being used?
- How is the matching driver resolved?
Selenium 4’s built-in driver management helps, but you still need to understand what your code is relying on. If a team starts pinning browser versions in the workflow, that is usually because a browser update changed rendering behavior or broke a selector assumption.
A simple Python setup that lets Selenium manage ChromeDriver can be enough for many teams:
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
options = Options() options.add_argument(“–headless=new”) options.add_argument(“–no-sandbox”) options.add_argument(“–disable-dev-shm-usage”)
driver = webdriver.Chrome(options=options)
If your CI environment becomes more complex, you may need to pin the browser version more tightly, especially if you are debugging a regression that only appears on a specific browser release.
Structure the suite for CI, not for a demo
Local test code often has hidden dependencies that are harmless on one laptop and destructive in CI. The most common ones are:
- Tests that depend on execution order
- Shared mutable state between tests
- Hard-coded sleep calls
- Locators that depend on layout instead of semantics
- Tests that assume data created by another test
For GitHub Actions browser tests, each test should be able to run in isolation. A workflow re-run might execute a single failed test, and if your suite only passes when the entire file runs in a specific order, you are storing risk instead of value.
Use explicit waits rather than sleeps. That is not a style preference, it is a reliability requirement.
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
wait = WebDriverWait(driver, 10) button = wait.until(EC.element_to_be_clickable((By.CSS_SELECTOR, “button[type=’submit’]”))) button.click()
A wait does not make a flaky test good, but it removes one of the most common reasons browser tests fail under CI load.
Capture artifacts when tests fail
Running Selenium tests in GitHub Actions without artifacts is operationally expensive. When a test fails, the first question is usually, “What did the browser see?” If you do not store a screenshot, log output, or HTML snapshot, the team has to reproduce the failure blindly.
For practical debugging, save at least:
- A screenshot on failure
- The page source or a DOM snapshot where relevant
- Test logs
Here is a simple pytest fixture that saves a screenshot on failure:
# conftest.py
import pytest
@pytest.fixture def driver(): from selenium import webdriver from selenium.webdriver.chrome.options import Options
options = Options()
options.add_argument("--headless=new")
options.add_argument("--no-sandbox")
options.add_argument("--disable-dev-shm-usage")
browser = webdriver.Chrome(options=options)
yield browser
browser.quit()
And a hook to capture failure artifacts:
# conftest.py
from pathlib import Path
def pytest_runtest_makereport(item, call): if call.when == “call” and call.excinfo is not None: driver = item.funcargs.get(“driver”) if driver: Path(“artifacts”).mkdir(exist_ok=True) driver.save_screenshot(f”artifacts/{item.name}.png”)
Then upload the artifacts in GitHub Actions:
- name: Upload test artifacts
if: failure()
uses: actions/upload-artifact@v4
with:
name: selenium-artifacts
path: artifacts/
This is one of the highest leverage improvements you can make. It shortens the distance between a red CI check and a usable diagnosis.
Run tests against a deployed preview, not a developer laptop
The best Selenium CI setup usually points at a real environment, not a local server running only on the same machine as the test.
That usually means one of these:
- A preview deployment from the pull request
- A staging environment populated with test data
- A disposable test environment created by the pipeline
You can pass the target URL through an environment variable:
- name: Run Selenium tests
env:
BASE_URL: $
run: pytest -q
And read it in your test:
import os
base_url = os.environ[“BASE_URL”] driver.get(base_url)
This separation matters because browser tests are integration tests. If the app, backend, auth layer, and data store are not all available, a Selenium failure can be a genuine product issue or a bad test fixture. The cleaner the environment contract, the easier that distinction becomes.
Add matrix jobs only when you need them
A common temptation is to run every browser on every pull request. That is often too much for an early suite.
Start with the smallest browser set that protects the riskiest workflows. For many teams that is Chrome on Linux. Expand only when the product or customer base justifies it.
When you do need multiple browsers, GitHub Actions matrix jobs are the right pattern:
strategy:
matrix:
browser: [chrome, firefox]
Then branch in your setup based on the matrix value. That gives you a clean way to express coverage without duplicating workflow files.
The tradeoff is obvious, matrix expansion increases CI time and makes failures noisier. If a test fails only in Firefox, that is useful signal. If every commit now runs a dozen browser combinations with no clear ownership of failures, the workflow can become a drag on delivery.
Keep the suite stable under real CI constraints
GitHub Actions runners are not your workstation. They are shared, ephemeral, and limited. Selenium tests that are acceptable on a laptop can break for reasons that have nothing to do with the app.
Watch for these failure modes:
Timing assumptions
A DOM element may exist but not yet be ready to interact with. Prefer conditions like visibility, clickability, or URL changes over raw sleeps.
Resource pressure
Headless browsers consume memory and CPU. If a page is heavy, a shared runner may make hidden performance issues visible.
Data collisions
Parallel jobs that create the same test user, same email, or same record name will interfere with each other. Make test data unique per run.
Environment drift
If a workflow changes browser versions, image versions, or dependency versions without a corresponding test change, new flakes appear with no code diff in the test suite.
Bad teardown
If a test leaves data behind, later tests can inherit the mess. Teardown is not optional in CI, especially if the suite writes to a shared staging environment.
The most expensive flaky test is the one everyone has learned to ignore.
A stronger workflow for teams that need logs and screenshots
Here is a more complete workflow that installs dependencies, runs tests, and uploads artifacts if anything fails:
name: selenium-tests
on: pull_request: push: branches: [“main”]
jobs: test: runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Install Chrome
uses: browser-actions/setup-chrome@v1
- name: Run tests
env:
BASE_URL: $
run: pytest -q --junitxml=reports/junit.xml
- name: Upload JUnit report
if: always()
uses: actions/upload-artifact@v4
with:
name: junit-report
path: reports/junit.xml
- name: Upload screenshots
if: failure()
uses: actions/upload-artifact@v4
with:
name: selenium-screenshots
path: artifacts/
This pattern is useful because it treats CI as a debugging surface, not just a pass/fail gate.
When containerized Selenium is the better option
Installing browsers directly on the runner works well until it does not. If your suite grows, a container can make browser setup more repeatable.
Use containers when:
- You need the same browser stack in local dev and CI
- You want to pin browser dependencies more tightly
- You are debugging environment-specific issues
- Your team is already comfortable maintaining Docker images
The downside is maintenance overhead. A container is another artifact to build, patch, and keep aligned with your application dependencies. If your test infrastructure is already complex, a custom image can improve reproducibility. If you are just getting started, it may be more machinery than you need.
Where Endtest fits as a simpler alternative
If your team wants scheduled or CI-triggered browser tests without maintaining browser setup in GitHub Actions, a platform like Endtest can be a simpler option. Endtest is an agentic AI test automation platform, and its Selenium migration docs note support for bringing in Java, Python, and C# suites automatically, which can matter if you are gradually moving away from hand-maintained framework code.
The practical tradeoff is this, Selenium gives you complete control, but that control comes with setup, driver management, and ongoing maintenance. A maintained platform can reduce the amount of CI plumbing your team owns directly. That is not a universal answer, but it is worth evaluating if your main problem is not writing the test, it is keeping the test running reliably across browsers and environments.
If you are comparing the operational cost of framework ownership, the broader question is not just “can we run Selenium tests in GitHub Actions?” It is also “how much time do we want to spend maintaining that pipeline versus maintaining the product?”
Choosing the right level of CI investment
A good Selenium CI setup is not the one with the most features. It is the one your team can keep healthy.
Use this rough decision rule:
- If you have a small suite and one browser, keep the workflow simple
- If you need artifacts to debug failures, add them immediately
- If browser version drift hurts you, pin versions or move to containers
- If your team spends too much time on CI setup, reevaluate whether Selenium is still the right ownership model for every test
The technical goal is not to make GitHub Actions look impressive. The goal is to get fast, trustworthy feedback from browser tests with enough observability to act on failures quickly.
Final checklist
Before you call the workflow done, verify these points:
- The browser is installed explicitly in CI
- Selenium tests run headlessly and cleanly
- Waits are explicit, not sleep-driven
- Test data is isolated per run
- Artifacts are uploaded on failure
- The suite points at a real target environment
- Browser versions are controlled or understood
- Failed runs leave behind enough evidence to debug
If all of that is true, you have a Selenium CI/CD setup that can survive contact with real development work.
That is the standard that matters.