Testing
A practical test strategy for Linux desktop apps: unit, integration, UI, accessibility, performance, packaging, headless Wayland, and CI release gates.
Desktop apps are harder to test than libraries: they have a main loop, a display server, a session bus, a filesystem, and a user. The answer is not “test everything through the UI”. It is a pyramid with fast tests at the bottom and a small number of high-value end-to-end tests at the top.
The test pyramid
Section titled “The test pyramid”| Layer | What it proves | Speed | Tools |
|---|---|---|---|
| Unit | Pure logic, parsers, state machines | Milliseconds | pytest, GLib testing, QtTest, cargo test |
| Integration | Storage, D-Bus, portals, async flows | Seconds | GTestDBus, dbus-run-session, temp XDG dirs |
| UI | Keyboard flows, layout, accessibility | Seconds–minutes | dogtail, AT-SPI, Qt Quick Test |
| Packaging | The artifact users install | Minutes | flatpak-builder, lintian, snapcraft |
| Manual | Real desktop, real hardware, Orca | Hours | A human |
Most of your tests should be unit and integration tests. UI tests are expensive and flaky; use them for the critical paths, not every button.
Unit tests
Section titled “Unit tests”Python
Section titled “Python”from notes.domain.note import Notefrom notes.application.notes import NotesService
class MemoryRepository: def __init__(self): self.notes = {}
def list(self): return list(self.notes.values())
def save(self, note): self.notes[note.id] = note
def delete(self, note_id): self.notes.pop(note_id, None)
def test_create_trims_title(): service = NotesService(MemoryRepository()) note = service.create(" Hello ", "Body") assert note.title == "Hello"
def test_empty_title_falls_back(): service = NotesService(MemoryRepository()) note = service.create(" ", "Body") assert note.title == "Untitled"Run with pytest -q. Use fixtures for temp directories and fake clocks. Do not touch the real home directory.
C / GLib
Section titled “C / GLib”Use GTest:
#include <glib.h>#include "note.h"
static void test_note_title(void) { Note *note = note_new(" Hello ", "Body"); g_assert_cmpstr(note_get_title(note), ==, "Hello"); note_free(note);}
int main(int argc, char **argv) { g_test_init(&argc, &argv, NULL); g_test_add_func("/note/title", test_note_title); return g_test_run();}Use QtTest for C++ and TestCase for QML:
#include <QtTest>#include "note.h"
class TestNote : public QObject { Q_OBJECTprivate slots: void trimsTitle() { Note note(" Hello ", "Body"); QCOMPARE(note.title(), QString("Hello")); }};
QTEST_MAIN(TestNote)#include "tst_note.moc"#[test]fn trims_title() { let note = notes::Note::new(" Hello ", "Body"); assert_eq!(note.title, "Hello");}Use insta for snapshots, proptest for parsers, and trybuild for macro/API contracts.
Integration tests
Section titled “Integration tests”Integration tests exercise the seams: storage, D-Bus, portals, and async work. Keep them isolated from the user’s real session.
Isolate XDG directories
Section titled “Isolate XDG directories”import osfrom pathlib import Pathimport pytest
@pytest.fixturedef xdg(tmp_path, monkeypatch): config = tmp_path / "config" data = tmp_path / "data" state = tmp_path / "state" cache = tmp_path / "cache" runtime = tmp_path / "runtime" for path in (config, data, state, cache, runtime): path.mkdir() runtime.chmod(0o700) monkeypatch.setenv("XDG_CONFIG_HOME", str(config)) monkeypatch.setenv("XDG_DATA_HOME", str(data)) monkeypatch.setenv("XDG_STATE_HOME", str(state)) monkeypatch.setenv("XDG_CACHE_HOME", str(cache)) monkeypatch.setenv("XDG_RUNTIME_DIR", str(runtime)) return tmp_pathIsolate D-Bus
Section titled “Isolate D-Bus”dbus-run-session -- pytest -qFor GLib apps, GTestDBus starts a private session bus. For portal tests, run a mock portal or use the real one in a nested session.
Test async work
Section titled “Test async work”- Use a fake clock or
GLib.MainLoopwith a timeout. - Assert cancellation: cancel the task and verify no stale state is applied.
- Assert error paths: permission denied, disk full, network timeout.
- Do not sleep arbitrarily; wait for a condition with a deadline.
UI tests
Section titled “UI tests”UI tests should verify the user-visible contract, not implementation details.
dogtail (AT-SPI)
Section titled “dogtail (AT-SPI)”dogtail drives the accessibility tree, which makes it closer to a real user than pixel clicking.
from dogtail.tree import root
def test_create_note(): app = root.application("io.github.yourname.Notes") app.button("New note").click() assert app.child("Untitled note").shows()Advantages: works with keyboard and accessibility semantics; catches missing labels. Limitations: slower, depends on AT-SPI, can be flaky if the tree changes.
Qt Quick Test
Section titled “Qt Quick Test”import QtQuickimport QtTest
TestCase { name: "Notes"
function test_createNote() { let model = createNotesModel() model.addNote("Hello") compare(model.count, 1) }}Run with qmltestrunner -input tests.
Screenshot tests
Section titled “Screenshot tests”Screenshot tests catch layout regressions that unit tests miss.
- Run the app in a deterministic environment (fixed theme, font, scale, window size).
- Capture with the compositor’s screenshot tool, not by reading the framebuffer.
- Compare with a perceptual diff, not pixel equality.
- Store baselines per theme and scale.
- Review diffs manually; do not auto-accept.
On CI, a headless Wayland compositor (weston --backend=headless) or Xvfb is usually easier than a full desktop session. Match the display backend to the behavior you are testing.
Accessibility tests
Section titled “Accessibility tests”- Run the critical flows with Orca enabled.
- Use Accerciser to inspect names, roles, states, and relations.
- Use dogtail to assert accessible names and roles.
- Test keyboard-only navigation, focus order, and dialog focus traps.
- Test at 200% text scaling and in high-contrast mode.
- Add accessibility checks to the release checklist, not just the test suite.
Headless Wayland
Section titled “Headless Wayland”A headless compositor gives you a real Wayland session without a physical display:
export XDG_RUNTIME_DIR=$(mktemp -d)chmod 700 "$XDG_RUNTIME_DIR"weston --backend=headless --socket=wayland-test &export WAYLAND_DISPLAY=wayland-testexport GDK_BACKEND=waylanddbus-run-session -- ./build/notesweston is the simplest starting point. For GNOME-specific behavior, use Mutter Devkit or a nested GNOME session. grim is wlroots-only; on GNOME use the Shell or portal screenshot path instead.
Sanitizers, fuzzing, and reproducibility
Section titled “Sanitizers, fuzzing, and reproducibility”- Build C/C++ with ASan/UBSan in CI; run the parser and protocol tests under
cargo-fuzzor AFL++. - Use
SOURCE_DATE_EPOCHand a reproducible build environment; compare artifacts withdiffoscopeorreprotest. - Generate an SBOM and run
cargo audit/cargo deny,pip-audit, or the equivalent for your ecosystem. - Test the upgrade path and the uninstall path, not just a fresh install.
Packaging tests
Section titled “Packaging tests”The artifact is part of the product. Test it.
# Flatpakflatpak-builder --user --install --force-clean build-dir io.github.yourname.Notes.ymlflatpak run io.github.yourname.Notesflatpak info --show-permissions io.github.yourname.Notes
# Metadatadesktop-file-validate data/io.github.yourname.Notes.desktopappstreamcli validate --no-net data/io.github.yourname.Notes.metainfo.xmlflatpak remote-add --if-not-exists --user flathub https://dl.flathub.org/repo/flathub.flatpakrepoflatpak install --user -y flathub org.flatpak.Builderflatpak run --command=flatpak-builder-lint org.flatpak.Builder manifest io.github.yourname.Notes.yml
# debdpkg-buildpackage -b -us -uclintian ../*.changes
# Snapsnapcraft packsudo snap install --dangerous notes_0.1.0_amd64.snapsnap run notesRun the packaged app on a clean VM or container. A build that only works on your machine is not a release.
A minimal GitHub Actions workflow:
name: cion: [push, pull_request]jobs: test: runs-on: ubuntu-26.04 steps: - uses: actions/checkout@v4 - run: sudo apt-get update - run: sudo apt-get install -y python3 python3-gi gir1.2-gtk-4.0 gir1.2-adw-1 \ meson ninja-build flatpak flatpak-builder desktop-file-utils appstream \ python3-pytest - run: /usr/bin/python3 -m pytest -q - run: desktop-file-validate data/io.github.yourname.Notes.desktop - run: appstreamcli validate --no-net data/io.github.yourname.Notes.metainfo.xml flatpak: runs-on: ubuntu-26.04 steps: - uses: actions/checkout@v4 - uses: flatpak/flatpak-github-actions/flatpak-builder@v6 with: bundle: notes.flatpak manifest-path: io.github.yourname.Notes.yml cache-key: flatpak-builder-${{ hashFiles('**/*.yml', '**/*.json') }}Cache dependencies, pin the runner image, and upload the built artifact. A CI job that builds a different app than the one you ship is worse than no CI.
Flaky tests
Section titled “Flaky tests”UI tests fail for reasons unrelated to the app: focus, timing, animations, D-Bus races, and screen size. Reduce flakiness:
- Disable animations in tests.
- Fix the window size and theme.
- Wait for conditions, not fixed sleeps.
- Isolate XDG, D-Bus, and network.
- Use a fresh app instance per test.
- Retry only known-flaky tests, and track the flake rate.
- Fix the race instead of adding a longer timeout.
Coverage
Section titled “Coverage”Coverage is a smell detector, not a goal.
- Aim for high coverage of domain logic and parsers.
- Do not chase 100% on UI glue.
- Use branch coverage where available.
- Review uncovered error paths; those are where bugs live.
coverage.py,gcovr,cargo llvm-cov, andlcovall work.
Release gates
Section titled “Release gates”Before a release, require:
- ☐ Unit and integration tests pass.
- ☐ Critical UI flows pass under AT-SPI.
- ☐ Accessibility checks pass (keyboard, Orca, contrast, scaling).
- ☐ Metadata validates.
- ☐ Flatpak/deb/Snap artifacts build and launch.
- ☐ The app runs with denied permissions and explains the failure.
- ☐ The upgrade path from the previous release is tested.
- ☐ Performance budgets are checked.
- ☐ No known critical security issue is open.
Sources
Section titled “Sources”Next: Reliability & Logging — what happens when the disk is full, the network is down, and the compositor restarts.