如何测试 PyQt/PySide 信号?

How to test PyQt/PySide signals?

我正在尝试测试一个 class,它从另一个图书馆获取 "events" 并将它们重新分发为 Signals/pyqtSignals。

我正在尝试通过以下方式对其进行测试:

  1. 正在连接处理程序。
  2. 执行一些应该触发事件的操作(因此,Signal/pyqtSignal 被发出,应该调用处理程序)。
  3. 正在检查是否调用了处理程序。

但是处理程序永远不会被调用。或者至少在我检查它时它没有被调用。如果我 运行 在控制台中一行一行地测试函数,测试成功。

我认为问题在于信号在测试函数完成后才得到处理,但如果是这样,我将如何测试它们?

这是我如何尝试测试的示例:

from mock import Mock


def test_object():
    obj = MyObject()
    handler = Mock()

    # This connects handler to a Signal/pyqtSignal
    obj.connect('event_type', handler)

    # This should trigger that signal
    obj.do_some_stuff()

    # This fails (call_count is 0)
    assert handler.call_count = 1

    # This will also fail (call_count is 0)
    QtCore.QCoreApplication.instance().processEvents()
    assert handler.call_count = 1

您正在连接的对象可能会在收到信号之前被垃圾回收,因为它们是在 test_object 函数的范围内创建的,可能没有时间完成。

编辑:示例代码:

from PySide import QtCore, QtGui

class MyObject(QtCore.QObject):
    fired = QtCore.Signal()
    def __init__(self, parent=None):
        super(MyObject, self).__init__(parent)

    def do_some_stuff(self):
        self.fired.emit()

class Mock(object):
    def __init__(self, parent=None):
        self.call_count = 0

    def signalReceived(self):
        self.call_count+=1
        print "Signal received, call count: %d" % self.call_count

def test_object():
    obj = MyObject()
    handler = Mock()
    obj.fired.connect(handler.signalReceived)
    obj.do_some_stuff()
    assert handler.call_count == 1
    QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.AllEvents, 50)


test_object()

您可能需要在等待发送信号时运行一个本地事件循环。

由于您似乎在使用 pytest(如果没有,您应该考虑使用!),我推荐 pytest-qt 插件。有了它,您可以像这样编写测试:

def test_object(qtbot):
    obj = MyObject()

    with qtbot.waitSignal(obj.event_type, raising=True):
        obj.do_some_stuff()

当您调用 do_some_stuff() 时,应用程序甚至没有实例化。如果没有应用程序,那么它将在您第一次调用 QCoreApplication.instance()

时创建

所以你所要做的就是在函数的开头插入一行来实例化应用程序。您不需要事件处理方法,因为信号不是事件。事件是 gui 事件。至少在默认情况下,信号会被立即调用。

这来自 Qt 文档:

When a signal is emitted, the slots connected to it are usually executed immediately, just like a normal function call. When this happens, the signals and slots mechanism is totally independent of any GUI event loop. Execution of the code following the emit statement will occur once all slots have returned. The situation is slightly different when using queued connections; in such a case, the code following the emit keyword will continue immediately, and the slots will be executed later.

http://doc.qt.io/qt-5/signalsandslots.html

def test_object():
    app = QtCore.QCoreApplication.instance() # isntantiate the app
    obj = MyObject()
    handler = Mock()

    # This connects handler to a Signal/pyqtSignal
    obj.connect('event_type', handler)

    # This should trigger that signal
    obj.do_some_stuff()

    # This fails (call_count is 0)
    assert handler.call_count = 1

    # This will also fail (call_count is 0)
    assert handler.call_count = 1

你为什么不用python的单元测试模块? 我也只是想测试 PySide 信号,然后 class 编辑了 TestCase class 来支持它。您可以 subclass this 并使用 assertSignalReceived 方法来测试是否调用了信号。它还测试参数。 让我知道它对你有用!我还没怎么用过。

示例:

class TestMySignals(UsesQSignals):
    def test_my_signal(self):
        with self.assertSignalReceived(my_signal, expected_args):
            my_signal.emit(args)

使用 QSignal class,有一点上下文管理器

class SignalReceiver:
    def __init__(self, test, signal, *args):
        self.test = test
        self.signal = signal
        self.called = False
        self.expected_args = args

    def slot(self, *args):
        self.actual_args = args
        self.called = True

    def __enter__(self):
        self.signal.connect(self.slot)

    def __exit__(self, e, msg, traceback):
        if e: 
            raise e, msg
        self.test.assertTrue(self.called, "Signal not called!")
        self.test.assertEqual(self.expected_args, self.actual_args, """Signal arguments don't match!
            actual:   {}
            expected: {}""".format(self.actual_args, self.expected_args))


class UsesQSignals(unittest.TestCase):
    def setUp(self):
        from PySide.QtGui import QApplication

        '''Creates the QApplication instance'''
        _instance = QApplication.instance()
        if not _instance:
            _instance = QApplication([])
        self.app = _instance

    def tearDown(self):
        '''Deletes the reference owned by self'''
        del self.app

    def assertSignalReceived(self, signal, args):
        return SignalReceiver(self, signal, args)

我 运行 遇到了同样的问题,但我没有使用 pytest,因此无法使用推荐的插件。

我的 hacky 解决方案比预期的效果更好,它是按照我想要的方式设置被测小部件,实际启动应用程序,然后在小部件中有一个特殊选项以在方法是时调用 QApplication.quit()完成测试。到目前为止,我进行了单元测试,其中我使用 pyqt5 信号调用函数 10 次,使用此方法没有问题。

对我来说,这是在测试使用 worker 和信号的数据加载 class。这是代码的简化版本。首先下面是被测的class

from PyQt5.QtWidgets import QWidget, QApplication
from PyQt5.QtCore import QTimer


class LoadWidget(QWidget):
    def __init__(self, *args, test_mode=False, **kwargs):
        super(LoadWidget, self).__init__(*args, **kwargs)
        self.obj = ''

        if test_mode:
            self.timer = QTimer()
            self.timer.setSingleShot(True)
            self.timer.timeout.connect(lambda: QApplication.quit())
            self.timer.start(100)

    def load(fname):
        # The following two lines happens in a worker and a signal calls  
        # another class method on completion to deposit the loaded data  
        # in self.obj, but for brevity I will just write it out here
        with open(fname) as f:
            self.obj = f.read()

然后是测试本身。

import unittest
import random
import string
from PyQt5.QtWidgets import QApplication


ctx = QApplication([])


class LoadTests(unittest.TestCase):
    def test_load_file(self):
        test_dat = ''.join(random.choice(string.ascii_lowercase) for _ in range(10))
        with open("test.txt", 'w') as f:
            f.write(test_dat)
        w = LoadWidget(test_mode=True)
        w.load("test.txt")
        ctx.exec_()
        self.assertEqual(test_dat, w.obj)

编辑: 在我的示例中实现此功能后,我 运行 遇到另一个问题,事件循环未被调用,因为加载函数直到调用应用程序退出才结束。这影响了我的一些测试。这可以通过将 quit 调用添加到计时器来缓解,我正在更新我的代码以反映这一点。

进一步编辑: 计时器必须在 single-shot 模式下调用,否则它将在进一步的测试中继续调用并导致难以诊断的问题。由于它是一个计时器,因此可以在 class 构造函数中进行设置,从而允许它在多个方法之间共享。

最后的注释: 当我开始 运行 在套件中进行许多测试并生成许多应用程序时,在 setUp 中创建 QApplication 会导致奇怪的崩溃。通过将其移动到文件的主体并调用全局版本解决了这个问题。

最后最后的笔记 使用计时器终止应用程序甚至不需要修改被测试的小部件。在您的测试中启动应用程序之前,您可以简单地创建一个函数来将计时器粘贴到它上面。这简化了事情,并允许通过多次启动应用程序在单个实例上多次 运行 涉及信号的函数。

import unittest
import random
import string
from PyQt5.QtWidgets import QApplication
from PyQt5.QtCore import QTimer


ctx = QApplication([])


def add_timer(w):
    w.timer = QTimer()
    w.timer.setSingleShot(True)
    w.timer.timeout.connect(lambda: QApplication.quit())
    w.timer.start(100)


class LoadTests(unittest.TestCase):
    def test_load_file(self):
        test_dat = ''.join(random.choice(string.ascii_lowercase) for _ in range(10))
        with open("test.txt", 'w') as f:
            f.write(test_dat)
        w = LoadWidget(test_mode=True)

        # Load the file
        w.load("test.txt")
        add_timer(w)
        ctx.exec_()

        # Demonstrate how one would load multiple times
        w.load("test.txt")
        add_timer(w)
        ctx.exec_()

        self.assertEqual(test_dat, w.obj)