如何测试PYQT按钮信号调用功能?
我有一个pyqt5 GUI,当我按下工具栏按钮时,可以调用插槽。我知道它有效,因为当我运行GUI时,按钮本身可以工作。但是,我无法让我的Pytest通过。
我了解,在修补时,我必须在调用该方法而不是定义的地方进行修补。我是否错误地定义了模拟?
NB:我尝试使用Python的Inspect
模块来查看是否可以获取调用功能。打印输出是
Calling object: <module>
Module: __main__
无济于事的,因为__ MAIM __
不是软件包,而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
# (https://stackoverflow.com/questions/1551605/how-to-set-applications-taskbar-icon-in-windows-7/1552105#1552105) # 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
I have a PyQt5 GUI that calls a slot when I press a toolbar button. I know it works because the button itself works when I run the GUI. However, I cannot get my pytest to pass.
I understand that, when patching, I have to patch where the method is called rather than where it is defined. Am I defining my mock incorrectly?
NB: I tried to use python's inspect
module to see if I could get the calling function. The printout was
Calling object: <module>
Module: __main__
which doesn't help because __main__
is not a package and what goes into patch
has to be importable.
MRE
Here is the folder layout:
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
Here is the test:
Test
@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
Here is the relevant project code
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
# (https://stackoverflow.com/questions/1551605/how-to-set-applications-taskbar-icon-in-windows-7/1552105#1552105) # 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
Result
./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
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论
评论(1)
我忽略了一个重要的微妙之处。 python docs
,但应该真正阅读
我不调用
create_project
在我的代码中(qt
引擎盖)。因此,这不是修补的好候选人。规则是:“成长为对象的软件,在测试的指导下”,史蒂夫·弗里曼(Steve Freeman),nat pryce
nb:您可以模拟第三方库方法,但仅在您将其称为代码。否则,该测试将是脆弱的,因为当第三方实施变化时,测试将破裂。
相反,我们可以使用另一个测试双:假。这可以用于覆盖
create_project
,我们可以利用最后,应该注意的是,在此测试中手动触发操作而不是使用GUI自动化工具,例如
pytest-qt
来触发操作。相反,您应该创建一个单独的测试,该测试使用pytest-qt
按下按钮并断言触发信号已发出。There is an important subtlety I overlooked. The python docs state that you must
but it should really be read
I don't call
create_project
directly in my code (Qt
does this under the hood). So, it isn't a good candidate for patching. The rule is:"Growing Object-Oriented Software, Guided by Tests" by Steve Freeman, Nat Pryce
NB: You can mock a 3rd-party library method, but only when you call it in your code. Otherwise, the test will be brittle because it will break when the 3rd-party implementation changes.
Instead, we can use another test-double: a fake. This can be used to override
create_project
and we can exploit QtCore.QObject.sender() to get information about the caller and assert on it.Lastly, it should be noted that it is easier to manually trigger the action in this test rather than use GUI automation tools like
pytest-qt
to trigger the action. Instead, you should create a separate test that usespytest-qt
to push the button and assert that the trigger signal is emitted.