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.
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:
@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
"""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_()
"""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
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()