Skillversitas

Testing PyQt Applications

Testing a PyQt app is the pytest plus the offscreen platform plugin: the QT_QPA_PLATFORM=offscreen (the QApplication without a display - the CI's, the headless) runs the widget in the loop (the QTest's qWait, the processEvents), and the test is the widget's state (the text, the value, the count) not the pixel. The pytest-qt plugin is the bridge: the qtbot fixture (the QApplication per test, the qWait, the processEvents), the qtbot.addWidget (the widget's destroy after the test), and the qtbot.waitSignal (the signal's wait, the timeout). This chapter covers the offscreen platform (the QApplication without the display), the pytest-qt (the qtbot fixture, the addWidget, the waitSignal), the widget test (the click, the setText, the state read), and the signal test (the waitSignal, the QSignalSpy).

The offscreen Platform: The Headless

# the offscreen: the QApplication WITHOUT a display (the
# CI's, the headless) - the QT_QPA_PLATFORM env var
QT_QPA_PLATFORM=offscreen python -m pytest test_app.py
# the offscreen plugin (the qoffscreen): the render to
# the buffer (the no X11, the no window), the widget's
# the logic runs (the signal, the slot, the state) but
# the pixel is the buffer (the no screen)
# the pytest-qt plugin: the pip install pytest-qt
pip install pytest-qt

The offscreen platform is the QApplication without the display: the QT_QPA_PLATFORM=offscreen env var (the qoffscreen plugin) renders to the buffer (the no X11, the no window), and the widget's logic runs (the signal, the slot, the state) but the pixel is the buffer (the no screen). The CI is the offscreen's (the headless, the no display), and the pytest-qt plugin (the pip install pytest-qt) is the bridge (the qtbot fixture, the QApplication per test). The offscreen is the test's platform, and the pytest-qt is the test's fixture - the two are the PyQt test's setup, and the widget test (the next section) is the test's content.

The pytest-qt: The qtbot Fixture

# the test file (the test_app.py):
# the qtbot fixture: the QApplication per test (the
# pytest-qt plugin provides it, the offscreen is the
# platform), the addWidget (the widget's destroy after),
# the waitSignal (the signal's wait, the timeout)
from PyQt5.QtWidgets import QApplication
import pytest

# the qtbot fixture (the pytest-qt): the QApplication
# (the offscreen, the per-test), the widget's destroy
# (the addWidget's teardown)
def test_button_text(qtbot):
    from PyQt5.QtWidgets import QPushButton
    button = QPushButton("Save")
    qtbot.addWidget(button)   # the destroy after
    assert button.text() == "Save"
    # the click (the QTest's, the qtbot's mouse):
    qtbot.mouseClick(button, Qt.LeftButton)
    # the state (the clicked's, the count's):
    # the signal's the connect's the spy's (below)

# the waitSignal: the signal's wait (the async's, the
# timeout's) - the worker's the done's the wait's
def test_async(qtbot):
    from PyQt5.QtCore import QObject, pyqtSignal
    class Worker(QObject):
        done = pyqtSignal(str)
    w = Worker()
    result = []
    w.done.connect(lambda r: result.append(r))
    w.done.emit("the done")
    qtbot.waitSignal(w.done, timeout=1000)
    assert result == ["the done"]

The pytest-qt is the bridge: the qtbot fixture (the QApplication per test, the offscreen platform, the widget's destroy after the addWidget), the qtbot.mouseClick (the QTest's, the widget's click), and the qtbot.waitSignal (the signal's wait, the timeout). The widget test is the state (the text, the value, the count) not the pixel - the button.text() is the state, the qtbot.mouseClick is the input, and the assert is the state's read. The waitSignal is the async's (the signal's wait, the timeout's) - the worker's done the wait's, and the assert is the result's. The qtbot is the test's fixture, and the widget test is the test's content - the state the test's, and the signal test (the next section) is the async's.

The QSignalSpy: The Signal's Count

from PyQt5.QtTest import QSignalSpy
from PyQt5.QtWidgets import QPushButton
import pytest

def test_clicked_emits(qtbot):
    button = QPushButton("Go")
    qtbot.addWidget(button)
    # the QSignalSpy: the signal's the spy (the count,
    # the arguments, the emit's the record)
    spy = QSignalSpy(button.clicked)
    # the click (the qtbot's mouse):
    qtbot.mouseClick(button, Qt.LeftButton)
    # the assert: the count (the emit's, the 1)
    assert spy.count() == 1
    # the spy's the argument (the emit's, the bool):
    # the spy[0] is the argument's list (the emit's)

# the QSignalSpy is the signal's the record (the count,
# the arguments) - the test's the signal's the proof
# (the emit's, the connect's) - the signal's the test's
# the QSignalSpy is the signal's the assert (the count,
# the argument), and the qtbot is the signal's the wait
# (the waitSignal's, the timeout's)

The QSignalSpy is the signal's record: the count (the emit's, the 1), the argument (the emit's, the bool), and the spy[0] is the argument's list. The test is the signal's proof (the emit's, the connect's) - the QSignalSpy is the signal's assert (the count, the argument), and the qtbot is the signal's wait (the waitSignal's, the timeout's). The widget test is the state (the text, the value), and the signal test is the emit (the count, the argument) - the two are the PyQt test's content, and the offscreen is the test's platform.

The Working Rules

  • The offscreen platform is the test's, the QT_QPA_PLATFORM=offscreen is the env var. The QApplication without the display (the CI's, the headless), the widget's logic runs (the signal, the slot, the state), and the pixel is the buffer (the no screen).
  • The qtbot fixture is the test's, the addWidget is the destroy's. The pytest-qt plugin (the pip install pytest-qt) provides the qtbot (the QApplication per test, the offscreen), and the addWidget is the widget's destroy after the test (the teardown's).
  • The widget test is the state, not the pixel. The button.text() is the state, the qtbot.mouseClick is the input (the QTest's), and the assert is the state's read (the text, the value, the count) - the pixel is the offscreen's buffer, not the test's.
  • The signal test is the QSignalSpy (the count, the argument) and the waitSignal (the wait, the timeout). The QSignalSpy is the emit's record (the count, the argument), and the qtbot.waitSignal is the async's wait (the timeout's) - the signal's proof is the spy's count, and the async's is the waitSignal's.
  • The main() guard is the test's import. The if __name__ == "__main__": (the first-window chapter's skeleton) makes the module importable (the test's import the widget's class, the loop not entered) - the test imports the widget, not the main's exec.

Summary

Testing a PyQt app is the pytest plus the offscreen platform: the QT_QPA_PLATFORM=offscreen (the QApplication without the display, the CI's, the headless) runs the widget in the loop (the signal, the slot, the state), and the test is the widget's state (the text, the value, the count) not the pixel. The pytest-qt plugin is the bridge: the qtbot fixture (the QApplication per test, the offscreen, the addWidget the widget's destroy), the qtbot.mouseClick (the QTest's, the input), and the qtbot.waitSignal (the signal's wait, the timeout). The QSignalSpy is the signal's record (the count, the argument), and the widget test is the state (the assert the read) while the signal test is the emit (the spy's count, the waitSignal's wait). The main() guard is the test's import (the module importable, the widget the test's, the loop not entered) - the two are the PyQt test's: the offscreen the platform, and the qtbot the fixture.