PyQt5 не может протестировать всплывающую подсказку в автономном режиме в Windows
Я использую GitLab для CI, и мне нужно протестировать графический интерфейс PyQt5 в автономном режиме с помощью
pytest-qt
(Я использую Python 3.8 в Windows 10). С этой целью я могу работать в безголовом режиме, установив переменную среды
QT_QPA_PLATFORM
к
"offscreen"
в моем
pyproject.toml
:
[tool.pytest.ini_options]
env = [
"D:QT_QPA_PLATFORM=offscreen"
]
и следующий тест проходит успешно при запуске в оконном режиме, но тест всплывающей подсказки не проходит в автономном режиме. Как я могу сделать этот проход в безголовом режиме?:
tests/test_view.py
def test_toolbar_statusbar_and_tooltip_messages(app: MainApp, qtbot: QtBot) -> None:
"""Test for correct status bar message when a toolbar item is hovered.
For example, when the user clicks 'File' in the menubar and hovers over 'New', the
statusbar tooltip should read 'Create a new project...'.
Args:
app (MainApp): (fixture) Qt main application
qtbot (QtBot): (fixture) Bot that imitates user interaction
"""
# Arrange
window = app.view
statusbar = window.statusbar
toolbar = window.toolbar
new_action = window.new_action
new_rect = toolbar.actionGeometry(new_action)
tooltip = QtWidgets.QToolTip
# Assert - Precondition
assert statusbar.currentMessage() == ''
assert tooltip.text() == ''
# Act
qtbot.mouseMove(toolbar, new_rect.center())
# Assert - Postcondition
def check_status():
assert statusbar.currentMessage() == 'Create a new project...'
def check_tooltip():
assert tooltip.text() == 'New Project'
qtbot.waitUntil(check_status)
qtbot.waitUntil(check_tooltip)
Вот оставшийся код для MRE:
tests/conftest.py
from typing import Generator, Union, Sequence
import pytest
from pytestqt.qtbot import QtBot
from qtpy import QtCore
from myproj.main import MainApp
# Register plugins to use in testing
pyteset_plugins: Union[str, Sequence[str]] = [
'pytestqt.qtbot',
]
@pytest.fixture(autouse=True)
def clear_settings() -> Generator[None, None, None]:
"""Fixture to clear ``Qt`` settings."""
yield
QtCore.QSettings().clear()
@pytest.fixture(name='app')
def fixture_app(qtbot: QtBot) -> Generator[MainApp, None, None]:
"""``pytest`` fixture for ``Qt``.
Args:
qtbot (QtBot): pytest fixture for Qt
Yields:
Generator[QtBot, None, None]: Generator that yields QtBot fixtures
"""
# Setup
root = MainApp()
root.show()
qtbot.addWidget(root.view)
# Run
yield root
# Teardown
# None
myproj/main.py
from pyvistaqt import MainWindow
from qtpy import QtCore, QtGui, QtWidgets
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QApplication
import resources
class View(MainWindow):
def __init__(
self,
controller: 'MainApp',
) -> None:
"""Display GUI main window.
Args:
controller (): The application controller, in the model-view-controller (MVC)
framework sense
"""
super().__init__()
self.controller = controller
self.setWindowTitle('My Project')
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...')
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)
class MainApp:
def __init__(self) -> None:
"""GUI controller."""
self.view = View(controller=self)
def show(self) -> None:
"""Display the main window."""
self.view.showMaximized()
if __name__ == '__main__':
app = QApplication([])
app.setStyle('fusion')
app.setAttribute(Qt.AA_DontShowIconsInMenus, True)
root = MainApp()
root.show()
app.exec_()
Полученные результаты
Вот сообщение об ошибке, которое я получаю в безголовом режиме:
PS> pytest
================================================================================================ test session starts =================================================================================================
platform win32 -- Python 3.8.10, pytest-7.1.2, pluggy-1.0.0
PyQt5 5.15.6 -- Qt runtime 5.15.2 -- Qt compiled 5.15.2
Using --randomly-seed=1234
rootdir: %USERPROFILE%\Code\myproj, configfile: pyproject.toml, testpaths: tests
plugins: hypothesis-6.46.9, cov-3.0.0, doctestplus-0.12.0, env-0.6.2, memprof-0.2.0, qt-4.0.2, randomly-3.12.0, typeguard-2.13.3
collected 5 items
tests\docs_tests\test_index_page.py . [ 20%]
tests\test_view.py ...F [100%]
====================================================================================================== FAILURES ======================================================================================================
____________________________________________________________________________________ test_toolbar_statusbar_and_tooltip_messages _____________________________________________________________________________________
self = <pytestqt.qtbot.QtBot object at 0x000001C4858B78E0>, callback = <function test_toolbar_statusbar_and_tooltip_messages.<locals>.check_tooltip at 0x000001C4858BB9D0>
def waitUntil(self, callback, *, timeout=5000):
"""
.. versionadded:: 2.0
Wait in a busy loop, calling the given callback periodically until timeout is reached.
``callback()`` should raise ``AssertionError`` to indicate that the desired condition
has not yet been reached, or just return ``None`` when it does. Useful to ``assert`` until
some condition is satisfied:
.. code-block:: python
def view_updated():
assert view_model.count() > 10
qtbot.waitUntil(view_updated)
Another possibility is for ``callback()`` to return ``True`` when the desired condition
is met, ``False`` otherwise. Useful specially with ``lambda`` for terser code, but keep
in mind that the error message in those cases is usually not very useful because it is
not using an ``assert`` expression.
.. code-block:: python
qtbot.waitUntil(lambda: view_model.count() > 10)
Note that this usage only accepts returning actual ``True`` and ``False`` values,
so returning an empty list to express "falseness" raises a ``ValueError``.
:param callback: callable that will be called periodically.
:param timeout: timeout value in ms.
:raises ValueError: if the return value from the callback is anything other than ``None``,
``True`` or ``False``.
.. note:: This method is also available as ``wait_until`` (pep-8 alias)
"""
__tracebackhide__ = True
import time
start = time.time()
def timed_out():
elapsed = time.time() - start
elapsed_ms = elapsed * 1000
return elapsed_ms > timeout
timeout_msg = f"waitUntil timed out in {timeout} milliseconds"
while True:
try:
> result = callback()
.venv\lib\site-packages\pytestqt\qtbot.py:510:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
def check_tooltip():
> assert tooltip.text() == 'New Project'
E AssertionError: assert '' == 'New Project'
E - New Project
tests\test_view.py:104: AssertionError
The above exception was the direct cause of the following exception:
app = MainApp(), qtbot = <pytestqt.qtbot.QtBot object at 0x000001C4858B78E0>
def test_toolbar_statusbar_and_tooltip_messages(app: MainApp, qtbot: QtBot) -> None:
"""Test for correct status bar message when a toolbar item is hovered.
For example, when the user clicks 'File' in the menubar and hovers over 'New', the
statusbar tooltip should read 'Create a new project...'.
Args:
app (MainApp): (fixture) Qt main application
qtbot (QtBot): (fixture) Bot that imitates user interaction
"""
# Arrange
window = app.view
statusbar = window.statusbar
toolbar = window.toolbar
new_action = window.new_action
new_rect = toolbar.actionGeometry(new_action)
tooltip = QtWidgets.QToolTip
# Assert - Precondition
assert statusbar.currentMessage() == ''
assert tooltip.text() == ''
# Act
qtbot.mouseMove(toolbar, new_rect.center())
# Assert - Postcondition
def check_status():
assert statusbar.currentMessage() == 'Create a new project...'
def check_tooltip():
assert tooltip.text() == 'New Project'
qtbot.waitUntil(check_status)
> qtbot.waitUntil(check_tooltip)
E pytestqt.exceptions.TimeoutError: waitUntil timed out in 5000 milliseconds
tests\test_view.py:107: TimeoutError
------------------------------------------------------------------------------------------------ Captured Qt messages ------------------------------------------------------------------------------------------------
QtWarningMsg: This plugin does not support propagateSizeHints()
============================================================================================ memory consumption estimates ============================================================================================
tests/test_view.py::test_toolbar_statusbar_and_tooltip_messages - 13.0 MB
tests/test_view.py::test_menubar_statusbar_messages - 136.0 KB
docs_tests::test_index_page.py::test_index[index.html] - 128.0 KB
tests/test_view.py::test_window_appears - 68.0 KB
tests/test_view.py::test_title_correct - 68.0 KB
---------- coverage: platform win32, python 3.8.10-final-0 -----------
Name Stmts Miss Branch BrPart Cover Missing
-------------------------------------------------------------------------
docs\source\conf.py 33 0 2 0 100%
myproj\__init__.py 2 0 0 0 100%
myproj\main.py 11 1 0 0 91% 15
myproj\model.py 3 0 2 0 100%
myproj\view.py 42 1 4 1 96% 13
resources\__init__.py 1 0 0 0 100%
resources\icons\__init__.py 4 0 0 0 100%
-------------------------------------------------------------------------
TOTAL 96 2 8 1 97%
Coverage HTML written to dir logs/coverage/html
Coverage XML written to file logs/coverage/coverage.xml
============================================================================================== short test summary info ===============================================================================================
FAILED tests/test_view.py::test_toolbar_statusbar_and_tooltip_messages - pytestqt.exceptions.TimeoutError: waitUntil timed out in 5000 milliseconds
============================================================================================ 1 failed, 4 passed in 6.41s =============================================================================================
Обновлять
Я также провел аналогичный тест для своих пунктов меню и обнаружил, что если я не запустил его, тест проходит в режиме без головы. Я думаю, что-то не так с моими приборами...
Тест также проходит, если я запускаю его индивидуально, например:
PS> pytest ./tests/test_view.py::test_toolbar_statusbar_and_tooltip_messages
Вот еще один тест, который я прокомментировал:
def test_menubar_statusbar_messages(app: MainApp, qtbot: QtBot) -> None:
"""Test for correct status bar message when a menu item is hovered.
For example, when the user clicks 'File' in the menubar and hovers over 'New', the
statusbar tooltip should read 'Create a new project...'.
Args:
app (MainApp): (fixture) Qt main application
qtbot (QtBot): (fixture) Bot that imitates user interaction
"""
# Arrange
window = app.view
menubar = window.menubar
statusbar = window.statusbar
file_menu = window.file_menu
new_action = window.new_action
file_rect = menubar.actionGeometry(file_menu.menuAction())
new_rect = file_menu.actionGeometry(new_action)
# Act
qtbot.mouseMove(menubar, file_rect.center())
qtbot.mouseClick(menubar, qt_api.QtCore.Qt.LeftButton, pos=file_rect.center())
qtbot.mouseMove(file_menu, new_rect.center())
qtbot.mousePress(file_menu, qt_api.QtCore.Qt.LeftButton, pos=new_rect.center())
# Assert
def check_status():
assert statusbar.currentMessage() == 'Create a new project...'
qtbot.waitUntil(check_status)