PySide6 in Practice #7 Testing: pytest-qt and a Per-Layer Strategy

4 min read

“GUI apps are hard to test” is half-true. What is hard is testing through the screen — and a well-divided app can be verified mostly without touching the screen at all. This is the part where the three-layer structure kept since part 1 pays interest. Each layer tests differently, and the principle is: the lower the layer, the easier and more numerous the tests; the higher, the harder and fewer.

Layer 1: core tests — it is just pytest #

Core has no PySide6 imports, so the pytest from the Python Testing series applies unchanged. Part 4’s statistics functions are the perfect example.

tests/test_stats.py
# tests/test_stats.py
from datetime import date

from daily.core.stats import completion_rate, current_streak


def test_streak_counts_from_today_when_checked():
    checked = {"2026-09-28", "2026-09-29", "2026-09-30"}
    assert current_streak(checked, today=date(2026, 9, 30)) == 3


def test_streak_counts_from_yesterday_when_today_unchecked():
    checked = {"2026-09-28", "2026-09-29"}
    assert current_streak(checked, today=date(2026, 9, 30)) == 2


def test_streak_breaks_at_gaps():
    checked = {"2026-09-27", "2026-09-29", "2026-09-30"}
    assert current_streak(checked, today=date(2026, 9, 30)) == 2


def test_completion_rate_over_whole_period():
    checked = {"2026-09-29", "2026-09-30"}
    rate = completion_rate(checked, date(2026, 9, 27), date(2026, 9, 30))
    assert rate == 0.5

Designing current_streak to take today as an argument gets collected here. Had it called date.today() internally, freezing time would have needed extra machinery. Pure functions that receive time as an argument minimize test cost. Notice too how part 4’s policy question — “does an unchecked today break the streak?” — is now documented as the second test.

Layer 2: repository tests — one temp database is all it takes #

The data layer is tested against real SQLite. Since SQLite is a one-file database, creating a pristine DB per test via pytest’s tmp_path fixture costs practically nothing.

tests/test_repository.py
# tests/test_repository.py
from datetime import date

import pytest

from daily.data.repository import HabitRepository


@pytest.fixture
def repo(tmp_path):
    return HabitRepository(tmp_path / "test.db")


def test_add_and_list_habits(repo):
    repo.add_habit("Exercise")
    habits = repo.active_habits()
    assert [h.name for h in habits] == ["Exercise"]


def test_duplicate_same_day_check_counts_once(repo):
    habit_id = repo.add_habit("Reading")
    repo.set_checked(habit_id, date(2026, 9, 30), True)
    repo.set_checked(habit_id, date(2026, 9, 30), True)   # duplicate
    assert repo.checked_days(habit_id) == {"2026-09-30"}


def test_archived_habits_leave_the_list(repo):
    habit_id = repo.add_habit("Meditation")
    repo.archive_habit(habit_id)
    assert repo.active_habits() == []

Part 2’s decision to have the repository constructor take a path (dependency injection) was the justification for this three-line fixture. The value of this layer’s tests is confirming schema constraints (the composite key blocking duplicates) against a real database, with no mocks.

Layer 3: tests that need Qt — pytest-qt and qtbot #

Models and widgets are Qt objects; they cannot live without a QApplication. The pytest-qt plugin takes that chore over: accept the qtbot fixture in a test and the plugin manages QApplication creation, teardown, and event processing.

tests/test_today_model.py
# tests/test_today_model.py
from datetime import date

from PySide6.QtCore import Qt

from daily.data.repository import HabitRepository
from daily.ui.today_model import TodayModel


def make_model(tmp_path) -> TodayModel:
    repo = HabitRepository(tmp_path / "t.db")
    repo.add_habit("Exercise")
    return TodayModel(repo)


def test_model_has_one_row_per_habit(qtbot, tmp_path):
    model = make_model(tmp_path)
    assert model.rowCount() == 1
    index = model.index(0, 0)
    assert model.data(index, Qt.ItemDataRole.DisplayRole) == "Exercise"


def test_check_toggle_reaches_the_db(qtbot, tmp_path):
    model = make_model(tmp_path)
    index = model.index(0, 0)

    with qtbot.waitSignal(model.dataChanged):     # assert the signal fires
        model.setData(index, Qt.CheckState.Checked.value,
                      Qt.ItemDataRole.CheckStateRole)

    assert model.data(index, Qt.ItemDataRole.CheckStateRole) == Qt.CheckState.Checked
    assert date.today().isoformat() in model._repo.checked_days(1)

qtbot.waitSignal asserts that the block’s action actually emits the signal — the moment part 3’s contract (“setData emits dataChanged”) gets frozen into a test. The knack of model testing: verify the model’s contract (rowCount, data, setData, signals) without a view. Most of a model/view structure is caught at this level, viewless.

For widget interaction, qtbot can send real clicks:

tests/test_today_page.py
def test_add_button_opens_dialog(qtbot, tmp_path, monkeypatch):
    from daily.ui.today_page import TodayPage
    from PySide6.QtWidgets import QInputDialog

    model = make_model(tmp_path)
    page = TodayPage(model)
    qtbot.addWidget(page)                          # delegate cleanup to qtbot

    monkeypatch.setattr(QInputDialog, "getText",
                        staticmethod(lambda *a, **k: ("Stretching", True)))
    qtbot.mouseClick(page.add_button, Qt.MouseButton.LeftButton)

    assert model.rowCount() == 2

Modal dialogs halt a test, so replacing them with monkeypatch is the standard move. Widget tests cost more to write and maintain than the lower layers, so spend them on a few key flows only. The healthy distribution is a pyramid: core > repository > model > widget.

Running headless in CI #

Qt tests demand a display, so headless CI needs a measure. On Linux runners the two standards are wrapping with a virtual display (xvfb) or — simpler — setting QT_QPA_PLATFORM=offscreen to run Qt displayless. That one line appears in part 8’s GitHub Actions workflow.

Summary #

  • The test strategy follows the layers: Qt-free core gets the most tests with plain pytest, the repository runs against real SQLite in tmp_path, and the Qt tier gets fewer tests via pytest-qt.
  • Pure functions taking time as an argument minimize test cost, and policy questions become documented test cases.
  • qtbot manages QApplication for you, and waitSignal verifies signal contracts. Model tests check the contract without a view.
  • Replace modal dialogs with monkeypatch, and reserve widget-interaction tests for key flows.
  • CI runs with QT_QPA_PLATFORM=offscreen. The final part covers release polish, including that workflow.
X