pythonpyqt5pytestpytest-qt

`pytest-qt` Function `mouseMove()` Not Working


This question has been asked here, here, here, here, and here and there is apparently still a bug regarding it in Qt5, noted here. So far, nothing I have found has solved my problem.

I am trying to test that when my mouse hovers over a toolbar button that the correct statusbar message is displayed.

Setup

OS: Windows 10 Professional x64-bit, Build 1909
Python: 3.8.10 x64-bit
PyQt: 5.15.4
pytest-qt: 4.0.2
IDE: VSCode 1.59.0

Project Directory

gui/
├───gui/
│   │   main.py
│   │   __init__.py
│   │   
│   ├───controller/
│   │       controller.py
│   │       __init__.py
│   │
│   ├───model/
│   │      model.py
│   │       __init__.py
│   │
│   └───view/
│           view.py
│            __init__.py
├───resources/
│   │    __init__.py
│   │   
│   └───icons
│       │   main.ico
│       │   __init__.py
│       │   
│       └───toolbar
│               new.png
│               __init__.py
└───tests/
    │   conftest.py
    │   __init__.py
    │
    └───unit_tests
            test_view.py
            __init__.py

Code

gui/main.py:

from PyQt5.QtWidgets import QApplication

from gui.controller.controller import Controller
from gui.model.model import Model
from gui.view.view import View


class MainApp:
    def __init__(self) -> None:
        self.controller = Controller()
        self.model = self.controller.model
        self.view = self.controller.view

    def show(self) -> None:
        self.view.showMaximized()


if __name__ == "__main__":
    app = QApplication([])
    root = MainApp()
    root.show()
    app.exec_()

gui/view.py:

from typing import Any

from PyQt5.QtCore import QSize
from PyQt5.QtGui import QIcon
from PyQt5.QtWidgets import QAction, QFrame, QGridLayout, QStatusBar, QToolBar, QWidget
from pyvistaqt import MainWindow

from resources.icons import toolbar


class View(MainWindow):
    def __init__(
        self, controller, parent: QWidget = None, *args: Any, **kwargs: Any
    ) -> None:
        super().__init__(parent, *args, **kwargs)
        self.controller = controller

        # Set the window name
        self.setWindowTitle("GUI Demo")

        # Create the container frame
        self.container = QFrame()

        # Create the layout
        self.layout = QGridLayout()
        self.layout.setContentsMargins(0, 0, 0, 0)

        # Set the layout
        self.container.setLayout(self.layout)
        self.setCentralWidget(self.container)

        # Create and position widgets
        self._create_actions()
        self._create_menubar()
        self._create_toolbar()
        self._create_statusbar()

    def _create_actions(self):
        self.new_icon = QIcon(toolbar.NEW_ICO)

        self.new_action = QAction(self.new_icon, "&New Project...", self)
        self.new_action.setStatusTip("Create a new project...")

    def _create_menubar(self):
        self.menubar = self.menuBar()

        self.file_menu = self.menubar.addMenu("&File")

        self.file_menu.addAction(self.new_action)

    def _create_toolbar(self):
        self.toolbar = QToolBar("Main Toolbar")
        self.toolbar.setIconSize(QSize(16, 16))

        self.addToolBar(self.toolbar)

        self.toolbar.addAction(self.new_action)

    def _create_statusbar(self):
        self.statusbar = QStatusBar(self)
        self.setStatusBar(self.statusbar)

gui/model.py:

from typing import Any


class Model(object):
    def __init__(self, controller, *args: Any, **kwargs: Any):
        self.controller = controller

gui/controller.py:

from typing import Any

from gui.model.model import Model
from gui.view.view import View


class Controller(object):
    def __init__(self, *args: Any, **kwargs: Any):
        self.model = Model(controller=self, *args, **kwargs)
        self.view = View(controller=self, *args, **kwargs)

resources/icons/toolbar/__init__.py:

import importlib.resources as rsrc

from resources.icons import toolbar

with rsrc.path(toolbar, "__init__.py") as path:
    NEW_ICO = str((path.parent / "new.png").resolve())

test/conftest.py:

from typing import Any, Callable, Generator, List, Sequence, Union

import pytest
import pytestqt
from pytestqt.qtbot import QtBot
from gui.main import MainApp
from PyQt5 import QtCore

pytest_plugins: Union[str, Sequence[str]] = ["pytestqt.qtbot",]
"""A ``pytest`` global variable that registers plugins for use in testing."""


@pytest.fixture(autouse=True)
def clear_settings() -> Generator[None, None, None]:
    yield
    QtCore.QSettings().clear()


@pytest.fixture
def app(qtbot: QtBot) -> Generator[MainApp, None, None]:
    # Setup
    root = MainApp()
    root.show()
    qtbot.addWidget(root.view)

    # Run
    yield root

    # Teardown - None

test/unit_tests/test_view.py:

import time

from PyQt5 import QtCore, QtWidgets
import pytest
from pytestqt import qt_compat
from pytestqt.qt_compat import qt_api
from pytestqt.qtbot import QtBot

from gui.main import MainApp


def test_toolbar_newbutton_hover(app: MainApp, qapp: QtBot, qtbot: QtBot):
    # Arrange
    new_button = app.view.toolbar.widgetForAction(app.view.new_action)
    new_button.setMouseTracking(True)

    qtbot.addWidget(new_button)

    # Act
    qtbot.mouseMove(new_button)
    qapp.processEvents()
    time.sleep(5)  # See if statusbar message appears

    # Assert
    assert app.view.statusbar.currentMessage() == "Create a new project..."

Problem:

The statusbar message never updates, and the mouse will only sometimes move to the toolbar button. I cannot figure out how to get this test to pass.


Solution

  • Unfortunately, I assumed the above was the answer, but the following is actually correct.

    However, the larger issue does have to do with PyQt5 itself that @eyllanesc addressed previously here and I mentioned in my question, but overlooked:

    How to automate mouse drag using pytest-qt?

    The QTBUG-5232 (which I also put in my question, but overloked) reports that the Qt5 method QTest::mouseMove has a bug and the only workaround to resolve it is to use the QWindow overloads of the function QTest::mouseMoved(QWindow *) rather than the QWidget overloads.

    Unfortunately, those overloads are not ported to PyQt5 (in fact if you try to use them, the program will simply crash), so only the QWidget version is available.

    However, the issue was properly resolved (as promised) in Qt6, so the function works properly in PyQt6 and PySide6.

    Rather than resorting to using alternative gui automation packages, like pywinauto or pyautogui, the best result is to upgrade to PySide6. It also helps to use a package like qtpy that standardizes the syntax between versions 5 and 6 so you won't have to update your code if you switch back and forth between versions.

    For those who are not able to update, the alternative @eyllanesc mentions, which is to use QtGui.QMouseEvents directly is the best alternative. The caveat is that they will not physically move the mouse, so these tests would have to be run in a headless fashion.

    A simple way to implement this with pytest-qt (so you can still use its other features) is to override the mouse functions in QtBot and override the qtbot fixture in your conftest.py. This approach was modeled after the way pyqtgraph does its UI testing:

    NOTE: For Windows, you can attempt to add ctypes.windll.user32.SetCursorPos(x, y) as pyautogui does to get the cursor to move, but I have not succeeded in getting this to work and have simply upgraded to PySide6.

    import pytest
    from _pytest.fixtures import SubRequest
    from pytestqt.qt_compat import qt_api
    from pytestqt.qtbot import QtBot  # type: ignore
    from qtpy import QtCore, QtWidgets
    
    class SilentQtBot(QtBot):
        """``QtBot`` that creates mouse actions from ``QtGui`` events.
    
        ``pytest-qt`` uses ``QtTest.QTest`` methods for mouse actions. These are known
        to have problems in ``PyQt5``. See
        
            - `SO Post How to automate mouse drag using pytest-qt?`_,
            - `QTBUG-5232`_,
            - `SO Post pytest-qt Function mouseMove() Not Working`_, and
            - `Issue #428`_.
    
        This class overrides mouse events using ``QtGui.QMouseEvent``s directly and sending
        the events to the application. This class is meant to be used through the ``qtbot``
        fixture rather than instantiated directly.
    
        Unfortunately, this workaround still does not physically move the mouse, so ``qtbot``
        must be used headless (no GUI windows will run). To enforce this, ``qtbot`` isn't
        passed ``qapp`` in its fixture function.
    
        Note:
            Function ``fixture_guiqtbot`` in ``conftest.py`` overrides ``qtbot`` from
            ``pytestqt`` to use this class.
        
        .. _How to automate mouse drag using pytest-qt?:
           https://stackoverflow.com/questions/59080123/how-to-automate-mouse-drag-using-pytest-qt
    
        .. _QTBUG-5232:
           https://bugreports.qt.io/browse/QTBUG-5232
    
        .. _SO Post pytest-qt Function mouseMove() Not Working:
           https://stackoverflow.com/questions/68696865/pytest-qt-function-mousemove-not-working
    
        .. _Issue 428:
           https://github.com/pytest-dev/pytest-qt/issues/428
        """
    
        # pylint: disable=arguments-differ, line-too-long, invalid-name
    
        def mouseClick(
            self,
            widget: QtWidgets.QWidget,
            button: QtCore.Qt.MouseButton,
            pos: Optional[QtCore.QPointF] = None,
            modifiers: Optional[QtCore.Qt.KeyboardModifier] = None,
        ) -> None:
            """Click mouse button ``button`` on ``widget`` at position ``pos``.
    
            Position ``pos`` is in local coordinates. If no position is defined, the center of
            the widget is used.
    
            Args:
                widget (QtWidgets.QWidget): Widget to click
                button (QtCore.Qt.MouseButton): Button to use when clicking
                pos (QtCore.QPointF, optional): Where to click (local coordinates). Defaults to None.
                    If no position is provided, the center of the widget is used.
                modifiers (QtCore.Qt.KeyboardModifier, optional): Keyboard modifiers. Defaults to None.
            """
            if pos is None:
                pos = widget.rect().center()
            self.mouseMove(widget, pos)
            self.mousePress(widget, button, pos, modifiers)
            self.mouseRelease(widget, button, pos, modifiers)
    
        def mouseDClick(
            self,
            widget: QtWidgets.QWidget,
            button: QtCore.Qt.MouseButton,
            pos: Optional[QtCore.QPointF] = None,
            modifiers: Optional[QtCore.Qt.KeyboardModifier] = None,
        ) -> None:
            """Double-click mouse button ``button`` on ``widget`` at position ``pos``.
    
            Position ``pos`` is in local coordinates. If no position is defined, the center of
            the widget is used.
    
            Args:
                widget (QtWidgets.QWidget): Widget to click
                button (QtCore.Qt.MouseButton): Button to use when clicking
                pos (QtCore.QPointF, optional): Where to click (local coordinates). Defaults to None.
                    If no position is provided, the center of the widget is used.
                modifiers (QtCore.Qt.KeyboardModifier, optional): Keyboard modifiers. Defaults to None.
            """
            if pos is None:
                pos = widget.rect().center()
            self.mouseClick(widget, button, pos, modifiers)
            self.mouseClick(widget, button, pos, modifiers)
    
        def mouseDrag(
            self,
            widget: QtWidgets.QWidget,
            button: QtCore.Qt.MouseButton,
            start_pos: Optional[QtCore.QPointF],
            end_pos: QtCore.QPointF,
            modifiers: Optional[QtCore.Qt.KeyboardModifier] = None,
        ) -> None:
            """Drag mouse on widget.
    
            Positions ``start_pos`` and ``end_pos`` are in local coordinates. If ``start_pos``
            is not defined, the center of the widget is used (i.e. drag starts at center of
            widget).
    
            Args:
                widget (QtWidgets.QWidget): Widget being dragged on
                button (QtCore.Qt.MouseButton): Button used when dragging
                start_pos (QtCore.QPointF, optional): Starting point for drag (local coordinates). Defaults to None.
                end_pos (QtCore.QPointF): End point for drag (local coordinates)
                modifiers (QtCore.Qt.KeyboardModifier, optional): Keyboard modifiers. Defaults to None.
            """  # pylint: disable=line-too-long
            self.mouseMove(widget, start_pos)
            self.mousePress(widget, start_pos, button, modifiers)
            self.mouseMove(widget, end_pos, modifiers)
            self.mouseRelease(widget, end_pos, button, modifiers)
    
        def mouseMove(
            self,
            widget: QtWidgets.QWidget,
            pos: Optional[QtCore.QPointF] = None,
            modifiers: Optional[QtCore.Qt.KeyboardModifier] = None,
        ) -> None:
            """Move mouse to position ``pos`` on widget.
    
            Position ``pos`` is in local coordinates. If no position is defined, the center of
            the widget is used.
    
            Args:
                widget (QtWidgets.QWidget): Widget on which mouse is moving
                pos (QtCore.QPointF, optional): Position to move mouse (local coordinates). Defaults to None.
                modifiers (QtCore.Qt.KeyboardModifer, optional): Keyboard modifiers. Defaults to None.
            """
            if isinstance(widget, qt_api.QtWidgets.QGraphicsView):
                widget = widget.viewport()
            if modifiers is None:
                modifiers = qt_api.QtCore.Qt.KeyboardModifier.NoModifier
            if pos is None:
                pos = widget.rect().center()
            if isinstance(pos, qt_api.QtCore.QPoint):
                pos = qt_api.QtCore.QPointF(pos)  # PyQt6 requires `QPointF`
            buttons = qt_api.QtCore.Qt.MouseButton.NoButton
            # `QMouseEvent` does not accept keyword arguments. Results in `TypeError:
            # not enough arguments`. Use positional instead.
            event = qt_api.QtGui.QMouseEvent(
                qt_api.QtCore.QEvent.Type.MouseMove,
                pos,
                qt_api.QtCore.Qt.MouseButton.NoButton,
                buttons,
                modifiers,
            )
            qt_api.QtWidgets.QApplication.sendEvent(widget, event)
    
        def mousePress(
            self,
            widget: QtWidgets.QWidget,
            button: QtCore.Qt.MouseButton,
            pos: Optional[QtCore.QPointF] = None,
            modifiers: Optional[QtCore.Qt.KeyboardModifier] = None,
        ) -> None:
            """Press mouse button ``button`` on ``widget`` at position ``pos``.
    
            Mouse is not released after calling this. To release, use ``mouseRelease``.
            Position `pos` is in local coordinates. If no position is defined, the center of
            the widget is used.
    
            Args:
                widget (QtWidgets.QWidget): Widget where mouse is pressed
                button (QtCore.Qt.MouseButton): Button to use when pressing
                pos (QtCore.QPointF, optional): Where to press (local coordinates). Defaults to None.
                    If position is ``None``, the center of the widget is used.
                modifiers (QtCore.Qt.KeyboardModifier, optional): Keyboard modifiers. Defaults to None.
            """
            if isinstance(widget, qt_api.QtWidgets.QGraphicsView):
                widget = widget.viewport()
            if modifiers is None:
                modifiers = qt_api.QtCore.Qt.KeyboardModifier.NoModifier
            if pos is None:
                pos = widget.rect().center()
            if isinstance(pos, qt_api.QtCore.QPoint):
                pos = qt_api.QtCore.QPointF(pos)  # PyQt6 requires `QPointF`
            buttons = qt_api.QtCore.Qt.MouseButton.NoButton
            # `QMouseEvent` does not accept keyword arguments. Results in `TypeError:
            # not enough arguments`. Use positional instead.
            event = qt_api.QtGui.QMouseEvent(
                qt_api.QtCore.QEvent.Type.MouseButtonPress,
                pos,
                button,
                buttons,
                modifiers,
            )
            qt_api.QtWidgets.QApplication.sendEvent(widget, event)
    
        def mouseRelease(
            self,
            widget: QtWidgets.QWidget,
            button: QtCore.Qt.MouseButton,
            pos: Optional[QtCore.QPointF] = None,
            modifiers: Optional[QtCore.Qt.KeyboardModifier] = None,
        ):
            """Release mouse button ``button`` on ``widget``.
    
            Position `pos` is in local coordinates. If no position is defined, the center of
            the widget is used.
    
            Args:
                widget (QtWidgets.QWidget): Widget on which mouse button is released
                button (QtCore.Qt.MouseButton): Button to release
                pos (QtCore.QPointF, optional): Position where to release. Defaults to ``None``.
                    If position is ``None``, the center of the widget is used.
                modifiers (QtCore.Qt.KeyboardModifier, optional): Keyboard modifiers. Defaults to ``None``.
            """
            if isinstance(widget, qt_api.QtWidgets.QGraphicsView):
                widget = widget.viewport()
            if modifiers is None:
                modifiers = qt_api.QtCore.Qt.KeyboardModifier.NoModifier
            if pos is None:
                pos = widget.rect().center()
            if isinstance(pos, qt_api.QtCore.QPoint):
                pos = qt_api.QtCore.QPointF(pos)  # PyQt6 requires `QPointF`
            buttons = qt_api.QtCore.Qt.MouseButton.NoButton
            # `QMouseEvent` does not accept keyword arguments. Results in `TypeError:
            # not enough arguments`. Use positional instead.
            event = qt_api.QtGui.QMouseEvent(
                qt_api.QtCore.QEvent.Type.MouseButtonRelease,
                pos,
                button,
                buttons,
                modifiers,
            )
            qt_api.QtWidgets.QApplication.sendEvent(widget, event)
    
    
    @pytest.fixture(name='qtbot')
    def fixture_qtbot(
        request: SubRequest,
    ):
        """Fixture for ``SilentQtBot`` (This overrides ``qtbot``).
    
        Fixture used to create a GuiQtBot instance for use during testing.
        Make sure to call ``addWidget`` for each top-level widget you create to ensure
        that they are properly closed after the test ends.
    
        This overrides the ``pytestqt.qtbot`` fixture. It is not passed ``qapp``, so all
        ``qtbot`` operations are headless (no running GUI).
    
        Args:
            request (Generator[pytest.FixtureRequest, None, None]): ``pytest`` request fixture
    
        Returns:
            Generator[SilentQtBot, None, None]: Generator that returns ``SilentQtBot`` fixtures
        """
        result = SilentQtBot(request)
        return result