Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 20 additions & 15 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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.
Expand Down
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ pytest-qt
tutorial
logging
signals
wait_until
virtual_methods
modeltester
app_exit
Expand Down
72 changes: 72 additions & 0 deletions docs/wait_until.rst
Original file line number Diff line number Diff line change
@@ -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 <pytestqt.plugin.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'
73 changes: 71 additions & 2 deletions pytestqt/qtbot.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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**

Expand Down Expand Up @@ -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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this should be an argument?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thought about it, but didn't think it would end up being necessary... perhaps we should wait until someone asks for it?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds like a plan 😉


wait_until = waitUntil # pep-8 alias


# provide easy access to SignalTimeoutError to qtbot fixtures
Expand Down
65 changes: 65 additions & 0 deletions tests/test_wait_until.py
Original file line number Diff line number Diff line change
@@ -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()
2 changes: 1 addition & 1 deletion tox.ini
Original file line number Diff line number Diff line change
@@ -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
Expand Down