Skip to content

Testing

photo-tagger uses pytest with a small set of plugins configured up front, so a plain uv run pytest already runs the suite with branch coverage, parallelism, and randomized order. This page explains how to run the tests and what that configured setup gives you.

Running the suite

Run the full test suite from the project root:

uv run pytest

To run a single test by name, pass a substring match to -k. pytest selects every test whose name contains the value:

uv run pytest -k test_name

Tip

While iterating on one change, run only the test you care about with -k. It skips the rest of the suite (and the parallel startup cost), so the feedback loop stays fast. Run the full uv run pytest again before you commit.

What the configured setup does

The plugins below are wired into the default pytest run, so you get their behavior without extra flags.

Plugin What it adds
pytest-cov Branch coverage, with term-missing output and reports/coverage.xml
pytest-xdist Parallel execution across CPUs via --numprocesses=logical
pytest-random-order Randomized test order each run
inline-snapshot Inline snapshot assertions stored next to the test code

Branch coverage

Coverage runs in branch mode through pytest-cov. The terminal report uses term-missing, so it lists the line (and branch) numbers that were not exercised, and a machine-readable report is written to reports/coverage.xml for tooling such as SonarCloud to pick up.

Parallelism

pytest-xdist runs tests in parallel with --numprocesses=logical, which spreads the suite across all logical CPUs. This is why test order should never be assumed: tests must not depend on each other or on shared mutable state.

Randomized order

pytest-random-order shuffles the test order on every run. This surfaces hidden ordering dependencies between tests, where one test only passes because an earlier one left some state behind.

Inline snapshots

inline-snapshot keeps expected values inline in the test source rather than in separate fixture files. When an expected value changes intentionally, the snapshot can be updated in place instead of being hand-edited.

Integration tests

Integration tests are marked with the integration marker and are deselected by default, so they do not run during a normal uv run pytest. Run them explicitly with -m:

uv run pytest -m integration

Note

Integration tests can reach out to external dependencies (such as a running model server or the exiftool binary), which is why they are kept out of the default run. See Installation for the required tools.

GUI tests

The optional desktop GUI is tested in two layers so the suite stays green whether or not the gui extra is installed:

  • tests/test_gui_state.py covers the Qt-free logic in gui_state.py. It imports no PySide6, so it always runs and counts toward coverage like any other module.
  • tests/test_gui.py exercises the real window and worker. It calls pytest.importorskip("PySide6"), so it is skipped entirely when the extra is absent (the default CI run), and uses Qt's offscreen platform to build widgets without a display server.

To run the GUI tests, sync the extra and point Qt at the offscreen platform:

uv sync --extra gui --group test
QT_QPA_PLATFORM=offscreen uv run pytest tests/test_gui.py

gui.py itself is the Qt event-loop shell. The offscreen GUI tests do drive its widgets and workers, but the event loop cannot be measured for coverage and shiboken's runtime attributes confuse the static tools, so the whole module is excluded from coverage via [tool.coverage.run].omit in pyproject.toml. Keep testable logic in gui_state.py (covered) rather than gui.py (omitted). See Code quality for why gui.py is also excluded from the type checker and static analyzer.

Coverage target

Aim for 90%+ coverage. Add or update tests whenever you change behavior so the suite keeps tracking what the code actually does. For the lint, type-check, and analysis steps that run alongside the tests, see Code quality.