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.
Unfortunately, I assumed the above was the answer, but the following is actually correct.
time.sleep
should not be used, as it is blockingHowever, 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
methodQTest::mouseMove
has a bug and the only workaround to resolve it is to use theQWindow
overloads of the functionQTest::mouseMoved(QWindow *)
rather than theQWidget
overloads.Unfortunately, those overloads are not ported to
PyQt5
(in fact if you try to use them, the program will simply crash), so only theQWidget
version is available.However, the issue was properly resolved (as promised) in
Qt6
, so the function works properly inPyQt6
andPySide6
.Rather than resorting to using alternative gui automation packages, like
pywinauto
orpyautogui
, the best result is to upgrade toPySide6
. It also helps to use a package likeqtpy
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 inQtBot
and override theqtbot
fixture in yourconftest.py
. This approach was modeled after the waypyqtgraph
does its UI testing:NOTE: For Windows, you can attempt to add
ctypes.windll.user32.SetCursorPos(x, y)
aspyautogui
does to get the cursor to move, but I have not succeeded in getting this to work and have simply upgraded toPySide6
.