关于 pyqt-testing
这是一份使用 pytest-qt 测试 Qt 应用的指南,面向编写 Python 桌面应用测试的开发者。内容涵盖控件交互、信号、线程及模型测试,并介绍将业务逻辑与界面分离的测试方法。
主要功能
- 01使用 qtbot 与控件交互
- 02等待信号和窗口状态
- 03测试对话框、模型及线程
- 04通过 Presenter 分离业务逻辑
- 05断言精确控件状态并使用截图
- 06限定变异测试范围
适用场景
- 01验证按钮、输入框和复选框行为
- 02测试异步信号及工作线程
- 03在不启动 QApplication 时测试业务逻辑
- 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()
最佳实践
- 始终使用 qtbot.addWidget() - 确保正确清理
- 对异步操作使用 waitSignal - 设置适当的超时时间
- 避免实际延迟 - 使用 QTimer.singleShot 控制时序
- 测试信号,而非实现 - 验证行为
- 对常见的设置使用固件 - 遵循 DRY 原则
- 让测试彼此隔离 - 每项测试都应独立
手动测试
交互式测试和故障排除的调试技巧:
# 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
参考资料
- pytest-qt:https://pytest-qt.readthedocs.io/
- Qt for Python Testing:https://doc.qt.io/qtforpython-6/
- pytest:https://docs.pytest.org/