如何测试PYQT按钮信号调用功能?

发布于 2025-02-03 14:09:28 字数 7458 浏览 3 评论 0原文

我有一个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 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。

评论(1

2025-02-10 14:09:28

我忽略了一个重要的微妙之处。 python docs

查找对象的补丁

,但应该真正阅读

patch 在哪里查找对象

我不调用create_project在我的代码中(qt引擎盖)。因此,这不是修补的好候选人。规则是:

只有您拥有的模拟代码/可以更改

“成长为对象的软件,在测试的指导下”,史蒂夫·弗里曼(Steve Freeman),nat pryce

nb:您可以模拟第三方库方法,但仅在您将其称为代码。否则,该测试将是脆弱的,因为当第三方实施变化时,测试将破裂。

相反,我们可以使用另一个测试双:假。这可以用于覆盖create_project,我们可以利用

最后,应该注意的是,在此测试中手动触发操作而不是使用GUI自动化工具,例如pytest-qt来触发操作。相反,您应该创建一个单独的测试,该测试使用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()

There is an important subtlety I overlooked. The python docs state that you must

patch where an object is looked up

but it should really be read

patch where YOU look up the object

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:

only mock code you own/can change

"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 uses pytest-qt to push the button and assert that the trigger signal is emitted.

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()
~没有更多了~
我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
原文