PySide6 実践講座 #7 テスト — pytest-qt と層別テスト戦略

読了 5分

「GUI アプリはテストが難しい」という言葉は半分だけ正しいです。難しいのは画面を通したテストであり、うまく分かれたアプリは画面をほとんど通さずに大部分を検証できます。第 1 回から守ってきた 3 層構造が、この回で利子を払ってくれます。層ごとにテストの方法が違い、下の層ほど簡単にたくさん、上の層ほど難しく少なく、が原則です。

第 1 層 — core のテストはただの pytest です #

core には PySide6 のインポートがないので、Python テストシリーズで学んだ pytest がそのまま適用できます。第 4 回の統計関数が良い例です。

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

from daily.core.stats import completion_rate, current_streak


def test_今日チェック済みなら今日から連続():
    checked = {"2026-09-28", "2026-09-29", "2026-09-30"}
    assert current_streak(checked, today=date(2026, 9, 30)) == 3


def test_今日まだなら昨日までで計算():
    checked = {"2026-09-28", "2026-09-29"}
    assert current_streak(checked, today=date(2026, 9, 30)) == 2


def test_途中に穴があればそこで切れる():
    checked = {"2026-09-27", "2026-09-29", "2026-09-30"}
    assert current_streak(checked, today=date(2026, 9, 30)) == 2


def test_達成率は期間全体に対する割合():
    checked = {"2026-09-29", "2026-09-30"}
    rate = completion_rate(checked, date(2026, 9, 27), date(2026, 9, 30))
    assert rate == 0.5

current_streak が today を引数で受ける設計にしたことが、ここで回収されます。関数の中で date.today() を呼ぶ設計だったら、日付を固定する別の仕掛けが必要だったはずです。時間を引数で受け取る純粋関数が、テストのコストを最小にします。「今日未チェックなら連続は切れたことになるのか?」という第 4 回のポリシーの疑問が、2 番目のテストとして文書化されている点にも注目してください。

第 2 層 — リポジトリのテストは一時 DB ひとつで済みます #

データ層は本物の SQLite でテストします。SQLite はファイルひとつの DB なので、pytest の tmp_path フィクスチャでテストごとにまっさらな DB を作るコストは実質ゼロです。

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_習慣の追加と一覧(repo):
    repo.add_habit("運動")
    habits = repo.active_habits()
    assert [h.name for h in habits] == ["運動"]


def test_同じ日の重複チェックは一度だけ(repo):
    habit_id = repo.add_habit("読書")
    repo.set_checked(habit_id, date(2026, 9, 30), True)
    repo.set_checked(habit_id, date(2026, 9, 30), True)   # 重複
    assert repo.checked_days(habit_id) == {"2026-09-30"}


def test_アーカイブした習慣は一覧から消える(repo):
    habit_id = repo.add_habit("瞑想")
    repo.archive_habit(habit_id)
    assert repo.active_habits() == []

第 2 回でリポジトリのコンストラクタがパスを受けるようにしたこと(依存性注入)が、このフィクスチャ 3 行の根拠でした。スキーマ制約(複合主キーの重複遮断)が実際に機能するかを、モックなしの本物の DB で確認できるのがこの層のテストの価値です。

第 3 層 — Qt が必要なテストは pytest-qt と qtbot #

モデルとウィジェットは Qt オブジェクトなので、QApplication がなければ生きられません。この面倒な準備を肩代わりしてくれるのが pytest-qt プラグインです。テスト関数で qtbot フィクスチャを受け取るだけで、QApplication の生成と後始末、イベント処理をプラグインが管理します。

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("運動")
    return TodayModel(repo)


def test_モデルは習慣の数だけ行を持つ(qtbot, tmp_path):
    model = make_model(tmp_path)
    assert model.rowCount() == 1
    index = model.index(0, 0)
    assert model.data(index, Qt.ItemDataRole.DisplayRole) == "運動"


def test_チェックのトグルが_DB_まで届く(qtbot, tmp_path):
    model = make_model(tmp_path)
    index = model.index(0, 0)

    with qtbot.waitSignal(model.dataChanged):     # シグナルの発火を検証
        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 は、ブロック内の操作がそのシグナルを実際に放出するかを検証します。第 3 回で「setData は dataChanged を放つ」と決めた契約が、テストとして固定される瞬間です。モデルのテストはビューなしでモデルの契約(rowCount、data、setData、シグナル)だけを検証するのがコツです。ビューがなくても、モデル/ビュー構造の大部分はこのレベルで捕まえられます。

ウィジェットの操作まで確認したいなら、qtbot で実際のクリックを送れます。

tests/test_today_page.py
def test_追加ボタンがダイアログを開く(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)                          # 後始末を qtbot に委任

    monkeypatch.setattr(QInputDialog, "getText",
                        staticmethod(lambda *a, **k: ("ストレッチ", True)))
    qtbot.mouseClick(page.add_button, Qt.MouseButton.LeftButton)

    assert model.rowCount() == 2

モーダルダイアログはテストを止めてしまうので、monkeypatch で差し替えるのが定石です。こうしたウィジェットのテストは作成・維持のコストが上の 2 層より大きいので、核心のフローいくつかだけに使います。「テストの数は core > リポジトリ > モデル > ウィジェット」というピラミッドが健全な配分です。

CI で画面なしに回す #

Qt のテストはディスプレイを要求するので、画面のない CI では対策が必要です。Linux ランナーの標準は 2 つ。仮想ディスプレイ(xvfb)をかぶせるか、より簡単には環境変数 QT_QPA_PLATFORM=offscreen で Qt を画面なしモードで動かすことです。第 8 回の GitHub Actions ワークフローにこの 1 行が入ります。

まとめ #

  • テスト戦略は層構造に従います。Qt のない core は普通の pytest で最も多く、リポジトリは tmp_path の本物の SQLite で、Qt の層は pytest-qt で少なく。
  • 時間を引数で受ける純粋関数の設計がテストのコストを最小化し、ポリシーの疑問がテストケースとして文書化されます。
  • qtbot は QApplication の管理を肩代わりし、waitSignal でシグナルの契約を検証します。モデルのテストはビューなしで契約だけを確認します。
  • モーダルダイアログは monkeypatch で差し替え、ウィジェット操作のテストは核心フローだけに惜しんで使います。
  • CI では QT_QPA_PLATFORM=offscreen で画面なしに回します。次回、最終回でこのワークフローを含む配布の仕上げを扱います。
X