question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. It collects links to all the places you might be looking at while hunting down a tough bug.

And, if you’re still stuck at the end, we’re happy to hop on a call to see how we can help out.

Cannot Assert Button Calls Method

See original GitHub issue

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

"""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"medtronic.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

Issue Analytics

  • State:closed
  • Created a year ago
  • Comments:11 (4 by maintainers)

github_iconTop GitHub Comments

1reaction
adam-grant-hendrycommented, Jun 4, 2022

According to this code:

self.new_action.triggered.connect(self.create_project)

It should be calling View.create_project

In python, you have to patch where the object is called due to the nature of python’s import mechanism [1, 2] . connect links triggered to create_project, but doesn’t actually call it. Otherwise, we would see a print statement after initializing View since __init__ calls _create_actions. Qt internally calls slot functions when signals get triggered, so we would have to patch where that happens in Qt.

This reminded me of an important subtlety I overlooked. The python docs state that you should

patch where an object is looked up

but it should really be

patch where YOU look up the object

Since I don’t call create_project directly anywhere in my code, this isn’t a good candidate for patching:

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 their implementation changes.

I think it is much better to not use mocking at all here

I agree, except on one point: if the function call is expensive (it shouldn’t be here), creating a fake and patching that might be a good idea. For instance, we could override create_project and exploit QtCore.QObject.sender() see the docs

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

The above works for me.

0reactions
adam-grant-hendrycommented, Jun 4, 2022

Thank you pytest-qt developers all for your help! Please feel free to close this issue as it appears to be implementation detail on my end and not directly related to pytest-qt.

Read more comments on GitHub >

github_iconTop Results From Across the Web

Angular CLI button (click) do not call method - Stack Overflow
I wanted to add a button and assign a listener to it on the click event, I have already googled a lot of...
Read more >
.simulate('click') only working when done twice on the same ...
and here is my test: it('calls handleSubmit when Submit button is clicked', () => { let wrapper = shallow(<Contact {...mockProps} ...
Read more >
API Call Button Not Functioning On First Click or Two
I am currently working on a simple project for a client that wants a tool to calculate the rates they will charge for...
Read more >
Selenium Assertion Examples - Practical Applications In Projects
In this Tutorial, we will Discuss How to use Assertions in Various Real-Time Project Scenarios: To verify if an object is visible (Button, ......
Read more >
Component testing scenarios - Angular
To correct the problem, call compileComponents() as explained in the following ... Then you can assert that the quote element displays the expected...
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found