PyQt的一个UI单元测试框架思路

PyQt的一个UI单元测试框架思路

丁果丁果

1、思路

PyQt是个 相当灵活的UI框架,不过,这个Qt的Python版本一直没有一个好用的针对UI的单元测试工具。

PyQt里的逻辑层都是采用信号槽的方式连接的,我们可以通过拦截并重建信号槽的方式,动态生成一个单元测试的脚本。按这个思路写了一个单元测试的工具。如果需要的人多的话,我就把这个模块做成一个单元测试的框架。

2、demo

一个好用的工具应该是非侵入式的,接口合理且命名规范,符合大多数人使用习惯的,我认为这样一个PyQt的单元测试用例应该长这样子。

def knife_into_view(view_instance):
    knife = Knife(view_instance)
    knife.view.target_button.clicked()
    sleep(10)
    text = view_instance._view.show.text()
    assert text == "target"

def test_start_main_ui()
    app = QApplication(sys.argv)
    view = View()
    threading.Thread(target=knife_into_view, args=(view,)).start()
    view.show()
    sys.exit(app.exec_())

主要的动作就是,按照源代码中按钮的信号槽连接的调用链,触发按钮的点击实践,执行点击按钮后的逻辑。

在上述代码中,首先单元测试的入口是启动界面的代码,也就是test_start_main_ui函数,这段代码是最简单的一个PyQt的界面启动代码,其中不一样的是启动了一个线程用于执行单元测试。

而单元测试的函数是这样的,首先初始化一个参数为view的实例的类,这个类用于拦截信号槽,并执行信号动作,我把这个类命名为Knife。

接下来就是执行view下面的target_button的点击事件,这一系列的成员函数是根据原始view里面的信号槽连接代码动态生成的,后面会讲具体方法。

触发点击事件后,结果显示在一个label上,assert一下这个结果是否正确就行了。

GIF是一个演示实例,QLineEdit里面输入一个数,按一下-1s的按钮(QPushButton),会在最右的label上将该数减一之后显示,Demo GUI部分的代码看这里

3、Qt与PyQt

Qt中信号槽是个不可或缺的概念,和元对象系统之类的东西组成了Qt的基础组件。但对于起源于上古时代的Qt,这些东西很多是为了弥补当时C++的不足,对于Python这种强类型的语言来说并不是那么不可或缺,比如信号槽本质上就是观察者模式,完全可以自己实现一个,我自己的实现可以看这里

而Qt的元对象系统是一个代码生成框架,给C++提供了自省的能力,但Python这种动态语言在语言层面上就有强大的自省功能,所以我平时用PyQt的时候一般就把它当一个UI库用,其他的东西比如线程、信号槽、串口等都用Python版本的。

4、拦截的实现

在PyQt中,信号槽连接的写法一般是这样的。

signal_instance.connect(slot_name)

所以,我这个版本的拦截信号槽的功能的实现思路就是用正则匹配源代码,从符合这一模式的

语句中解析出信号的发送端和槽函数,将槽函数重新添加进新的生成的自定义信号槽中。

信号槽重连接

之前说Python的自省能力强大,现在有个非常实际的例子就是,在Python中可以动态的获取源代码。这个功能用到的Python 标准库中的inpect库,示例如下。

import inspect
print inspect.getsource(inspect.isclass)

这段代码的功能是将inspect库中的isclass函数的源代码打印出来。inspect模块是个很神奇的模块,如果你对闭包和协程不理解的话也可以调用该模块中的相应代码看看。

在程序中还用到了__code__.co_names这个东西,用来高效的查看函数的源代码里有没有"connect"字符串。

5、程序结构

这里是部分程序源代码,省略了代码细节,源代码可以看这个git仓库

class SubNode(object):
    def __init__(self):
        self.funs = []

    def __setattr__(self, key, value):
        self.__dict__[key] = value

    def __call__(self, *args, **kwargs):
        [f() for f in self.funs]


class FailAttr(ValueError):
    """ can't get attr correct"""


class Knife(object):
    def __new__(cls, widget_instance):
        pars = cls.parser_slots(widget_instance)
        cls.recover_slots(pars, widget_instance)
        return object.__new__(cls)

    @staticmethod
    def parser_slots(widget_instance):
        #codes

    @classmethod
    def recover_slots(cls, pars, widget_instance):
        def get_attrs(instance, attrs):
            #codes

        def set_attrs(cls, attrs, fun):
            #codes
        #codes
        return cls

这里用一个叫Knife的类来实现,在重建新的信号函数的时候我希望信号函数的调用方式和程序源代码里的调用方式保持一致,这里就得采用动态的生成方式。而涉及到类成员的动态生成,采取一种不一样的写法比较好,比如把生成的时间从__init__方法中提前到__new__方法中。

widget_instance就是包含信号槽的类,因为我写GUI都是采用MVC的方式,需要导出并拦截的信号槽都在一个类里面,这个类传入的时候已经是个实例了。动态解析该实例源代码,并动态生成新的信号去装载信号槽。

其中,还有个问题,有些调用可能嵌套的好几层,比如像这样。

self.mother.father.son.dog.clicked()

这样的操作需要用递归生成,就像这样。

      def set_attrs(cls, attrs, fun):
            if attrs:
                now_attrs = attrs[0]
                if not hasattr(cls, now_attrs):
                    setattr(cls, now_attrs, SubNode())
                return set_attrs(getattr(cls, now_attrs), attrs[1:], fun)
            else:
                cls.funs.append(fun)

调用链中自定义生成的节点类为SubNode,槽函数如果动态获取不到时,会返回一个自定义异常FailAttr。

具体的请看Github

6、知识点详解

这一栏列出一些特殊的知识点。

  • getattr,setattr,hasattr:动态的获取对象的方法,给一个对象动态的添加方法,判断一个对象是否含有某方法。
  • __new__魔法方法:这个方法在__init__之前,是真正的类初始化函数。要注意的是new方法需要返回的是类实例,就像源代码中的写法。而在__new__方法中是使用不了实例方法的,得用staticmethod和classmethod装饰器去修饰。
  • staticmethod,classmethod:都是类方法的装饰器,只不过classmethod装饰过的成员方法第一个参数是cls,staticmethod装饰过的东西不引入这个参数,相当于一个纯函数,叫做静态方法。这里的两个函数都可以用classmethod装饰,不过parser_slots函数中用不到cls,我就用staticmethod装饰了。
  • 用类方法去区别一些特殊操作,这一方式最常见的就是Django的ORM,将数据库操作和表单的定义分为类方法和成员方法。所以大家理解不了类方法和元类的时候可以去研究下Django的ORM。
  • 列表生成式和正则表达式之类的就不解释了。

感觉篇幅有点长,其他的细节如果有需要的话在下一篇文章里解释。如果大家真需要,可以考虑专门搞成一个开源项目。


GitHub仓库:

lidingke/fiberGeometry

参考文献:

PyQt信号槽的Python实现

我的专栏:

python杂七杂八的使用经验

文章被以下专栏收录
6 条评论
推荐阅读