本文へ移動
cccskills
無料GitHub で公開

pyqt-testing

Use when testing PyQt/PySide6 applications with pytest-qt - qtbot fixture, signal/wait patterns, mouse/keyboard simulation, dialog testing, model/view testing, threaded code testing, or manual debugging techniques

インストール方法を見る

含まれるファイル(1)

  • SKILL.md17.7 KB

SKILL.md(原文)

インストールする前に、エージェントに与えられる指示の中身を確認できます。

PyQt Testing - pytest-qt

Comprehensive guide to testing Qt applications with pytest-qt.

Installation

pip install pytest-qt

qtbot Fixture

The qtbot fixture provides methods for interacting with Qt widgets:

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"

Mouse and Keyboard Simulation

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

Wait for signals to be emitted:

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 and 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()

Testing Dialogs

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"

Testing Model/View

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

Testing Threaded Code

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()

Fixtures

# 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()

Best Practices

  1. Always use qtbot.addWidget() - Ensures proper cleanup
  2. Use waitSignal for async operations - With appropriate timeouts
  3. Avoid real delays - Use QTimer.singleShot for timing
  4. Test signals, not implementation - Verify behavior
  5. Use fixtures for common setup - DRY principle
  6. Keep tests isolated - Each test should be independent

Manual Testing

Debugging techniques for interactive testing and troubleshooting:

# 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)

Testing Logic Without QApplication: the Presenter Pattern

Business logic inside QWidget slots needs a QApplication, an event loop, and widget plumbing to test. Extract decisions into a plain presenter class and keep slots as one-line delegations that only render presenter state. Models follow the same rule (see Testing Model/View above): keep QAbstractItemModel subclasses thin over plain data.

pytest instantiates fixtures only when a test requests them, so a presenter unit test that imports no Qt module and never requests qtbot or qapp runs with no QApplication at all. Source: 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}")

With this split, a qtbot integration test only asserts the rendering glue (widget.status.text()), never the rules: the presenter unit test already covers those.

Mutation Testing for Qt Apps

Mutation testing mutates source lines and re-runs the suite; a surviving mutant means no assertion noticed the change. For Qt apps, mutate only non-UI logic (the presenters and models above) and exclude widget and slot code.

  • Scope runs to presenter/model modules: [tool.mutmut] with only_mutate = ["myapp/presenters.py"], do_not_mutate for widget modules, or # pragma: no mutate on a widget class.
  • Slot bodies are hostile to mutation runs: mutmut re-runs the covered tests per mutant in forked workers, so a mutant inside a slot pays the full UI lifecycle cost every time. QApplication startup, window mapping/exposure (asynchronous on X11), every qtbot.wait* call spending its timeout budget, widget teardown; hundreds of UI tests multiply that cost across thousands of mutants.
  • Slot mutations are also noisy: label text, tooltips, and margins are cosmetic, nothing should assert them, so they survive as equivalent mutants and bury real gaps.
  • Qt-specific fork hazard: the qapp fixture leaves a live QApplication in the pytest process and every forked mutant worker inherits it. If mutant runs hang or segfault, switch mutmut to process_isolation = "forkserver".

Tool mechanics, configuration reference, and equivalent-mutant triage: see frameworks/pytest/references/mutation-testing.md.

Exact-State Assertions vs Screenshot Oracles

Assert the exact widget/model state that encodes the behavior. A screenshot is a visual oracle, never a logic oracle.

  • Read state through accessors and assert exact values: line_edit.text(), checkbox.isChecked(), spin.value(), combo.currentText(), model.data(model.index(0, 0), Qt.ItemDataRole.DisplayRole). Exact equality beats is not None and truthiness checks.
  • Reach state through widget setters when input handling is not the subject: the pytest-qt docs note that QComboBox.setCurrentText, QLineEdit.setText, and similar widget methods have the same effect as user interaction but are more reliable than the raw QTest-style calls; keep qtbot.mouseClick/qtbot.keyClicks for tests about the input handling itself. Source: https://pytest-qt.readthedocs.io/en/latest/reference.html
  • For state updated asynchronously (worker thread, queued signal), poll with qtbot.waitUntil(callback, timeout=5000) (v2.0+): in assert form the callback raises AssertionError until the state holds; in lambda form it returns True/False (any other return raises ValueError). Source: 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+) saves a PNG under pytest's tmp_path and returns its pathlib.Path: a visual oracle for review or regression comparison. It cannot assert presenter state or model rows; passing pixels can coexist with broken logic. Source: https://pytest-qt.readthedocs.io/en/latest/reference.html#pytestqt.qtbot.QtBot.screenshot

References

レビュー

まだレビューはありません。使ってみた感想をお寄せください。

同じリポジトリのスキル

概要と使いどころ

aiohttp

無料

Use when building Python async HTTP services or clients with aiohttp - web server routing, middleware, WebSocket, SSE, streaming, client sessions, pytest-aiohttp testing, or troubleshooting SSL and timeout issues

日本語の概要は準備中です。原文の説明を表示しています。

CodeAtCode/oss-ai-skills222026年10月9日 更新

ast-grep

無料

Use when doing structural code search and rewriting - ast-grep linting, refactoring, multi-language patterns

日本語の概要は準備中です。原文の説明を表示しています。

CodeAtCode/oss-ai-skills222026年10月9日 更新

Use when building GBA games with the BPCore Lua engine - entity, sprite and tilemap functions, SRAM save and load, link cable multiplayer protocol, camera and scrolling, or optimization patterns

日本語の概要は準備中です。原文の説明を表示しています。

CodeAtCode/oss-ai-skills222026年10月9日 更新

celery

無料

Use when running background tasks with Celery - worker and broker configuration (Redis, RabbitMQ), task routing by name vs queue, chains/groups/chords, retry patterns (autoretry_for, retry_backoff), acks_late semantics, failure detection, and monitoring with Flower

日本語の概要は準備中です。原文の説明を表示しています。

CodeAtCode/oss-ai-skills222026年10月9日 更新

django

無料

Use when building Django applications - security hardening, authentication and permissions, ORM optimization, PostgreSQL features, Django 6.0, migrations, testing, and ecosystem libraries

日本語の概要は準備中です。原文の説明を表示しています。

CodeAtCode/oss-ai-skills222026年10月9日 更新

Use when customizing Django Admin - save_formset, get_search_results, formsets, queryset optimization, db_index, custom URLs

日本語の概要は準備中です。原文の説明を表示しています。

CodeAtCode/oss-ai-skills222026年10月9日 更新

CodeAtCode のスキルをすべて見る

このスキルの問題を報告する