python-3.xmockingpyqt5python-3.8pytest-qt

How to Test that a PyQt button signal calls a function?


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

Solution

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