如何测试 PyQt 按钮信号是否调用函数?
How to Test that a PyQt button signal calls a function?
我有一个 PyQt5 GUI,当我按下工具栏按钮时它会调用一个插槽。我知道它有效,因为当我 运行 GUI 时按钮本身有效。但是,我的 pytest 无法通过。
据我所知,在打补丁时,我必须在调用方法的地方打补丁,而不是在定义方法的地方打补丁。我是否错误地定义了模拟?
注意:我尝试使用python的inspect
模块来查看是否可以获取调用函数。打印输出是
Calling object: <module>
Module: __main__
这没有帮助,因为 __main__
不是一个包,进入 patch
的内容必须是可导入的。
MRE
文件夹布局如下:
myproj/
├─myproj/
│ ├─main.py
│ ├─model.py
│ ├─view.py
│ ├─widgets/
│ │ ├─project.py
│ │ └─__init__.py
│ ├─__init__.py
│ └─__version__.py
├─poetry.lock
├─pyproject.toml
├─resources/
│ ├─icons/
│ │ ├─main_16.ico
│ │ ├─new_16.png
│ │ └─__init__.py
│ └─__init__.py
└─tests/
├─conftest.py
├─docs_tests/
│ ├─test_index_page.py
│ └─__init__.py
├─test_view.py
└─__init__.py
这是测试:
测试
@patch.object(myproj.View, 'create_project', autospec=True, spec_set=True)
def test_make_project(create_project_mock: Any, app: MainApp, qtbot: QtBot):
"""Test when New button clicked that project is created if no project is open.
Args:
create_project_mock (Any): A ``MagicMock`` for ``View._create_project`` method
app (MainApp): (fixture) The ``PyQt`` main application
qtbot (QtBot): (fixture) A bot that imitates user interaction
"""
# Arrange
window = app.view
toolbar = window.toolbar
new_action = window.new_action
new_button = toolbar.widgetForAction(new_action)
qtbot.addWidget(toolbar)
qtbot.addWidget(new_button)
# Act
qtbot.wait(10) # In non-headless mode, give time for previous test to finish
qtbot.mouseMove(new_button)
qtbot.mousePress(new_button, QtCore.Qt.LeftButton)
qtbot.waitSignal(new_button.triggered)
# Assert
assert create_project_mock.called
这里是相关的项目代码
main.py
"""Myproj entry point."""
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QApplication
import myproj
class MainApp:
def __init__(self) -> None:
"""Myproj GUI controller."""
self.model = myproj.Model(controller=self)
self.view = myproj.View(controller=self)
def __str__(self):
return f'{self.__class__.__name__}'
def __repr__(self):
return f'{self.__class__.__name__}()'
def show(self) -> None:
"""Display the main window."""
self.view.showMaximized()
if __name__ == '__main__':
app = QApplication([])
app.setStyle('fusion') # type: ignore
app.setAttribute(Qt.AA_DontShowIconsInMenus, True) # cSpell:ignore Dont
root = MainApp()
root.show()
app.exec_()
view.py (MRE)
"""Graphic front-end for Myproj GUI."""
import ctypes
import inspect
from importlib.metadata import version
from typing import TYPE_CHECKING, Optional
from pyvistaqt import MainWindow # type: ignore
from qtpy import QtCore, QtGui, QtWidgets
import resources
from myproj.widgets import Project
if TYPE_CHECKING:
from myproj.main import MainApp
class View(MainWindow):
is_project_open: bool = False
project: Optional[Project] = None
def __init__(
self,
controller: 'MainApp',
) -> None:
"""Display Myproj GUI main window.
Args:
controller (): The application controller, in the model-view-controller (MVC)
framework sense
"""
super().__init__()
self.controller = controller
self.setWindowTitle('Myproj')
self.setWindowIcon(QtGui.QIcon(resources.MYPROJ_ICO))
# Set Windows Taskbar Icon
# ( # pylint: disable=line-too-long
app_id = f"mycompany.myproj.{version('myproj')}"
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(app_id)
self.container = QtWidgets.QFrame()
self.layout_ = QtWidgets.QVBoxLayout()
self.layout_.setSpacing(0)
self.layout_.setContentsMargins(0, 0, 0, 0)
self.container.setLayout(self.layout_)
self.setCentralWidget(self.container)
self._create_actions()
self._create_menubar()
self._create_toolbar()
self._create_statusbar()
def _create_actions(self) -> None:
"""Create QAction items for menu- and toolbar."""
self.new_action = QtWidgets.QAction(
QtGui.QIcon(resources.NEW_ICO),
'&New Project...',
self,
)
self.new_action.setShortcut('Ctrl+N')
self.new_action.setStatusTip('Create a new project...')
self.new_action.triggered.connect(self.create_project)
def _create_menubar(self) -> None:
"""Create the main menubar."""
self.menubar = self.menuBar()
self.file_menu = self.menubar.addMenu('&File')
self.file_menu.addAction(self.new_action)
def _create_toolbar(self) -> None:
"""Create the main toolbar."""
self.toolbar = QtWidgets.QToolBar('Main Toolbar')
self.toolbar.setIconSize(QtCore.QSize(24, 24))
self.addToolBar(self.toolbar)
self.toolbar.addAction(self.new_action)
def _create_statusbar(self) -> None:
"""Create the main status bar."""
self.statusbar = QtWidgets.QStatusBar(self)
self.setStatusBar(self.statusbar)
def create_project(self):
"""Creates a new project."""
frame = inspect.stack()[1]
print(f'Calling object: {frame.function}')
module = inspect.getmodule(frame[0])
print(f'Module: {module.__name__}')
if not self.is_project_open:
self.project = Project(self)
self.is_project_open = True
结果
./tests/test_view.py::test_make_project Failed: [undefined]assert False
+ where False = <function create_project at 0x000001B5CBDA71F0>.called
create_project_mock = <function create_project at 0x000001B5CBDA71F0>
app = MainApp(), qtbot = <pytestqt.qtbot.QtBot object at 0x000001B5CBD19E50>
@patch('myproj.view.View.create_project', autospec=True, spec_set=True)
def test_make_project(create_project_mock: Any, app: MainApp, qtbot: QtBot):
"""Test when New button clicked that project is created if no project is open.
Args:
create_project_mock (Any): A ``MagicMock`` for ``View._create_project`` method
app (MainApp): (fixture) The ``PyQt`` main application
qtbot (QtBot): (fixture) A bot that imitates user interaction
"""
# Arrange
window = app.view
toolbar = window.toolbar
new_action = window.new_action
new_button = toolbar.widgetForAction(new_action)
qtbot.addWidget(toolbar)
qtbot.addWidget(new_button)
# Act
qtbot.wait(10) # In non-headless mode, give time for previous test to finish
qtbot.mouseMove(new_button)
qtbot.mousePress(new_button, QtCore.Qt.LeftButton)
qtbot.waitSignal(new_button.triggered)
# Assert
> assert create_project_mock.called
E assert False
E + where False = <function create_project at 0x000001B5CBDA71F0>.called
我忽略了一个重要的微妙之处。 python docs 声明您必须
patch where an object is looked up
但真的应该读一读
patch where YOU look up the object
我不直接在我的代码中调用 create_project
(Qt
在后台执行此操作)。因此,它不是打补丁的好选择。规则是:
only mock code you own/can change
“在测试的指导下开发 Object-Oriented 软件”作者:Steve Freeman,Nat Pryce
注意:您可以模拟第 3 方库方法,但 仅当您在代码中调用它时 。否则,测试会很脆弱,因为它会在第 3 方实现更改时中断。
相反,我们可以使用另一个 test-double:假的。这可以用来覆盖 create_project
,我们可以利用 QtCore.QObject.sender() 获取有关调用者的信息并对其进行断言。
最后,应该注意的是,在这个测试中手动触发动作比使用 pytest-qt
之类的 GUI 自动化工具触发动作更容易。相反,您应该创建一个单独的测试,使用 pytest-qt
按下按钮并断言触发信号已发出。
def test_make_project(app: main.MainApp):
"""Test when ``New`` action is triggered that ``create_project`` is called.
``New`` can be triggered either from the menubar or the toolbar.
Args:
app (MainApp): (fixture) The ``PyQt`` main application
"""
# Arrange
class ViewFake(view.View):
def create_project(self):
assert self.sender() is self.new_action()
app.view = ViewFake(controller=app)
window = app.view
new_action = window.new_action
# Act
new_action.trigger()
我有一个 PyQt5 GUI,当我按下工具栏按钮时它会调用一个插槽。我知道它有效,因为当我 运行 GUI 时按钮本身有效。但是,我的 pytest 无法通过。
据我所知,在打补丁时,我必须在调用方法的地方打补丁,而不是在定义方法的地方打补丁。我是否错误地定义了模拟?
注意:我尝试使用python的inspect
模块来查看是否可以获取调用函数。打印输出是
Calling object: <module>
Module: __main__
这没有帮助,因为 __main__
不是一个包,进入 patch
的内容必须是可导入的。
MRE
文件夹布局如下:
myproj/
├─myproj/
│ ├─main.py
│ ├─model.py
│ ├─view.py
│ ├─widgets/
│ │ ├─project.py
│ │ └─__init__.py
│ ├─__init__.py
│ └─__version__.py
├─poetry.lock
├─pyproject.toml
├─resources/
│ ├─icons/
│ │ ├─main_16.ico
│ │ ├─new_16.png
│ │ └─__init__.py
│ └─__init__.py
└─tests/
├─conftest.py
├─docs_tests/
│ ├─test_index_page.py
│ └─__init__.py
├─test_view.py
└─__init__.py
这是测试:
测试
@patch.object(myproj.View, 'create_project', autospec=True, spec_set=True)
def test_make_project(create_project_mock: Any, app: MainApp, qtbot: QtBot):
"""Test when New button clicked that project is created if no project is open.
Args:
create_project_mock (Any): A ``MagicMock`` for ``View._create_project`` method
app (MainApp): (fixture) The ``PyQt`` main application
qtbot (QtBot): (fixture) A bot that imitates user interaction
"""
# Arrange
window = app.view
toolbar = window.toolbar
new_action = window.new_action
new_button = toolbar.widgetForAction(new_action)
qtbot.addWidget(toolbar)
qtbot.addWidget(new_button)
# Act
qtbot.wait(10) # In non-headless mode, give time for previous test to finish
qtbot.mouseMove(new_button)
qtbot.mousePress(new_button, QtCore.Qt.LeftButton)
qtbot.waitSignal(new_button.triggered)
# Assert
assert create_project_mock.called
这里是相关的项目代码
main.py
"""Myproj entry point."""
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QApplication
import myproj
class MainApp:
def __init__(self) -> None:
"""Myproj GUI controller."""
self.model = myproj.Model(controller=self)
self.view = myproj.View(controller=self)
def __str__(self):
return f'{self.__class__.__name__}'
def __repr__(self):
return f'{self.__class__.__name__}()'
def show(self) -> None:
"""Display the main window."""
self.view.showMaximized()
if __name__ == '__main__':
app = QApplication([])
app.setStyle('fusion') # type: ignore
app.setAttribute(Qt.AA_DontShowIconsInMenus, True) # cSpell:ignore Dont
root = MainApp()
root.show()
app.exec_()
view.py (MRE)
"""Graphic front-end for Myproj GUI."""
import ctypes
import inspect
from importlib.metadata import version
from typing import TYPE_CHECKING, Optional
from pyvistaqt import MainWindow # type: ignore
from qtpy import QtCore, QtGui, QtWidgets
import resources
from myproj.widgets import Project
if TYPE_CHECKING:
from myproj.main import MainApp
class View(MainWindow):
is_project_open: bool = False
project: Optional[Project] = None
def __init__(
self,
controller: 'MainApp',
) -> None:
"""Display Myproj GUI main window.
Args:
controller (): The application controller, in the model-view-controller (MVC)
framework sense
"""
super().__init__()
self.controller = controller
self.setWindowTitle('Myproj')
self.setWindowIcon(QtGui.QIcon(resources.MYPROJ_ICO))
# Set Windows Taskbar Icon
# ( # pylint: disable=line-too-long
app_id = f"mycompany.myproj.{version('myproj')}"
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(app_id)
self.container = QtWidgets.QFrame()
self.layout_ = QtWidgets.QVBoxLayout()
self.layout_.setSpacing(0)
self.layout_.setContentsMargins(0, 0, 0, 0)
self.container.setLayout(self.layout_)
self.setCentralWidget(self.container)
self._create_actions()
self._create_menubar()
self._create_toolbar()
self._create_statusbar()
def _create_actions(self) -> None:
"""Create QAction items for menu- and toolbar."""
self.new_action = QtWidgets.QAction(
QtGui.QIcon(resources.NEW_ICO),
'&New Project...',
self,
)
self.new_action.setShortcut('Ctrl+N')
self.new_action.setStatusTip('Create a new project...')
self.new_action.triggered.connect(self.create_project)
def _create_menubar(self) -> None:
"""Create the main menubar."""
self.menubar = self.menuBar()
self.file_menu = self.menubar.addMenu('&File')
self.file_menu.addAction(self.new_action)
def _create_toolbar(self) -> None:
"""Create the main toolbar."""
self.toolbar = QtWidgets.QToolBar('Main Toolbar')
self.toolbar.setIconSize(QtCore.QSize(24, 24))
self.addToolBar(self.toolbar)
self.toolbar.addAction(self.new_action)
def _create_statusbar(self) -> None:
"""Create the main status bar."""
self.statusbar = QtWidgets.QStatusBar(self)
self.setStatusBar(self.statusbar)
def create_project(self):
"""Creates a new project."""
frame = inspect.stack()[1]
print(f'Calling object: {frame.function}')
module = inspect.getmodule(frame[0])
print(f'Module: {module.__name__}')
if not self.is_project_open:
self.project = Project(self)
self.is_project_open = True
结果
./tests/test_view.py::test_make_project Failed: [undefined]assert False
+ where False = <function create_project at 0x000001B5CBDA71F0>.called
create_project_mock = <function create_project at 0x000001B5CBDA71F0>
app = MainApp(), qtbot = <pytestqt.qtbot.QtBot object at 0x000001B5CBD19E50>
@patch('myproj.view.View.create_project', autospec=True, spec_set=True)
def test_make_project(create_project_mock: Any, app: MainApp, qtbot: QtBot):
"""Test when New button clicked that project is created if no project is open.
Args:
create_project_mock (Any): A ``MagicMock`` for ``View._create_project`` method
app (MainApp): (fixture) The ``PyQt`` main application
qtbot (QtBot): (fixture) A bot that imitates user interaction
"""
# Arrange
window = app.view
toolbar = window.toolbar
new_action = window.new_action
new_button = toolbar.widgetForAction(new_action)
qtbot.addWidget(toolbar)
qtbot.addWidget(new_button)
# Act
qtbot.wait(10) # In non-headless mode, give time for previous test to finish
qtbot.mouseMove(new_button)
qtbot.mousePress(new_button, QtCore.Qt.LeftButton)
qtbot.waitSignal(new_button.triggered)
# Assert
> assert create_project_mock.called
E assert False
E + where False = <function create_project at 0x000001B5CBDA71F0>.called
我忽略了一个重要的微妙之处。 python docs 声明您必须
patch where an object is looked up
但真的应该读一读
patch where YOU look up the object
我不直接在我的代码中调用 create_project
(Qt
在后台执行此操作)。因此,它不是打补丁的好选择。规则是:
only mock code you own/can change
“在测试的指导下开发 Object-Oriented 软件”作者:Steve Freeman,Nat Pryce
注意:您可以模拟第 3 方库方法,但 仅当您在代码中调用它时 。否则,测试会很脆弱,因为它会在第 3 方实现更改时中断。
相反,我们可以使用另一个 test-double:假的。这可以用来覆盖 create_project
,我们可以利用 QtCore.QObject.sender() 获取有关调用者的信息并对其进行断言。
最后,应该注意的是,在这个测试中手动触发动作比使用 pytest-qt
之类的 GUI 自动化工具触发动作更容易。相反,您应该创建一个单独的测试,使用 pytest-qt
按下按钮并断言触发信号已发出。
def test_make_project(app: main.MainApp):
"""Test when ``New`` action is triggered that ``create_project`` is called.
``New`` can be triggered either from the menubar or the toolbar.
Args:
app (MainApp): (fixture) The ``PyQt`` main application
"""
# Arrange
class ViewFake(view.View):
def create_project(self):
assert self.sender() is self.new_action()
app.view = ViewFake(controller=app)
window = app.view
new_action = window.new_action
# Act
new_action.trigger()