如何测试 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_projectQt 在后台执行此操作)。因此,它不是打补丁的好选择。规则是:

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