mcpField
P
pyqt-testing

pyqt-testing

github.com/codeatcodeテスト・品質2743 回インストール今日更新
リポジトリ

pyqt-testing について

pytest-qt で Qt アプリをテストするためのガイドで、Python デスクトップアプリのテストを書く開発者を対象としています。ウィジェット操作、シグナル、スレッド、モデルに加え、テスト時に業務ロジックを UI から分離する方法を扱います。

主な機能

  1. 01qtbot によるウィジェット操作
  2. 02シグナルとウィンドウ状態の待機
  3. 03ダイアログ、モデル、スレッドのテスト
  4. 04Presenter による業務ロジックの分離
  5. 05ウィジェット状態の厳密な検証とスクリーンショット
  6. 06ミューテーションテスト範囲の限定

活用シーン

  1. 01ボタン、入力欄、チェックボックスの動作確認
  2. 02非同期シグナルとワーカースレッドのテスト
  3. 03QApplication を起動せず業務ロジックをテスト
  4. 04UI 状態と視覚的な回帰の確認

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/codeatcode/oss-ai-skills --skill pyqt-testing

インストール後、Claude Code で/pyqt-testingを入力して読み込まれたか確認します。

詳細

PyQt のテスト — pytest-qt

pytest-qt を使用して Qt アプリケーションをテストするための包括的なガイドです。

インストール

pip install pytest-qt

qtbot フィクスチャ

qtbot フィクスチャは、Qt ウィジェットを操作するためのメソッドを提供します。

import pytest
from PySide6.QtWidgets import QApplication, QPushButton, QLabel
from PySide6.QtCore import Qt

def test_button_click(qtbot):
    """Test button click updates label."""
    button = QPushButton("Click Me")
    label = QLabel("Before")
    
    # Register widgets for cleanup
    qtbot.addWidget(button)
    qtbot.addWidget(label)
    
    def on_click():
        label.setText("After")
    
    button.clicked.connect(on_click)
    
    # Simulate click
    qtbot.mouseClick(button, Qt.MouseButton.LeftButton)
    
    assert label.text() == "After"

def test_key_press(qtbot):
    """Test keyboard input."""
    from PySide6.QtWidgets import QLineEdit
    
    line_edit = QLineEdit()
    qtbot.addWidget(line_edit)
    
    # Type text
    qtbot.keyClicks(line_edit, "Hello World")
    
    assert line_edit.text() == "Hello World"

マウスとキーボードのシミュレーション

from PySide6.QtCore import Qt
from PySide6.QtWidgets import QPushButton, QLineEdit, QCheckBox

def test_mouse_buttons(qtbot):
    """Test different mouse buttons."""
    button = QPushButton("Test")
    qtbot.addWidget(button)
    
    clicks = []
    button.clicked.connect(lambda: clicks.append("left"))
    
    # Left click
    qtbot.mouseClick(button, Qt.MouseButton.LeftButton)
    
    # Right click
    qtbot.mouseClick(button, Qt.MouseButton.RightButton)
    
    # Double click
    qtbot.mouseDClick(button, Qt.MouseButton.LeftButton)
    
    assert clicks == ["left"]

def test_keyboard_modifiers(qtbot):
    """Test keyboard with modifiers."""
    line_edit = QLineEdit()
    qtbot.addWidget(line_edit)
    
    line_edit.setFocus()
    
    # Type with Ctrl held
    qtbot.keyClicks(line_edit, "a", Qt.KeyboardModifier.ControlModifier)
    
    # Press specific key
    qtbot.keyPress(line_edit, Qt.Key.Key_Return)
    qtbot.keyRelease(line_edit, Qt.Key.Key_Enter)

def test_checkbox_toggle(qtbot):
    """Test checkbox interaction."""
    checkbox = QCheckBox("Test")
    qtbot.addWidget(checkbox)
    
    # Click to check
    qtbot.mouseClick(checkbox, Qt.MouseButton.LeftButton)
    assert checkbox.isChecked()
    
    # Click to uncheck
    qtbot.mouseClick(checkbox, Qt.MouseButton.LeftButton)
    assert not checkbox.isChecked()

waitSignal

シグナルが発行されるまで待機します。

from PySide6.QtCore import QThread, Signal, QTimer

def test_wait_signal(qtbot):
    """Test waiting for signal."""
    
    class Worker(QThread):
        finished = Signal(str)
        
        def run(self):
            import time
            time.sleep(0.1)
            self.finished.emit("Done")
    
    worker = Worker()
    
    # Wait for signal with timeout
    with qtbot.waitSignal(worker.finished, timeout=1000) as blocker:
        worker.start()
    
    # Check signal argument
    assert blocker.args == ["Done"]

def test_wait_signal_raising(qtbot):
    """raising is a bool: raise AssertionError on timeout instead of returning silently."""
    timer = QTimer()
    timer.setInterval(100)

    with qtbot.waitSignal(timer.timeout, timeout=500, raising=True):
        timer.start()

    timer.stop()

def test_wait_signals_any(qtbot):
    """Wait for any of multiple signals."""
    timer1 = QTimer()
    timer2 = QTimer()
    
    timer1.setInterval(200)
    timer2.setInterval(100)
    
    # Returns when either signal fires
    with qtbot.waitSignals([timer1.timeout, timer2.timeout], timeout=1000):
        timer2.start()  # This one will fire first
        timer1.start()
    
    timer1.stop()
    timer2.stop()

waitActive と waitExposed

def test_window_activation(qtbot, qapp):
    """Test window becomes active."""
    from PySide6.QtWidgets import QWidget
    
    widget = QWidget()
    qtbot.addWidget(widget)
    
    widget.show()
    
    # Wait for window to be active
    with qtbot.waitActive(widget, timeout=1000):
        qapp.setActiveWindow(widget)

def test_window_exposed(qtbot):
    """Test window is exposed (visible on screen)."""
    from PySide6.QtWidgets import QWidget
    
    widget = QWidget()
    qtbot.addWidget(widget)
    
    # Show and wait for exposure
    with qtbot.waitExposed(widget, timeout=1000):
        widget.show()

ダイアログのテスト

from PySide6.QtWidgets import QDialog, QDialogButtonBox, QVBoxLayout
from PySide6.QtCore import QTimer

def test_dialog_accept(qtbot):
    """Test dialog accepted."""
    
    class TestDialog(QDialog):
        def __init__(self):
            super().__init__()
            buttons = QDialogButtonBox(
                QDialogButtonBox.StandardButton.Ok |
                QDialogButtonBox.StandardButton.Cancel
            )
            buttons.accepted.connect(self.accept)
            buttons.rejected.connect(self.reject)
            
            layout = QVBoxLayout(self)
            layout.addWidget(buttons)
    
    dialog = TestDialog()
    
    # Find OK button
    button_box = dialog.findChild(QDialogButtonBox)
    ok_button = button_box.button(QDialogButtonBox.StandardButton.Ok)
    
    # Click OK after dialog opens
    QTimer.singleShot(100, lambda: qtbot.mouseClick(ok_button, Qt.MouseButton.LeftButton))
    
    result = dialog.exec()
    
    assert result == QDialog.DialogCode.Accepted

def test_custom_dialog_values(qtbot):
    """Test custom dialog returns values."""
    
    class InputDialog(QDialog):
        def __init__(self):
            super().__init__()
            
            from PySide6.QtWidgets import QLineEdit
            
            self.line_edit = QLineEdit()
            self.line_edit.setPlaceholderText("Enter name")
            
            buttons = QDialogButtonBox(
                QDialogButtonBox.StandardButton.Ok |
                QDialogButtonBox.StandardButton.Cancel
            )
            buttons.accepted.connect(self.accept)
            buttons.rejected.connect(self.reject)
            
            layout = QVBoxLayout(self)
            layout.addWidget(self.line_edit)
            layout.addWidget(buttons)
        
        def get_value(self):
            return self.line_edit.text()
    
    dialog = InputDialog()
    qtbot.addWidget(dialog)
    
    # Enter text
    qtbot.keyClicks(dialog.line_edit, "Test Name")
    
    # Accept dialog
    button_box = dialog.findChild(QDialogButtonBox)
    ok_button = button_box.button(QDialogButtonBox.StandardButton.Ok)
    QTimer.singleShot(100, lambda: qtbot.mouseClick(ok_button, Qt.MouseButton.LeftButton))
    
    result = dialog.exec()
    
    assert result == QDialog.DialogCode.Accepted
    assert dialog.get_value() == "Test Name"

モデル/ビューのテスト

from PySide6.QtCore import QAbstractListModel, Qt, QModelIndex

def test_list_model(qtbot):
    """Test QAbstractListModel."""
    
    class SimpleModel(QAbstractListModel):
        def __init__(self, data):
            super().__init__()
            self._data = data
        
        def rowCount(self, parent=QModelIndex()):
            return len(self._data)
        
        def data(self, index, role=Qt.ItemDataRole.DisplayRole):
            if 0 <= index.row() < len(self._data):
                return self._data[index.row()]
            return None
    
    model = SimpleModel(["Item 1", "Item 2", "Item 3"])
    
    assert model.rowCount() == 3
    index = model.index(1, 0)
    assert model.data(index, Qt.ItemDataRole.DisplayRole) == "Item 2"

def test_model_updates(qtbot):
    """Test model signals data changes."""
    
    class MutableModel(QAbstractListModel):
        def __init__(self):
            super().__init__()
            self._items = []
        
        def rowCount(self, parent=QModelIndex()):
            return len(self._items)
        
        def data(self, index, role=Qt.ItemDataRole.DisplayRole):
            if 0 <= index.row() < len(self._items):
                return self._items[index.row()]
            return None
        
        def add_item(self, item):
            self.beginInsertRows(QModelIndex(), len(self._items), len(self._items))
            self._items.append(item)
            self.endInsertRows()
    
    model = MutableModel()
    
    # Wait for rowsInserted signal
    with qtbot.waitSignal(model.rowsInserted, timeout=1000):
        model.add_item("New Item")
    
    assert model.rowCount() == 1

スレッド化されたコードのテスト

from PySide6.QtCore import QThread, Signal

def test_worker_thread(qtbot):
    """Test worker thread emits signals."""
    
    class TestWorker(QThread):
        progress = Signal(int)
        
        def run(self):
            for i in range(5):
                self.progress.emit(i * 20)
    
    worker = TestWorker()
    
    # Collect signals
    signals = []
    worker.progress.connect(signals.append)
    
    # Wait for thread to finish
    with qtbot.waitSignal(worker.finished, timeout=2000):
        worker.start()
    
    assert signals == [0, 20, 40, 60, 80]

def test_thread_cancellation(qtbot):
    """Test thread can be cancelled."""
    
    class CancellableWorker(QThread):
        finished = Signal()
        
        def __init__(self):
            super().__init__()
            self._cancelled = False
        
        def run(self):
            for i in range(100):
                if self._cancelled:
                    return
                import time
                time.sleep(0.01)
            self.finished.emit()
        
        def cancel(self):
            self._cancelled = True
    
    worker = CancellableWorker()
    worker.start()
    worker.cancel()
    worker.wait(100)  # Wait with timeout
    
    # Should have finished quickly due to cancellation
    assert not worker.isRunning()

フィクスチャ

# conftest.py - Shared fixtures
import pytest
from PySide6.QtWidgets import QApplication, QMainWindow

@pytest.fixture(scope="session")
def qapp():
    """Create QApplication once per session."""
    app = QApplication.instance()
    if app is None:
        app = QApplication([])
    yield app

@pytest.fixture
def main_window(qtbot):
    """Create main window for each test."""
    window = QMainWindow()
    qtbot.addWidget(window)
    window.show()
    return window

@pytest.fixture
def temp_settings(tmp_path):
    """Create temporary QSettings."""
    from PySide6.QtCore import QSettings
    import pathlib
    
    config_file = pathlib.Path(tmp_path) / "test.ini"
    settings = QSettings(str(config_file), QSettings.Format.IniFormat)
    yield settings
    settings.clear()

ベストプラクティス

  1. 常に qtbot.addWidget() を使用する - 適切なクリーンアップを保証します
  2. 非同期操作には waitSignal を使用する - 適切なタイムアウトを設定します
  3. 実際の遅延を避ける - タイミング制御には QTimer.singleShot を使用します
  4. 実装ではなくシグナルをテストする - 動作を検証します
  5. 共通のセットアップにはフィクスチャを使用する - DRY の原則に従います
  6. テストを分離する - 各テストは独立させます

手動テスト

対話的なテストとトラブルシューティングに役立つデバッグ手法です。

# Add debug output
import logging
logging.basicConfig(level=logging.DEBUG)

# Check memory
from PySide6.QtCore import QObject
print(f"QObject children: {len(self.children())}")

# Dump widget tree
def dump_widgets(widget, indent=0):
    print(" " * indent + (widget.objectName() or widget.__class__.__name__))
    for child in widget.findChildren(QObject):
        dump_widgets(child, indent + 2)

QApplication を使わずにロジックをテストする:プレゼンターパターン

QWidget のスロット内にあるビジネスロジックをテストするには、QApplication、イベントループ、ウィジェットのセットアップが必要です。判断処理を通常のプレゼンタークラスに切り出し、スロットはプレゼンターの状態を描画するだけの1行の委譲処理にします。モデルにも同じルールが適用されます(上記の「モデル/ビューのテスト」を参照):QAbstractItemModel のサブクラスは、通常のデータを薄く包むものにします。

pytest はテストから要求された場合にのみフィクスチャをインスタンス化するため、Qt モジュールをインポートせず、qtbot や qapp も要求しないプレゼンターの単体テストは、QApplication をまったく起動せずに実行されます。出典:https://pytest-qt.readthedocs.io/en/latest/reference.html#pytestqt.plugin.qapp

# presenters.py - plain Python, no Qt imports
class CartPresenter:
    """Decisions live here; the widget only renders presenter state."""

    def __init__(self, max_items=5):
        self.items = []
        self.max_items = max_items

    def add(self, name, price):
        if len(self.items) >= self.max_items:
            raise ValueError(f"cart full: max {self.max_items} items")
        self.items.append((name, price))
        return len(self.items)

    @property
    def total(self):
        return sum(price for _, price in self.items)
# test_presenters.py - unit test with no QApplication, event loop, or qtbot
import pytest

from presenters import CartPresenter


def test_add_rejects_full_cart():
    presenter = CartPresenter(max_items=2)
    presenter.add("apple", 1.0)
    presenter.add("pear", 2.0)
    with pytest.raises(ValueError, match="cart full: max 2 items"):
        presenter.add("fig", 3.0)
    assert presenter.total == 3.0
# cart_widget.py - the slot only delegates and renders
from PySide6.QtWidgets import QLabel, QPushButton, QVBoxLayout, QWidget

from presenters import CartPresenter


class CartWidget(QWidget):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.presenter = CartPresenter()
        self.status = QLabel("empty")
        add_button = QPushButton("Add")
        add_button.clicked.connect(self._on_add_clicked)
        layout = QVBoxLayout(self)
        layout.addWidget(self.status)
        layout.addWidget(add_button)

    def _on_add_clicked(self):
        try:
            count = self.presenter.add("item", 1.0)
        except ValueError as err:
            self.status.setText(str(err))
            return
        self.status.setText(f"{count} items, total {self.presenter.total:.2f}")

このように分離すると、qtbot を使った統合テストでは描画の接続部分(widget.status.text())だけを検証し、ロジックは検証しません。ロジックはすでにプレゼンターの単体テストでカバーされているためです。

Qt アプリのミューテーションテスト

ミューテーションテストではソースコードの行を変更してテストスイートを再実行します。ミュータントが生き残った場合、その変更に気づくアサーションがなかったことを意味します。Qt アプリでは、UI 以外のロジック(上記のプレゼンターとモデル)のみを変更対象とし、ウィジェットやスロットのコードは対象外にします。

  • 実行対象をプレゼンター/モデルのモジュールに限定します:[tool.mutmut] で only_mutate = ["myapp/presenters.py"] を指定する、ウィジェットのモジュールを do_not_mutate に指定する、またはウィジェットクラスに # pragma: no mutate を記述します。
  • スロットの本体はミューテーションテストの実行に適しません。mutmut はフォークされたワーカーで、対象となるテストをミュータントごとに再実行するため、スロット内のミュータントは毎回 UI のライフサイクル全体にかかるコストを伴います。QApplication の起動、ウィンドウのマッピング/表示(X11 では非同期)、タイムアウト時間を消費する qtbot.wait* 呼び出し、ウィジェットの破棄が発生します。数百件の UI テストがあると、このコストは数千件のミュータントにわたって累積します。
  • スロットの変更はノイズにもなります。ラベルのテキスト、ツールチップ、マージンは装飾的なもので、これらをアサートするべきではありません。そのため、同等ミュータントとして生き残り、実際のギャップを見つけにくくします。
  • Qt 固有のフォークに関する注意点:qapp フィクスチャは pytest プロセス内に起動中の QApplication を残し、フォークされた各ミュータントワーカーはそれを引き継ぎます。ミュータントの実行がハングまたはセグメンテーションフォールトを起こす場合は、mutmut を process_isolation = "forkserver" に切り替えてください。

ツールの仕組み、設定リファレンス、同等ミュータントのトリアージについては、frameworks/pytest/references/mutation-testing.md を参照してください。

厳密な状態アサーションとスクリーンショットによるオラクル

動作を表すウィジェット/モデルの状態を厳密にアサートします。スクリーンショットは視覚的なオラクルであり、ロジックのオラクルではありません。

  • アクセサーで状態を読み取り、値を厳密にアサートします:line_edit.text()、checkbox.isChecked()、spin.value()、combo.currentText()、model.data(model.index(0, 0), Qt.ItemDataRole.DisplayRole)。厳密な等価性は、is not Noneや真偽値のチェックより優れています。
  • 入力処理自体がテスト対象でない場合は、ウィジェットのセッターを使って状態に到達させます:pytest-qt のドキュメントによると、QComboBox.setCurrentText、QLineEdit.setTextなどのウィジェットメソッドはユーザー操作と同じ効果があり、生のQTest形式の呼び出しより信頼性が高くなります。入力処理自体をテストする場合は、qtbot.mouseClick/qtbot.keyClicksを使用してください。出典:https://pytest-qt.readthedocs.io/en/latest/reference.html
  • 状態が非同期に更新される場合(ワーカースレッド、キューイングされたシグナル)は、qtbot.waitUntil(callback, timeout=5000)(v2.0以降)でポーリングします。アサーション形式では、状態が成立するまでコールバックがAssertionErrorを発生させます。ラムダ形式ではTrue/Falseを返します(それ以外の戻り値ではValueErrorが発生します)。出典:https://pytest-qt.readthedocs.io/en/latest/reference.html#pytestqt.qtbot.QtBot.waitUntil
def test_async_status(qtbot):
    widget = StatusWidget()
    qtbot.addWidget(widget)

    widget.start_job()  # worker thread updates the label when done

    qtbot.waitUntil(lambda: widget.status.text() == "done", timeout=2000)
    assert widget.status.text() == "done"
    assert widget.presenter.ok_count == 1

qtbot.screenshot(widget)(v4.1以降)は、pytestのtmp_pathにPNGを保存し、そのpathlib.Pathを返します。レビューやリグレッション比較のための視覚的なオラクルです。プレゼンターの状態やモデルの行をアサートすることはできません。ピクセルが一致していても、ロジックが壊れている場合があります。出典:https://pytest-qt.readthedocs.io/en/latest/reference.html#pytestqt.qtbot.QtBot.screenshot

参考資料

コメント