macuitest is a Python library for end-to-end tests of macOS apps. It finds elements by accessibility attributes, AppleScript locator, screenshot, or visible text, then clicks, types, and waits on them the way a person would. It also includes helpers for the system around the app, such as preferences, property lists, processes, and files, so a test can set up state and check results outside the app's window.
To see complete suites for real apps, go to macuitest examples.
To use macuitest, you need the following:
- macOS and Python 3.13 or later.
- Two permissions for the app you run your tests from, such as Terminal, iTerm2, or your IDE. Grant them in System Settings > Privacy & Security:
- Accessibility, for mouse and keyboard input, AppleScript elements, and every lookup on a
Screenwith anapp. - Screen & System Audio Recording, called Screen Recording before macOS 15, for screenshots, visible text, and color checks.
- Accessibility, for mouse and keyboard input, AppleScript elements, and every lookup on a
After you grant Screen Recording, quit and reopen the app you run your tests from, since macOS applies that grant only to a newly started app. Accessibility takes effect right away.
When a permission is missing, Screen lookups and screen captures raise PermissionError naming the System Settings pane. Other calls fail without that hint: an AppleScript element raises AppleScriptError, and mouse and keyboard input does nothing.
The first time your tests control System Events, macOS asks for permission. Allow it.
To install macuitest from PyPI, run one of these commands:
uv add macuitest
# or
pip install macuitestThe following script drives Finder with three kinds of elements. The screenshot step needs a PNG of your own:
from macuitest.lib.apps.application import Finder
from macuitest.lib.elements.applescript_element import MenuBarItem
from macuitest.lib.elements.ui_element import UIElement
from macuitest.lib.elements.visible_text import VisibleText
finder = Finder()
finder.activate()
# An AppleScript locator, resolved through System Events.
MenuBarItem('menu bar item "File" of menu bar 1', process="Finder").click_mouse()
# Visible text, found with Apple Vision. No screenshot to maintain.
VisibleText("New Finder Window").click_mouse()
# A screenshot of the element, found with template matching.
UIElement("screenshots/sidebar_toggle.png").wait_displayed()Each module in macuitest.lib.elements finds elements a different way:
| Module | Finds elements by | Best for |
|---|---|---|
applescript_element |
AppleScript locator, such as button "OK" of window 1 |
Apps you already script through System Events |
native_element |
Accessibility (AX) objects through pyobjc | Reading attributes and walking the accessibility tree |
visible_text |
Visible text, read with Apple Vision | Buttons, tabs, links, and banners with a text label of two or more characters |
ui_element |
A screenshot of the element | Icons and custom-drawn controls without text |
locators |
Screen classes that declare an app's elements with the kinds above |
Any app you test more than once |
VisibleText and UIElement share one API: click_mouse, double_click, right_click, hover_mouse, paste, wait_displayed, wait_vanish, and the is_visible property. Each of these methods takes a region to search, and a window-sized region is faster to search than the whole desktop.
To browse an app's accessibility attributes, use Accessibility Inspector, which comes with Xcode, or see Inspect an app. To turn them into locators, see Capture elements.
A Screen class declares an app's elements in one place, so your tests don't need to contain locators:
from macuitest.lib.elements.applescript_element import Button as ScriptButton
from macuitest.lib.elements.locators import Screen, applescript, ax, image, text
from macuitest.lib.elements.native_element import Button
KEYPAD = "group 1 of group 1 of splitter group 1 of group 1 of window 1"
class Calculator(Screen, app="Calculator"):
keypad = ax(identifier="CalculatorKeypadView")
seven = ax(identifier="Seven", kind=Button)
display = ax(identifier="StandardInputView").child(role="AXStaticText")
all_clear = text("AC", within=keypad)
mode = image()
seven_as = applescript(
f'(first button whose value of attribute "AXIdentifier" is "Seven") of {KEYPAD}',
kind=ScriptButton,
)
Calculator.seven.press()Screens work as follows:
ax()matches accessibility attributes and searches again every time you use the element, sois_visibleandwait_vanishsee a closed sheet or panel. A missing element reads as not visible, and acting on it raisesLookupError. Passkind=for actions such aspress.text()andimage()search the app's first standard window, or the frame ofwithin=. When the window is missing, minimized, or hidden, they find nothing, and waits keep polling.image()loads<name>.pngnext to the module. For example,Calculatorinapps/calculator.pyreadsapps/calculator/calculator/mode.png.- Screens are never instantiated. Read elements from the class.
For an alert or a secondary window, name the window once with window=. The screen's text, image, and ax() lookups then search only that window:
from macuitest.lib.elements.locators import Screen, ax, window
from macuitest.lib.elements.native_element import Button
class EmptyTrash(Screen, app="Finder", window=window(subrole="AXDialog")):
cancel = ax(title="Cancel", role="AXButton", kind=Button)
class Fonts(Screen, app="TextEdit", window=window(title="Fonts")):
search = ax(description="Search", role="AXButton", kind=Button)Keep these window rules in mind:
- Alerts have no title, so match them by subrole.
- Floating panels, such as TextEdit's Fonts panel, disappear from the accessibility tree while their app isn't active. Activate the app before your tests look up their elements.
captureactivates the app for you. - Sheets, such as a Save panel, sit inside their window and need no
window=. appis the process that owns the window. System prompts belong to system processes, such asSecurityAgentfor password prompts, not to the app that triggered them.
To see what ax() can find in a running app, print its accessibility tree:
python -m macuitest.locators tree TextEdit --window-title FontsEach line shows an element's role, identifier, description, title, and value, indented under its parent, then the ax() locator that finds it, when one does. Narrow the output with --role, --window-title, or --window-subrole. tree only reads, and leaves the app in the background. Some apps, such as Calculator, and floating panels show their windows only while the app is active, so pass --activate to bring the app to the front first.
Instead of writing a screen by hand, capture it from the running app:
python -m macuitest.locators capture Calculator --out apps/calculator.py --role AXButton
python -m macuitest.locators check apps/calculator.pycapture walks the app's first standard window and writes the following:
apps/calculator.py, with anax()entry for each element it can identify, else animage()entry.- A PNG of each element in
apps/calculator/calculator/, cropped with a 4 pt margin. Change the margin with--margin.
capture brings the app to the front first and crops each element from a capture of that one window. To capture an alert, panel, or secondary window, pass --window-subrole AXDialog or --window-title. The screen class is named after the app, or after the window when you pass one of those options. capture doesn't overwrite existing files unless you pass --force.
capture and tree skip identifiers that AppKit generates, such as _NS:34, because they change between launches.
Then edit the generated module: delete the entries you don't need, and switch an entry to image() where its accessibility attributes don't identify it. Identifiers that encode state, such as Calculator's Mode: basic; unitConversion: false, need a stable replacement, such as a match on the description.
check lists declared images that are missing on disk and PNGs that no element declares. It exits with status 1 when it finds either.
One settings file holds the defaults for mouse and keyboard timings, the search display, element matching and timeouts, OCR languages, and the screenshot root. To create a documented copy in your project, run the following command:
python -m macuitest.config initThe command writes macuitest.toml with every key and a comment, and sets each key that has a default to that default. Edit the keys you need and delete the rest:
[mouse]
move = 0.36 # Slower cursor moves, easier to follow in a screen recording.
[screen]
display = 1 # Search only the second display. 0 is the menu bar display.
[ocr]
languages = ["en-US", "uk-UA"]The first time you import a macuitest element or controller, macuitest reads the first of these files that it finds:
- The file named by
$MACUITEST_CONFIG. macuitest.tomlin the working directory.pyproject.tomlin the working directory, with each table under[tool.macuitest], such as[tool.macuitest.mouse].
A missing $MACUITEST_CONFIG file, an unknown key, or a bad value raises ConfigError naming the file and the key. macuitest checks a display index against the displays connected at that moment. Your code can change any setting at run time:
from macuitest.config.settings import settings
from macuitest.lib.elements.ui.monitor import monitor
settings.mouse.move = 0.1
settings.screen.search_region = monitor.displays[1] # A Region, which code can set and files can't.macuitest also reads these environment variables:
$MACUITEST_SCRsets the screenshot root forScreenshotPathBuilder, and wins overpaths.screenshots.$MACUITEST_PASSWORDgivesShellExecutor.sudothe admin password when thecom.macuitest.automationkeychain item is missing. Keep it out of config files.
To set up the project and run the checks that CI runs, use these commands:
uv sync
uv run pytest tests/unit
uv run ruff check && uv run ruff format --check
uv run ty checkUnit tests never read the real screen. A test that needs text on screen draws it into an image with the text_image fixture.
For release notes, see CHANGELOG.md.