利用 pytest 玩转数据驱动测试框架

> 本文选自测试人社区



pytest架构是什么?  







首先,来看一个 pytest 的例子:




  *   *   * 




    def test_a():  

      print(123)




  *   *   *   *   * 




    collected 1 item  

    test_a.py .                                                                                                            [100%]  

    ============ 1 passed in 0.02s =======================




输出结果很简单:收集到 1 个用例,并且这条测试用例执行通过。


此时思考两个问题:


  1. pytest 如何收集到用例的?


  2. pytest 如何把 python 代码,转换成 pytest 测试用例(又称 item) ?



pytest如何做到收集到用例的?  





###  




###  


###


这个很简单,遍历执行目录,如果发现目录的模块中存在符合“ pytest 测试用例要求的 python 对象”,就将之转换为 pytest 测试用例。




比如编写以下 hook 函数:




  *   *   * 




    def pytest_collect_file(path, parent):  

        print("hello", path)




  *   *   *   *   * 




    hello C:\Users\yuruo\Desktop\tmp\tmp123\tmp\testcase\__init__.py  

    hello C:\Users\yuruo\Desktop\tmp\tmp123\tmp\testcase\conftest.py  

    hello C:\Users\yuruo\Desktop\tmp\tmp123\tmp\testcase\test_a.py




会看到所有文件内容。




如何构造pytest的item?  






pytest 像是包装盒,将 python 对象包裹起来,比如下图:






当写好 python 代码时:




  *   *   * 




    def test_a:  

        print(123)




会被包裹成 Function :




  * 




    <Function test_a>




可以从 hook 函数中查看细节:




  *   *   * 




    def pytest_collection_modifyitems(session, config, items):  

        pass






于是,理解包裹过程就是解开迷题的关键。pytest 是如何包裹 python 对象的?


下面代码只有两行,看似简单,但暗藏玄机!




  *   *   * 




    def test_a:  

        print(123)




把代码位置截个图,如下:  




我们可以说,上述代码是处于“testcase包”下的 “test_a.py模块”的“test_a函数”, pytest 生成的测试用例也要有这些信息:


处于“testcase包”下的 “test_a.py模块”的“test_a测试用例:


把上述表达转换成下图:


pytest 使用 parent 属性表示上图层级关系,比如 Module 是 Function 的上级, Function 的 parent 属性如下:  


  *   *   * 




    <Function test_a>:  

      parent: <Module test_parse.py>




当然 Module 的 parent 就是 Package:




  *   *   * 




    <Module test_parse.py>:  

      parent: <Package tests>




> 注意大小写:Module 是 pytest 的类,用于包裹 python 的 module 。Module 和 module 表示不同意义。




这里科普一下,python 的 package 和 module 都是真实存在的对象,你可以从 obj 属性中看到,比如 Module 的 obj

属性如下:






如果理解了 pytest 的包裹用途,非常好!我们进行下一步讨论:如何构造 pytest 的 item ?




以下面代码为例:




  *   *   * 




    def test_a:  

        print(123)




构造 pytest 的 item ,需要:


  1. 构建 Package


  2. 构建 Module


  3. 构建 Function


以构建 Function 为例,需要调用其`from_parent()`方法进行构建,其过程如下图:






从函数名`from_parent`,就可以猜测出,“构建 Function”一定与其 parent 有不小联系!又因为 Function 的 parent

是 Module :根据下面 Function 的部分代码(位于 python.py 文件):




  *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   * 




    class Function(PyobjMixin, nodes.Item):  

        # 用于创建测试用例  

        @classmethod  

        def from_parent(cls, parent, **kw):  

            """The public constructor."""  

            return super().from_parent(parent=parent, **kw)  

        # 获取实例  

        def _getobj(self):  

            assert self.parent is not None  

            return getattr(self.parent.obj, self.originalname)  # type: ignore[attr-defined]  

        # 运行测试用例  

        def runtest(self) -> None:  

            """Execute the underlying test function."""  

            self.ihook.pytest_pyfunc_call(pyfuncitem=self)




得出结论,可以利用 Module 构建 Function!其调用伪代码如下:




  * 




    Function.from_parent(Module)




既然可以利用 Module 构建 Function, 那如何构建 Module ?


当然是利用 Package 构建 Module!




  * 




    Module.from_parent(Package)




既然可以利用 Package 构建 Module 那如何构建 Package ?


别问了,快成套娃了,请看下图调用关系:






pytest 从 Config 开始,层层构建,直到 Function !Function 是 pytest 的最小执行单元。  

如何手动构建item?  




手动构建 item 就是模拟 pytest 构建 Function 的过程。也就是说,需要创建 Config ,然后利用 Config 创建 Session

,然后利用 Session 创建 Package ,…,最后创建 Function。





其实没这么复杂, pytest 会自动创建好 Config, Session和 Package ,这三者不用手动创建。






比如编写以下 hook 代码,打断点查看其 parent 参数:




  *   *   * 




    def pytest_collect_file(path, parent):  

        pass






如果遍历的路径是某个包(可从path参数中查看具体路径),比如下图的包:  




其 parent 参数就是 Package ,此时可以利用这个 Package 创建 Module :






编写如下代码即可构建 pytest 的 Module ,如果发现是 yaml 文件,就根据 yaml 文件内容动态创建 Module 和 module :




  *   *   *   *   *   *   *   *   *   *   *   *   * 




    from _pytest.python import Module, Package  

    def pytest_collect_file(path, parent):  

        if path.ext == ".yaml":  

            pytest_module = Module.from_parent(parent, fspath=path)  

            # 返回自已定义的 python module  

            pytest_module._getobj = lambda : MyModule  

            return pytest_module




需要注意,上面代码利用猴子补丁改写了 _getobj 方法,为什么这么做?


Module 利用 _getobj 方法寻找并导入(import语句) path 包下的 module ,其源码如下:




  *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   * 




    # _pytest/python.py Module  

    class Module(nodes.File, PyCollector):  

        def _getobj(self):  

            return self._importtestmodule()  

    def _importtestmodule(self):  

        # We assume we are only called once per module.  

        importmode = self.config.getoption("--import-mode")  

        try:  

            # 关键代码:从路径导入 module  

            mod = import_path(self.fspath, mode=importmode)   

        except SyntaxError as e:  

            raise self.CollectError(  

                ExceptionInfo.from_current().getrepr(style="short")  

            ) from e  

            # 省略部分代码...




但是,如果使用数据驱动,即用户创建的数据文件 test_parse.yaml ,它不是 .py 文件,不会被 python 识别成 module (只有

.py 文件才能被识别成 module)。


这时,就不能让 pytest 导入(import语句) test_parse.yaml ,需要动态改写 _getobj ,返回自定义的 module !




因此,可以借助 lambda 表达式返回自定义的 module :




  * 




    lambda : MyModule



如何自定义module  





这就涉及元编程技术:动态构建 python 的 module ,并向 module 中动态加入类或者函数:




  *   *   *   *   *   *   *   *   *   *   *   *   * 




    import types  

    # 动态创建 module  

    module = types.ModuleType(name)  

    def function_template(*args, **kwargs):  

        print(123)  

    # 向 module 中加入函数  

    setattr(module, "test_abc", function_template)




综上,将自己定义的 module 放入 pytest 的 Module 中即可生成 item :




  *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   * 




    # conftest.py  

    import types  

    from _pytest.python import Module  

    def pytest_collect_file(path, parent):  

        if path.ext == ".yaml":  

            pytest_module = Module.from_parent(parent, fspath=path)  

            # 动态创建 module  

            module = types.ModuleType(path.purebasename)  

            def function_template(*args, **kwargs):  

                print(123)  

            # 向 module 中加入函数  

            setattr(module, "test_abc", function_template)  

            pytest_module._getobj = lambda: module  

            return pytest_module




创建一个 yaml 文件,使用 pytest 运行:






  *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   * 




    ======= test session starts ====  

    platform win32 -- Python 3.8.1, pytest-6.2.4, py-1.10.0, pluggy-0.13.1  

    rootdir: C:\Users\yuruo\Desktop\tmp  

    plugins: allure-pytest-2.8.11, forked-1.3.0, rerunfailures-9.1.1, timeout-1.4.2, xdist-2.2.1  

    collected 1 item  

    test_a.yaml 123  

    .  

    ======= 1 passed in 0.02s =====  

    PS C:\Users\yuruo\Desktop\tmp>




现在停下来,回顾一下,我们做了什么?


借用 pytest hook ,将 .yaml 文件转换成 python module。






作为一个数据驱动测试框架,我们没做什么?


没有解析 yaml 文件内容!上述生成的 module ,其内的函数如下:




  *   *   * 




    def function_template(*args, **kwargs):  

        print(123)




只是简单打印 123 。数据驱动测试框架需要解析 yaml 内容,根据内容动态生成函数或类。比如下面 yaml 内容:




  *   *   * 




    test_abc:  

      - print: 123




表达的含义是“定义函数 test_abc,该函数打印 123”。




> 注意:关键字含义应该由你决定,这里仅给一个 demo 演示!




可以利用 `yaml.safe_load` 加载 yaml 内容,并进行关键字解析,其中`path.strpath`代表 yaml 文件的地址:




  *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   * 




    import types  

    import yaml  

    from _pytest.python import Module  

    def pytest_collect_file(path, parent):  

        if path.ext == ".yaml":  

            pytest_module = Module.from_parent(parent, fspath=path)  

            # 动态创建 module  

            module = types.ModuleType(path.purebasename)  

            # 解析 yaml 内容  

            with open(path.strpath) as f:  

                yam_content = yaml.safe_load(f)  

                for function_name, steps in yam_content.items():  



                    def function_template(*args, **kwargs):  

                        """  

                        函数模块  

                        """  

                        # 遍历多个测试步骤 [print: 123, print: 456]  

                        for step_dic in steps:  

                            # 解析一个测试步骤 print: 123  

                            for step_key, step_value in step_dic.items():  

                                if step_key == "print":  

                                    print(step_value)  



                    # 向 module 中加入函数  

                    setattr(module, function_name, function_template)  

            pytest_module._getobj = lambda: module  

            return pytest_module




上述测试用例运行结果如下:




  *   *   *   *   *   *   *   *   *   *   *   *   *   *   * 




    === test session starts ===  

    platform win32 -- Python 3.8.1, pytest-6.2.4, py-1.10.0, pluggy-0.13.1  

    rootdir: C:\Users\yuruo\Desktop\tmp  

    plugins: allure-pytest-2.8.11, forked-1.3.0, rerunfailures-9.1.1, timeout-1.4.2, xdist-2.2.1  

    collected 1 item  

    test_a.yaml 123  

    .  

    === 1 passed in 0.02s ====




当然,也支持复杂一些的测试用例:




  *   *   *   *   *   *   *   *   *   *   * 




    test_abc:  

      - print: 123  

      - print: 456  

    test_abd:  

      - print: 123  

      - print: 456




其结果如下:




  *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   *   * 




    == test session starts ==  

    platform win32 -- Python 3.8.1, pytest-6.2.4, py-1.10.0, pluggy-0.13.1  

    rootdir: C:\Users\yuruo\Desktop\tmp  

    plugins: allure-pytest-2.8.11, forked-1.3.0, rerunfailures-9.1.1, timeout-1.4.2, xdist-2.2.1  

    collected 2 items  

    test_a.yaml 123  

    456  

    .123  

    456  

    .  

    == 2 passed in 0.02s ==




利用pytest创建数据驱动测试框架就介绍到这里啦,希望能给大家带来一定的帮助。大家有什么不懂的地方或者有疑惑也可以留言讨论哈,让我们共同进步呦!




 ** _ 

来霍格沃兹测试开发学社,学习更多软件测试与测试开发的进阶技术,知识点涵盖web自动化测试 app自动化测试、接口自动化测试、测试框架、性能测试、安全测试、持续集成/持续交付/DevOps,测试左移、测试右移、精准测试、测试平台开发、测试管理等内容,课程技术涵盖bash、pytest、junit、selenium、appium、postman、requests、httprunner、jmeter、jenkins、docker、k8s、elk、sonarqube、jacoco、jvm-sandbox等相关技术,全面提升测试开发工程师的技术实力


视频资料领取:https://qrcode.testing-studio.com/f?from=jianshu&url=https://ceshiren.com/t/topic/15844

点击查看更多信息

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,636评论 5 468
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,890评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,680评论 0 330
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,766评论 1 271
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,665评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,045评论 1 276
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,515评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,182评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,334评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,274评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,319评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,002评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,599评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,675评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,917评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,309评论 2 345
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,885评论 2 341

推荐阅读更多精彩内容