Search code examples
pythonasynchronouspytestsignals-slotspytest-qt

How to check response to a signal


In a subclass of QFileDialog there is a method, on_dir_entered, which should be called when the QFileDialog's signal directoryEntered fires, thus:

self.directoryEntered.connect(self.on_dir_entered)

The problem is that a signal takes a non-negligible time to take effect. Originally I was inspired by this answer by eyllanesc, a notable PyQt5 expert. With an isolated test this sort of technique using QTimer.singleShot() can work, although I had vague doubts about it from the beginning. And indeed, it turns out on my machine that I get "test leakage" with this sort of thing, particularly when there is more than one such test method: strange errors apparently occurring outside the tests themselves:

TEARDOWN ERROR: Exceptions caught in Qt event loop:

... so I went back to the pytest-qt docs and found that there are various methods available beginning wait... seemingly to cater to the problem of signals or other events taking a non-negligible time to have effect. So I made a few tries to test signal directoryEntered:

def test_directoryEntered_triggers_on_dir_entered(request, qtbot, tmpdir):
    project = mock.Mock()
    project.main_window = QtWidgets.QWidget()
    project.home_dir_path = pathlib.Path(str(tmpdir))
    fd = save_project_dialog_class.SaveProjectDialog(project)
    with mock.patch.object(fd, 'on_dir_entered') as mock_entered:
        fd.directoryEntered.emit('dummy')
    qtbot.waitSignal(fd.directoryEntered, timeout=1000)
    mock_entered.assert_called_once()

and then

def test_directoryEntered_triggers_on_dir_entered(qtbot, tmpdir):
    project = mock.Mock()
    project.main_window = QtWidgets.QWidget()
    project.home_dir_path = pathlib.Path(str(tmpdir))
    fd = SaveProjectDialog(project)
    qtbot.waitSignal(fd.directoryEntered, timeout=1000)
    with mock.patch.object(fd, 'on_dir_entered') as mock_entered:
        fd.directoryEntered.emit('dummy')
    mock_entered.assert_called_once()

and then

def test_directoryEntered_triggers_on_dir_entered(qtbot, tmpdir):
    project = mock.Mock()
    project.main_window = QtWidgets.QWidget()
    project.home_dir_path = pathlib.Path(str(tmpdir))
    fd = SaveProjectDialog(project)
    with mock.patch.object(fd, 'on_dir_entered') as mock_entered:
        fd.directoryEntered.emit('dummy')
    qtbot.wait(1000)
    mock_entered.assert_called_once()

All fail: the method is called 0 times.

I also tried:

def test_directoryEntered_triggers_on_dir_entered(qtbot, tmpdir):
    project = mock.Mock()
    project.main_window = QtWidgets.QWidget()
    project.home_dir_path = pathlib.Path(str(tmpdir))
    fd = SaveProjectDialog(project)
    with mock.patch.object(fd, 'on_dir_entered') as mock_entered:
            fd.directoryEntered.emit('dummy')
    def check_called():
        mock_entered.assert_called_once()
    qtbot.waitUntil(check_called)

... this times out (default 5000 ms). I have double-checked and triple-checked that the code setting up connect on this signal is executed.

Later

By putting a print statement in the called slot (on_dir_entered) I now see what the problem is: despite the with mock.patch... line, the method is not being mocked!

At my low level of knowledge of mocking etc. I am tending to assume that this is because of the fact of using a signal with emit() to trigger the event: I can't think of another explanation.

NB this signal is fired "naturally" by one or two events in a QFileDialog (such as clicking the "go to parent directory" QToolButton). Maybe you have to do it that way... So I tried this:

def test_directoryEntered_triggers_on_dir_entered(request, qtbot, tmpdir):
    project = mock.Mock()
    project.main_window = QtWidgets.QWidget()
    project.home_dir_path = pathlib.Path(str(tmpdir))
    fd = save_project_dialog_class.SaveProjectDialog(project)
    to_parent_button = fd.findChild(QtWidgets.QToolButton, 'toParentButton')
    print(f'qtbot {qtbot} type(qtbot) {type(qtbot)}')
    with mock.patch.object(SaveProjectDialog, 'on_dir_entered') as mock_entered:
        qtbot.mouseClick(to_parent_button, QtCore.Qt.LeftButton)
    def check_called():
        mock_entered.assert_called_once()
    qtbot.waitUntil(check_called, timeout=1000)

Time out. 0 calls. Again I was able to ascertain that the real method is being called, and the patch is not working.

What am I doing wrong and is there a way to test this with something from pytest-qt?


Solution

  • I think I may have found the answer. Experts in pytest-qt may like to comment.

    I think I worked out (this is probably very obvious) that the problem in the above attempts using qtbot.waitUntil() was that the patch I had set up no longer applied once the execution left the context manager block, and the call to the method was, indeed, delayed, which was the whole point.

    A whole-test-method decorator patch is therefore one approach (or else retain the indent after setting up the patch context manager...). I found that both the following passed, and neither was a false positive (i.e. they failed if I commented out the app code line setting up the connect):

    @mock.patch('SaveProjectDialog.on_dir_entered')
    def test_directoryEntered_signal_triggers_on_dir_entered(mock_entered, request, qtbot, tmpdir):
        project = mock.Mock()
        project.main_window = QtWidgets.QWidget()
        project.home_dir_path = pathlib.Path(str(tmpdir))
        fd = SaveProjectDialog(project)
        fd.directoryEntered.emit('dummy')
        def check_called():
            mock_entered.assert_called_once()
        qtbot.waitUntil(check_called, timeout=1000)
        
    @mock.patch('SaveProjectDialog.on_dir_entered')
    def test_on_click_toParentButton_triggers_on_dir_entered(mock_entered, request, qtbot, tmpdir):
        project = mock.Mock()
        project.main_window = QtWidgets.QWidget()
        project.home_dir_path = pathlib.Path(str(tmpdir))
        fd = SaveProjectDialog(project)
        to_parent_button = fd.findChild(QtWidgets.QToolButton, 'toParentButton')
        qtbot.mouseClick(to_parent_button, QtCore.Qt.LeftButton)
        def check_called():
            mock_entered.assert_called_once()
        qtbot.waitUntil(check_called, timeout=1000)
    

    Update
    It turns out that using qtbot.waitUntil is probably not necessary, and such tests can usually pass by using QCoreApplication.processEvents(). Thus the second test above would be rewritten:

    @mock.patch('SaveProjectDialog.on_dir_entered')
    def test_on_click_toParentButton_triggers_on_dir_entered(mock_entered, request, qtbot, tmpdir):
        project = mock.Mock()
        project.main_window = QtWidgets.QWidget()
        project.home_dir_path = pathlib.Path(str(tmpdir))
        fd = SaveProjectDialog(project)
        to_parent_button = fd.findChild(QtWidgets.QToolButton, 'toParentButton')
        qtbot.mouseClick(to_parent_button, QtCore.Qt.LeftButton)
        QtCore.QCoreApplication.processEvents()
        mock_entered.assert_called_once()