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
