Writing Your First Test

A worked walkthrough of everything in nvda-addon-testkit, using examples/demo-addon/ — the same add-on this kit tests itself against — as the add-on under test. By the end you will have run one real end-to-end test against a real NVDA.

Windows only: NVDA cannot run headless on Linux or macOS. See Installation for CI, where the runner provides Windows for you.

1. Install and configure

pip install nvda-addon-testkit

Point the plugin at your add-on’s built bundle in pyproject.toml:

[tool.nvda-testkit]
addon-bundle = "examples/demo-addon.nvda-addon"

addon-bundle is a glob, not a literal path — it can match a version number your build script stamps into the filename. The addon_bundle fixture fails loudly if the pattern matches zero or more than one file, rather than guessing.

2. Build the bundle

The demo add-on builds with a small zip script:

#!/usr/bin/env python3
"""Zip the demo add-on into examples/demo-addon.nvda-addon."""

from __future__ import annotations

import zipfile
from pathlib import Path

SOURCE = Path(__file__).resolve().parent
BUNDLE = SOURCE.parent / "demo-addon.nvda-addon"


def build() -> Path:
    files = sorted(
        path
        for path in SOURCE.rglob("*")
        if path.is_file()
        and path.name != "build.py"
        and "__pycache__" not in path.parts
        and path.suffix != ".pyc"
    )
    with zipfile.ZipFile(BUNDLE, "w", compression=zipfile.ZIP_DEFLATED) as archive:
        for path in files:
            archive.write(path, path.relative_to(SOURCE).as_posix())
    print(f"wrote {BUNDLE}")
    return BUNDLE


if __name__ == "__main__":
    build()

Use whatever already builds your own add-on instead — scons dist, a Makefile target, this same script. The testkit only cares that a .nvda-addon lands somewhere the addon-bundle pattern can glob:

python examples/demo-addon/build.py

3. Look at what you’re testing

The demo add-on is deliberately boring — one gesture, one spoken phrase, one log line at startup:

"""A deliberately boring add-on: it speaks a known phrase on a known gesture,
and writes a known line to the log at startup. That is enough surface for the
kit's own end-to-end tests to assert against.
"""

from typing import ClassVar

import globalPluginHandler
import ui
from core import postNvdaStartup
from logHandler import log

STARTUP_MESSAGE = "testkit demo add-on loaded"
SPOKEN_PHRASE = "testkit demo says hello"


class GlobalPlugin(globalPluginHandler.GlobalPlugin):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        postNvdaStartup.register(self._logStartup)

    def _logStartup(self):
        log.info(STARTUP_MESSAGE)

    def script_sayHello(self, gesture):
        ui.message(SPOKEN_PHRASE)

    # Read by NVDA's ScriptableObject via mangled-name getattr, not a direct reference.
    __gestures: ClassVar[dict[str, str]] = {"kb:NVDA+shift+control+d": "sayHello"}  # NOSONAR(S4487)

Pressing NVDA+shift+control+d speaks "testkit demo says hello". Startup logs "testkit demo add-on loaded". That is the whole contract a test needs to exercise.

4. Write the test

Create tests_e2e/test_first.py:

def test_demo_addon_announces_itself(nvda, addon_under_test):
    before = nvda.speech.index()
    nvda.keys.press("NVDA+shift+control+d")
    found = nvda.speech.wait_for("testkit demo says hello", timeout=10, since=before)
    assert "testkit demo says hello" in found.text
    nvda.log.assert_no_errors()

What each fixture does:

Fixture Role in this test

nvda

the connected client — reset between tests, so each test starts clean

addon_under_test

installs addon_bundle and restarts NVDA, so the add-on is ENABLED before the test body runs

nvda.speech.index() before the gesture matters: wait_for only sees speech produced after the index it was given, so capturing it after the press would race the add-on and could miss the phrase entirely. See nvda.speech for the full namespace.

5. Run it

pytest tests_e2e/test_first.py -v

A failure here is expected the first few times — see Troubleshooting for what a timeout, a crash, or a mismatched assertion actually looks like and what it means.

Next steps

  • Fixtures and the rest of Guide cover every namespace this test touched, plus nvda.addons, nvda.config, and nvda.braille.

  • Example Project documents the full demo add-on as a standing reference, including the two-phase install test.

  • Configuring CI gets this same test running on every pull request.