mcpField
P
pyqt-testing

pyqt-testing

github.com/codeatcode测试与质量2743 次安装今天更新
代码仓库

关于 pyqt-testing

这是一份使用 pytest-qt 测试 Qt 应用的指南,面向编写 Python 桌面应用测试的开发者。内容涵盖控件交互、信号、线程及模型测试,并介绍将业务逻辑与界面分离的测试方法。

主要功能

  1. 01使用 qtbot 与控件交互
  2. 02等待信号和窗口状态
  3. 03测试对话框、模型及线程
  4. 04通过 Presenter 分离业务逻辑
  5. 05断言精确控件状态并使用截图
  6. 06限定变异测试范围

适用场景

  1. 01验证按钮、输入框和复选框行为
  2. 02测试异步信号及工作线程
  3. 03在不启动 QApplication 时测试业务逻辑
  4. 04检查界面状态和视觉回归

安装

复制下面的命令,在终端中运行。

$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 测试逻辑:Presenter 模式

测试 QWidget 槽中的业务逻辑需要 QApplication、事件循环和控件相关的处理。将决策逻辑提取到一个普通的 presenter 类中,并让槽函数仅通过单行委托来呈现 presenter 的状态。模型也遵循同一原则(见上文的模型/视图测试):让 QAbstractItemModel 子类保持精简,仅封装普通数据。

pytest 只会在测试请求固件时才实例化固件,因此,不导入任何 Qt 模块且从不请求 qtbot 或 qapp 的 presenter 单元测试,完全不会启动 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()),无需验证规则:presenter 单元测试已覆盖这些规则。

Qt 应用的变异测试

变异测试会修改源代码行并重新运行测试套件;如果某个变异存活,说明没有断言发现此更改。对于 Qt 应用,只对非 UI 逻辑(上文的 presenters 和 models)进行变异,并排除控件和槽代码。

  • 将运行范围限制在 presenter/model 模块:使用 [tool.mutmut] 中的 only_mutate = ["myapp/presenters.py"]、通过 do_not_mutate 排除控件模块,或在控件类上添加 # pragma: no mutate。
  • 槽函数体不适合进行变异测试:mutmut 会在 fork 的工作进程中针对每个变异重新运行已覆盖的测试,因此槽函数中的变异每次都要付出完整 UI 生命周期的成本。启动 QApplication、映射/显示窗口(在 X11 上是异步的)、每次 qtbot.wait* 调用都耗尽其超时预算、控件销毁;数百个 UI 测试会让数千个变异的成本成倍增长。
  • 槽函数变异还会产生大量噪声:标签文本、工具提示和边距都只是装饰内容,本不应被断言,因此会作为等价变异存活并掩盖真正的测试缺口。
  • Qt 特有的 fork 隐患:qapp 固件会在 pytest 进程中保留一个正在运行的 QApplication,每个 fork 出来的变异工作进程都会继承它。如果变异测试运行卡住或发生段错误,请将 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;lambda 形式下,回调返回 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:可用于审查或回归比较的视觉预言。它无法断言 presenter 状态或模型行;即使像素检查通过,逻辑仍可能存在问题。来源:https://pytest-qt.readthedocs.io/en/latest/reference.html#pytestqt.qtbot.QtBot.screenshot

参考资料

评论

支持 Markdown