diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 461f4aba..8fc17803 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,21 +1,8 @@ 2.0 --- -New -=== - -* From this version onward, ``pytest-qt`` is licensed under the MIT license (`#134`_). - -* New ``qtmodeltester`` fixture to test ``QAbstractItemModel`` subclasses. - Thanks `@The-Compiler`_ for the initiative and port of the original C++ code - for ModelTester (`#63`_). - -.. _#134: https://github.com/pytest-dev/pytest-qt/issues/134 -.. _#63: https://github.com/pytest-dev/pytest-qt/pull/63 - - Breaking changes -================ +~~~~~~~~~~~~~~~~ With ``pytest-qt`` 2.0, we changed some defaults to values we think are much better. @@ -36,8 +23,26 @@ However, this required some backwards-incompatible changes: on timeouts. You can set ``qt_wait_signal_raising = false`` in your config to get back the old behaviour. + +New Features +~~~~~~~~~~~~ + +* From this version onward, ``pytest-qt`` is licensed under the MIT license (`#134`_). + +* New ``qtmodeltester`` fixture to test ``QAbstractItemModel`` subclasses. + Thanks `@The-Compiler`_ for the initiative and port of the original C++ code + for ModelTester (`#63`_). + +* New ``qtbot.waitUntil`` method, which continuously calls a callback until a condition + is met or a timeout is reached. Useful for testing some features in X window environments + due to its asynchronous nature. + +.. _#134: https://github.com/pytest-dev/pytest-qt/issues/134 +.. _#63: https://github.com/pytest-dev/pytest-qt/pull/63 + + Other Changes -============= +~~~~~~~~~~~~~ - Exceptions caught by ``pytest-qt`` in ``sys.excepthook`` are now also printed to ``stderr``, making debugging them easier from within an IDE. diff --git a/docs/index.rst b/docs/index.rst index abbd340e..93b4b2b1 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -20,6 +20,7 @@ pytest-qt tutorial logging signals + wait_until virtual_methods modeltester app_exit diff --git a/docs/wait_until.rst b/docs/wait_until.rst new file mode 100644 index 00000000..1aa78d41 --- /dev/null +++ b/docs/wait_until.rst @@ -0,0 +1,72 @@ +waitUntil: Waiting for arbitrary conditions +=========================================== + +.. versionadded:: 2.0 + +Sometimes your tests need to wait a certain condition which does not trigger a signal, for example +that a certain control gained focus or a ``QListView`` has been populated with all items. + +For those situations you can use :meth:`qtbot.waitUntil ` to +wait until a certain condition has been met or a timeout is reached. This is specially important +in X window systems due to their asynchronous nature, where you can't rely on the fact that the +result of an action will be immediately available. + +For example: + +.. code-block:: python + + def test_validate(qtbot): + window = MyWindow() + window.edit.setText('not a number') + # after focusing, should update status label + window.edit.setFocus() + assert window.status.text() == 'Please input a number' + + +The ``window.edit.setFocus()`` may not be processed immediately, only in a future event loop, which +might lead to this test to work sometimes and fail in others (a *flaky* test). + +A better approach in situations like this is to use ``qtbot.waitUntil`` with a callback with your +assertion: + + +.. code-block:: python + + def test_validate(qtbot): + window = MyWindow() + window.edit.setText('not a number') + # after focusing, should update status label + window.edit.setFocus() + def check_label(): + assert window.status.text() == 'Please input a number' + qtbot.waitUntil(check_label) + + +``qtbot.waitUntil`` will periodically call ``check_label`` until it no longer raises +``AssertionError`` or a timeout is reached. If a timeout is reached, the last assertion error +re-raised and the test will fail: + +:: + + _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + def check_label(): + > assert window.status.text() == 'Please input a number' + E assert 'OK' == 'Please input a number' + E - OK + E + Please input a number + + +A second way to use ``qtbot.waitUntil`` is to pass a callback which returns ``True`` when the +condition is met or ``False`` otherwise. It is usually terser than using a separate callback with +``assert`` statement, but it produces a generic message when it fails because it can't make +use of ``pytest``'s assertion rewriting: + +.. code-block:: python + + def test_validate(qtbot): + window = MyWindow() + window.edit.setText('not a number') + # after focusing, should update status label + window.edit.setFocus() + qtbot.waitUntil(lambda: window.edit.hasFocus()) + assert window.status.text() == 'Please input a number' \ No newline at end of file diff --git a/pytestqt/qtbot.py b/pytestqt/qtbot.py index 7f0ed278..67b7a99c 100644 --- a/pytestqt/qtbot.py +++ b/pytestqt/qtbot.py @@ -1,7 +1,8 @@ import functools import contextlib import weakref -from pytestqt.wait_signal import SignalBlocker, MultiSignalBlocker, SignalTimeoutError, SignalEmittedSpy +from pytestqt.wait_signal import SignalBlocker, MultiSignalBlocker, SignalTimeoutError, \ + SignalEmittedSpy from pytestqt.qt_compat import QtTest, QApplication @@ -74,11 +75,12 @@ class QtBot(object): .. automethod:: stopForInteraction .. automethod:: wait - **Signals** + **Signals and Events** .. automethod:: waitSignal .. automethod:: waitSignals .. automethod:: assertNotEmitted + .. automethod:: waitUntil **Raw QTest API** @@ -373,6 +375,73 @@ def assertNotEmitted(self, signal): assert_not_emitted = assertNotEmitted # pep-8 alias + def waitUntil(self, callback, timeout=1000): + """ + .. 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 an ``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 + + while True: + try: + result = callback() + except AssertionError: + if timed_out(): + raise + else: + if result not in (None, True, False): + msg = 'waitUntil() callback must return None, True or False, returned %r' + raise ValueError(msg % result) + + # 'assert' form + if result is None: + return + + # 'True/False' form + if result: + return + else: + assert not timed_out(), 'waitUntil timed out in %s miliseconds' % timeout + self.wait(10) + + wait_until = waitUntil # pep-8 alias # provide easy access to SignalTimeoutError to qtbot fixtures diff --git a/tests/test_wait_until.py b/tests/test_wait_until.py new file mode 100644 index 00000000..2011227a --- /dev/null +++ b/tests/test_wait_until.py @@ -0,0 +1,65 @@ +import pytest + + +def test_wait_until(qtbot, wait_4_ticks_callback, tick_counter): + tick_counter.start(100) + qtbot.waitUntil(wait_4_ticks_callback, 1000) + assert tick_counter.ticks >= 4 + + +def test_wait_until_timeout(qtbot, wait_4_ticks_callback, tick_counter): + tick_counter.start(200) + with pytest.raises(AssertionError): + qtbot.waitUntil(wait_4_ticks_callback, 100) + assert tick_counter.ticks < 4 + + +def test_invalid_callback_return_value(qtbot): + with pytest.raises(ValueError): + qtbot.waitUntil(lambda: []) + + +def test_pep8_alias(qtbot): + qtbot.wait_until + + +@pytest.fixture(params=['predicate', 'assert']) +def wait_4_ticks_callback(request, tick_counter): + """Parametrized fixture which returns the two possible callback methods that can be + passed to ``waitUntil``: predicate and assertion. + """ + if request.param == 'predicate': + return lambda: tick_counter.ticks >= 4 + else: + def check_ticks(): + assert tick_counter.ticks >= 4 + return check_ticks + + +@pytest.yield_fixture +def tick_counter(): + """ + Returns an object which counts timer "ticks" periodically. + """ + from pytestqt.qt_compat import QtCore + + class Counter: + + def __init__(self): + self._ticks = 0 + self.timer = QtCore.QTimer() + self.timer.timeout.connect(self._tick) + + def start(self, ms): + self.timer.start(ms) + + def _tick(self): + self._ticks += 1 + + @property + def ticks(self): + return self._ticks + + counter = Counter() + yield counter + counter.timer.stop() diff --git a/tox.ini b/tox.ini index 632cb7f7..17e7f70f 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,7 @@ [tox] # note that tox expects interpreters to be found at C:\PythonXY, # with XY being python version ("27" or "34") for instance -envlist = py{27,34}-pyqt4, py{34,35}-pyqt5, py{26,27,34,35}-pyside, docs +envlist = py{27,34}-pyqt4, py{34,35}-pyqt5, py{27,34,35}-pyside, docs [testenv] deps=pytest