Accessibility issues are easy to miss in manual smoke testing, and they are also easy to hide inside a growing Selenium suite. A button can be clickable, a modal can open, and a form can submit, while the page still fails basic accessibility expectations like missing labels, broken heading structure, or poor color contrast. That is why many teams add accessibility checks Selenium tests, not as a replacement for manual review, but as a practical safety net.

This tutorial shows how to add accessibility checks to Selenium tests using common accessibility libraries, especially axe-core. You will see how to run checks at the page level, how to scope scans to a specific component, how to handle failures in CI, and what these checks can and cannot tell you.

The goal is not to turn Selenium into a full accessibility auditing tool. The goal is to catch obvious regressions early, in the same workflow where you already validate the UI.

Why add accessibility checks to Selenium?

Selenium is already good at driving a browser through real user flows. That makes it a natural place to add automated accessibility Selenium coverage for the parts of the UI that are most likely to regress, login screens, forms, dialogs, navigation menus, and dynamic widgets.

There are three practical reasons to combine accessibility and Selenium:

  1. You get checks in real user flows. A component may be technically accessible in isolation, but fail when rendered in a modal, embedded in a form, or revealed after async data loads.
  2. You reduce setup overhead. If your team already has Selenium tests, adding a browser-side accessibility library is often much cheaper than introducing a separate audit pipeline.
  3. You can gate obvious regressions in CI. Missing labels, broken ARIA, or contrast failures can be caught before merge, which is where they are cheapest to fix.

The key tradeoff is scope. Selenium plus an accessibility library is excellent at finding machine-detectable violations, but it does not replace keyboard-only manual testing, screen reader testing, or design review.

What Selenium can and cannot verify

Before writing code, it helps to be precise about the kinds of issues automation can detect.

Good candidates for automation

With an engine like axe-core, Selenium can help detect:

  • Missing or empty form labels
  • Inputs without accessible names
  • Invalid ARIA attributes and roles
  • Improper heading structure
  • Images missing alternative text
  • Contrast issues that can be determined from computed styles
  • Buttons or links without discernible text
  • Duplicate IDs and other DOM issues

Issues that still need human review

Automation will not fully validate:

  • Whether the focus order matches the intended user experience
  • Whether screen reader announcements are useful in context
  • Whether keyboard traps exist in custom widgets after interaction
  • Whether copy is understandable for assistive technology users
  • Whether a product meets the intent of a WCAG success criterion in a nuanced scenario

For example, a modal might pass automated checks while still being hard to use because focus is not moved into it after open. That is a real accessibility bug, but it requires an interaction test and often human judgment.

Why axe-core is the usual choice

The most common way to add accessibility checks Selenium teams is to run axe-core inside the browser session. Axe is widely used because it covers a large set of rule-based accessibility checks and returns structured violations that are easy to report in tests.

Axe is not tied to Selenium specifically. It runs in the browser, which means you can inject it into a Selenium session and analyze the current DOM state. That gives you a clean model:

  1. Navigate to a page with Selenium
  2. Reach the state you want to validate
  3. Inject axe-core
  4. Run a scan
  5. Fail the test if violations exceed your threshold

The accessibility rules implemented by axe are aligned with common WCAG guidance, so the results are usually understandable to developers and QA engineers. If you want to review the broader standard behind the checks, the W3C WCAG overview is the right reference point.

A basic Selenium + axe-core example in Python

The simplest setup is a Selenium test that loads the page, injects axe-core from a CDN or local file, and then runs a scan.

Here is a compact example using Python:

from selenium import webdriver
from selenium.webdriver.chrome.options import Options

AXE_SRC = “https://cdnjs.cloudflare.com/ajax/libs/axe-core/4.10.3/axe.min.js”

options = Options() options.add_argument(“–headless=new”) driver = webdriver.Chrome(options=options)

try: driver.get(“https://example.com”) driver.execute_script(f”var s=document.createElement(‘script’); s.src=’{AXE_SRC}’; document.head.appendChild(s);”) driver.implicitly_wait(2)

result = driver.execute_async_script("""
    const callback = arguments[arguments.length - 1];
    axe.run().then(results => callback(results));
""")

violations = result["violations"]
assert len(violations) == 0, violations finally:
driver.quit()

This is enough to prove the pattern, but there are two problems with it in real projects:

  • Loading axe from a remote CDN makes tests dependent on network access
  • Failing on every violation can be too noisy if your app already has known issues

A better practice is to vendor axe-core into your repository or install it as a package so test runs do not depend on external availability.

A more maintainable approach: bundle axe-core locally

For stable CI runs, keep axe-core available locally and reference the minified script from your test assets. The mechanics vary by stack, but the idea is the same, read the file and inject it into the browser session.

from pathlib import Path
from selenium import webdriver
from selenium.webdriver.chrome.options import Options

axe_src = Path(“tests/vendor/axe.min.js”).read_text(encoding=”utf-8”)

options = Options() options.add_argument(“–headless=new”) driver = webdriver.Chrome(options=options)

try: driver.get(“http://localhost:3000/login”) driver.execute_script(axe_src)

result = driver.execute_async_script("""
    const done = arguments[arguments.length - 1];
    axe.run(document, { runOnly: ['wcag2a', 'wcag2aa'] })
       .then(done)
       .catch(err => done({ error: err.message }));
""")

assert "error" not in result, result.get("error")
assert result["violations"] == [], result["violations"] finally:
driver.quit()

A few details matter here:

  • runOnly limits the rule set, which keeps output focused
  • Scanning document validates the current page state, not an arbitrary sub-tree
  • Storing the script locally avoids flaky test runs from external dependencies

Scoping scans to the part of the UI you changed

Scanning every page on every test is rarely the best starting point. If you are testing a drawer, dialog, or specific form, it can be more useful to scope the scan to a specific element.

That helps in two ways:

  • You get a smaller, more actionable violation list
  • You avoid unrelated issues elsewhere in the page from hiding the problem you are trying to fix

Axe supports running against a selected element. In Selenium, you can locate the element first and pass it into the scan.

from selenium.webdriver.common.by import By

form = driver.find_element(By.CSS_SELECTOR, “form[data-testid=’checkout’]”) result = driver.execute_async_script(“”” const el = arguments[0]; const done = arguments[arguments.length - 1]; axe.run(el).then(done).catch(err => done({ error: err.message })); “””, form)

This pattern is especially useful for component-level smoke tests, for example validating a modal after it opens or a form after fields render dynamically.

What to do with violations

Axe returns a structured object that usually includes the violation ID, impact, help text, affected nodes, and sometimes suggestions. In practice, you want to turn that into test output your team can act on quickly.

A useful failure message often includes:

  • Rule ID
  • Impact level
  • Help text
  • CSS selector or HTML snippet for each affected node

Here is a simple formatter:

def format_violations(violations):
    lines = []
    for v in violations:
        lines.append(f"{v['id']} ({v.get('impact', 'unknown')}): {v['help']}")
        for node in v.get('nodes', []):
            lines.append(f"  - {node['target']}: {node['html']}")
    return "\n".join(lines)

Then in your assertion:

python assert not violations, format_violations(violations)

That is much more useful than failing with a plain False.

The fastest accessibility fixes usually come from good error messages. If a developer can jump directly to the element and understand the rule, the test becomes a workflow tool, not just a gate.

Deciding which violations should fail the build

Not every team should fail on every violation immediately. The right threshold depends on how mature your UI is and how much accessibility debt already exists.

A practical progression looks like this:

Stage 1, observe only

Run the scan, record the report, do not fail the test yet. This helps you learn which rules are noisy in your application and which ones are true regressions.

Stage 2, fail on critical and serious issues

When the backlog is under control, make tests fail on higher-impact violations only. This reduces noise while still preventing obvious regressions.

Stage 3, tighten to all targeted rules

As the codebase improves, expand coverage to all chosen WCAG levels or the specific rules your team has committed to enforce.

This is where many teams get tripped up. If you enable everything on day one, the suite becomes annoying and people stop trusting it. If you never fail, the checks become decorative. Start with a policy you can sustain.

Build a reusable helper for Selenium accessibility checks

Do not copy the axe injection and assertion logic into every test. Wrap it in a helper so your suite stays consistent.

python class AccessibilityChecker: def init(self, driver): self.driver = driver

def inject_axe(self, axe_source: str):
    self.driver.execute_script(axe_source)

def scan(self, context=None, tags=None):
    tag_list = tags or ["wcag2a", "wcag2aa"]
    script = """
        const context = arguments[0] || document;
        const tags = arguments[1];
        const done = arguments[arguments.length - 1];
        axe.run(context, { runOnly: { type: 'tag', values: tags } })
           .then(done)
           .catch(err => done({ error: err.message }));
    """
    return self.driver.execute_async_script(script, context, tag_list)

Then your test reads more clearly:

def test_checkout_page_accessibility(driver, axe_source):
    driver.get("http://localhost:3000/checkout")
    checker = AccessibilityChecker(driver)
    checker.inject_axe(axe_source)
result = checker.scan(tags=["wcag2a", "wcag2aa"])
assert not result.get("violations"), format_violations(result["violations"])

That structure also makes it easier to reuse the same logic across page objects, test fixtures, or CI jobs.

Common edge cases when adding accessibility checks Selenium tests

The integration is straightforward, but a few edge cases show up often in real projects.

Dynamic content that loads after the page is “ready”

Selenium may consider the page ready before important UI sections have rendered. If you scan too early, you will get false negatives or incomplete results.

The fix is to wait for the state you actually care about, not just the page load event. For example, wait for the target component to appear and stabilize before scanning.

Virtualized lists

Virtualized DOMs can make page-wide scans noisy or incomplete because only a subset of items exists in the DOM at once. In those cases, scan the visible region or a specific component instead of the full page.

Third-party widgets

Chat widgets, embedded payment flows, or analytics overlays can introduce violations you cannot fix directly. Decide in advance whether to ignore those nodes, disable the widget in test environments, or isolate your scan to your own app container.

Shadow DOM

Some custom elements live inside shadow roots, which can complicate scanning depending on how the library and your app are structured. If your design system uses shadow DOM heavily, validate whether your chosen approach can inspect those internals.

Authenticated pages and feature flags

A page may look accessible in staging but not in production because a feature flag changes the markup. Run accessibility checks on representative states, not just one happy path.

Add checks to a Selenium suite without making it brittle

A common mistake is to combine every interaction and every audit in one huge test. That makes failures hard to debug. A better pattern is to separate concerns:

  • One Selenium test verifies the user flow
  • One accessibility scan verifies the rendered state of that flow

For example, you might have a login test that navigates to the dashboard, and after the dashboard loads you run a scan focused on the main content area. If the login flow fails, the test already tells you why. If the accessibility scan fails, the report identifies the UI issue.

You can also split scans by page or component:

  • Authentication pages
  • Core navigation
  • Forms and dialogs
  • Checkout or conversion flows
  • Reusable design system components

That modularity helps ownership too. Teams can map scans to the parts of the product they actually maintain.

Running accessibility checks in CI

Once the checks are stable locally, wire them into CI so they run on pull requests.

A simple GitHub Actions job might look like this:

name: tests

on: pull_request:

jobs: selenium-accessibility: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: ‘3.12’ - run: pip install -r requirements.txt - run: pytest tests/e2e –maxfail=1

The CI job itself does not need to know much about accessibility. Your Selenium tests handle the scan, and the build fails if violations are found.

Two practical CI tips:

  • Use a stable browser version matrix only if you need it, otherwise start simple
  • Keep the accessibility report visible in test logs or CI artifacts so developers can inspect it without rerunning locally

Reporting strategy that developers will actually use

If the output is hard to read, people will ignore it. A good accessibility test report should answer three questions quickly:

  1. What rule failed?
  2. Which element failed?
  3. What should I look at first?

You do not need a full custom dashboard to do that. Even plain test output can be effective if it includes the rule ID, the selector, and the help text.

Some teams also store the raw JSON from axe and attach it as a CI artifact. That is useful when you need to compare regressions over time or feed the data into another reporting system.

When to use Selenium plus axe, and when to choose another tool

Selenium plus axe-core is a strong default when your team already has browser automation in place. It is especially useful if you want to validate real user flows without adding a separate toolchain.

You may want a different approach if:

  • Your UI tests are being rewritten in another framework
  • You want deeper component-level accessibility checks in the same test runner
  • You need a broader test automation platform with accessibility included as a built-in step

For teams looking at that broader model, Endtest, an agentic AI test automation platform, accessibility testing is one option to consider because accessibility checks are part of the wider web test automation workflow, rather than a separate manual integration.

A practical rollout plan

If you are adding accessibility checks Selenium coverage to an existing suite, this rollout order usually works well:

  1. Pick one important page, like login or checkout
  2. Add axe-core locally to your Selenium harness
  3. Run scans against the rendered state after the page stabilizes
  4. Format failures so they point to real elements
  5. Start with observe-only mode if your backlog is large
  6. Expand to more pages or shared components
  7. Tighten fail thresholds once the team trusts the output

That approach keeps the workflow manageable and avoids the common trap of building a check that nobody wants to maintain.

Final takeaways

Adding accessibility checks to Selenium is one of the most practical ways to catch UI regressions early. The combination works well because Selenium gets you into the real browser state, while libraries like axe-core inspect that state against accessibility rules that developers can act on.

If you keep the scope focused, report violations clearly, and phase in failure thresholds carefully, accessibility checks become a reliable part of your test suite instead of a noisy add-on.

For most teams, the best starting point is simple, run axe-core inside Selenium on a few high-value pages, fix the obvious violations, then expand coverage as the suite proves itself.