Skip to main content

pytest-xa11y

Desktop UI testing for pytest, built on xa11y.

xa11y drives real applications through the platform accessibility APIs — AT-SPI2 on Linux, AXUIElement on macOS, UI Automation on Windows. This plugin supplies the part around the test: getting the app running and attached, deciding what this machine can actually exercise, and putting the tree in front of you when something fails.

# conftest.py
import pytest
from pytest_xa11y import AppLauncher

@pytest.fixture(scope="session")
def xa11y_launcher():
    return AppLauncher(
        command=["./target/debug/my-app"],
        ready='button[name="Sign in"]',
    )
# test_login.py
def test_sign_in(xa11y_app):
    xa11y_app.locator('text_field[name="Email"]').set_value("a@b.c")
    xa11y_app.locator('button[name="Sign in"]').press()
    xa11y_app.locator('static_text[name^="Welcome"]').wait_visible()
pip install pytest-xa11y

xa11y_app is an ordinary xa11y.App. The plugin wraps none of the library's own API — locators, elements, actions and errors are xa11y's, documented in xa11y's own reference. There is no second API surface here to learn or to drift out of step.

What the plugin does

Launch and attach. AppLauncher describes how the app starts; the plugin spawns it, polls until it registers with the platform accessibility API, waits for the readiness selector, and terminates it at the end of the session. A process that exits during startup is reported with its exit code and the tail of its output, rather than as a selector that never matched.

Liveness between tests. If the app dies mid-run, the next test says so — with the process's output — instead of the remaining eighty tests each failing on an unrelated lookup.

Reset, not relaunch. Launching a desktop app costs seconds, so xa11y_app is session-scoped. AppLauncher(reset=...) runs before each test to return the app to a known state. Use xa11y_fresh_app for the cases a reset cannot reach.

Capability skips. @pytest.mark.xa11y_requires("screenshot") skips when this session has no capture path — a headless X server, a missing macOS Screen Recording grant — instead of failing for a reason the developer cannot act on.

Failure diagnostics. A failing test gets the accessibility tree, the platform's focus state, the app's output, and any recorded events attached to its report. xa11y's structured error diagnosis (what it waited for, what it last observed, near-miss candidates) is rendered as its own labelled section. All of it bounded, on the failure path only, capped per run.

Fixtures

Fixture Scope What it gives you
xa11y_launcher session You define this. The launch recipe.
xa11y_app session The app under test.
xa11y_fresh_app function A newly launched app, torn down after the test.
xa11y_app_factory session Launch additional apps (dialogs that register separately, multi-process suites).
xa11y_events function An EventRecorder for a block of test code.
xa11y_capabilities session What this session can exercise, and why not.
xa11y_artifacts session The artifacts directory, or None.

AppLauncher

AppLauncher(
    command=[BINARY, "--headless"],   # argv, as a list
    env={"QT_ACCESSIBILITY": "1"},    # merged over os.environ
    cwd=PROJECT_ROOT,
    app_names=["my-app"],             # match the a11y name too, not just the PID
    app_name_prefix="Submit to ",     # ...or narrow to one app within our PID
    spawns_and_exits=False,           # the command hands off and exits
    ready='button[name="OK"]',        # gate startup on content
    startup_timeout=60,               # overrides --xa11y-startup-timeout
    frontmost=True,                   # claim the macOS front slot
    reset=lambda app: app.locator('button[name="Home"]').press(),
    label="widgets",                  # used in diagnostics and artifact names
)

attach_pid=N attaches to an already-running process instead of launching one; the plugin never terminates a process it did not start.

app_names matters when the process that registers with the accessibility API is not the one you launched — Electron helper processes, launcher shims that spawn a child and exit, anything that re-execs. It matches PID or name, widening the search.

spawns_and_exits is separate on purpose. Needing a name to find the app says nothing about which process to watch, and conflating the two costs the liveness reporting above: with death detection off, a startup crash is reported as "never registered" after the full timeout, and a mid-run exit is not noticed at all. Set it only for a command that genuinely hands off and goes — a Windows launcher shim — and leave it alone for an app that stays running under its own PID, app_names or not.

app_name_prefix is the opposite pairing: PID and name prefix, narrowing it. Reach for it when one process registers several accessibility apps and only one of them is what you want. A Qt dialog hosted inside a DCC application (Maya, Nuke, Cinema 4D) appears as its own accessibility app sharing the host's PID on Windows UIA, and matching on PID alone attaches to the host. The two fields are mutually exclusive — one widens what the other narrows.

Markers

@pytest.mark.xa11y_requires("screenshot")   # or "input_sim"
@pytest.mark.xa11y_frontmost                # claim the macOS front slot first

Capability names are plain strings. pytest_xa11y.Capability is a str enum of the same values, for completion and type checking; Capability.SCREENSHOT and "screenshot" are interchangeable everywhere.

xa11y_requires("screenshot") probes once per session with a full-display capture — the weakest thing the marker can be taken to mean, since a full-display capture can succeed where a region capture is rejected. Only the errors that mean "this session has no capture path" produce a skip. A capture that fails for any other reason propagates and fails the test, because a broken capture pipeline must not be able to turn a suite green.

xa11y_requires("input_sim") cannot probe on macOS or Windows, and the plugin does not pretend otherwise: CGEventPost returns void, so without the Accessibility and Input Monitoring grants the events are silently discarded and every layer reports success. Declare it with --xa11y-skip=input_sim (or XA11Y_SKIP_INPUT_SIM=1) on machines that lack the grant. On Linux the probe is real — both backends validate eagerly.

Tests carrying either marker skip when pytest-xdist is running more than one worker. Input synthesis and the frontmost slot are process-global, and parallel workers take them from each other.

The xa11y_ marker prefix is reserved, and every way of writing one of these markers wrongly fails collection rather than warning: a typo in the marker name, a capability that does not exist, xa11y_requires() with no arguments, or arguments passed to xa11y_frontmost, which takes none. pytest only warns about an unknown marker, which means the test still runs — unguarded, while reading as guarded. A test claiming a guard it does not have is worse than one with no guard at all, so this is an error and every offending marker in the run is reported at once.

Events

def test_focus_moves(xa11y_app, xa11y_events):
    with xa11y_events(xa11y_app) as events:
        xa11y_app.locator('button[name="OK"]').focus()
        events.expect("focus_changed", name="OK", timeout=2.0)

expect() blocks in xa11y's own wait (so it releases the GIL) and, on failure, reports the events that did arrive instead. Whatever a recorder saw is attached to the failure report.

It takes several event types when the platforms disagree about which one an interaction emits, which for accessibility bridges is common — toggling a checkbox is state_changed on one and value_changed on another:

events.expect(("state_changed", "value_changed"), timeout=5.0)

There is no expect() that means "the next event, whatever it is". A filter left off by accident would pass against anything that arrived; use Subscription.recv when that is genuinely what you want.

Options

Option Default Effect
--xa11y-timeout=SECONDS library default (5s) Calls xa11y.set_default_timeout(). Outranks XA11Y_DEFAULT_TIMEOUT; a per-call timeout= still wins over both.
--xa11y-startup-timeout=SECONDS 30 Waiting for the app to appear and become ready.
--xa11y-artifacts=DIR off Save a screenshot of each failing test.
--xa11y-skip=CAPABILITY none Declare a capability unavailable. Repeatable.
--xa11y-dump-depth=N 12 Depth of the tree dump on failure.
--xa11y-max-diagnostics=N 10 Cap diagnostics per run, so a wholesale failure does not bury its own report.

Suite-specific diagnostics

For state the plugin cannot know about:

import pytest_xa11y

pytest_xa11y.register_diagnostic(
    "event log",
    lambda app: app.locator('text_area[name="Event log"]').element().value or "",
)

Collectors run on failures only. One that raises is reported in the block rather than dropped, so a stale collector cannot quietly stop contributing.

Versioning

pytest-xa11y versions independently of xa11y and depends on it with a lower bound, not a pin. The launch path makes a single long App.find call, which needs the GIL fix from xa11y/xa11y#359 — the declared floor understates that until a release carrying it exists. See RELEASING.md.

License

MIT

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

pytest_xa11y-0.1.0.tar.gz (47.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

pytest_xa11y-0.1.0-py3-none-any.whl (36.9 kB view details)

Uploaded Python 3

File details

Details for the file pytest_xa11y-0.1.0.tar.gz.

File metadata

  • Download URL: pytest_xa11y-0.1.0.tar.gz
  • Upload date:
  • Size: 47.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for pytest_xa11y-0.1.0.tar.gz
Algorithm Hash digest
SHA256 fe1c961fc484ad7e7bb957130dffb47dad217bfc0a4e60a7f02009a1fa270bcd
MD5 33f441fb512771f59d58be145f21f380
BLAKE2b-256 2d86ca3d6ccdae4ed0e35c24b1b82dd015c6777d8407d203f72e9db099febb69

See more details on using hashes here.

Provenance

The following attestation bundles were made for pytest_xa11y-0.1.0.tar.gz:

Publisher: publish-pytest-xa11y.yml on xa11y/xa11y

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file pytest_xa11y-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: pytest_xa11y-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 36.9 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for pytest_xa11y-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 da841a43a10c0d305c7b78b31a8feb3536972a358bb5b68031d917b056725811
MD5 5e87380e07fc5ad08f9f1b1d44134982
BLAKE2b-256 5bd3392d6883e5ac5be44dd6e273cd2386ea2bfdfafef3b4c0273ee11aaf0bb8

See more details on using hashes here.

Provenance

The following attestation bundles were made for pytest_xa11y-0.1.0-py3-none-any.whl:

Publisher: publish-pytest-xa11y.yml on xa11y/xa11y

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page