全面实现HttpRunner并局部优化(一)

为了充分了解HttpRunner的设计思想,决定通过实现其功能,加深印象。HttpRunner中文文档:https://cn.httprunner.org/。本文仅记录实现步骤与思想,细节还需要自己去研究哟~

其核心特性在官网显示如下:

  • 继承 Requests 的全部特性,轻松实现 HTTP(S) 的各种测试需求
  • 采用 YAML/JSON 的形式描述测试场景,保障测试用例描述的统一性和可维护性
  • 借助辅助函数(debugtalk.py),在测试脚本中轻松实现复杂的动态计算逻辑
  • 支持完善的测试用例分层机制,充分实现测试用例的复用
  • 测试前后支持完善的 hook 机制
  • 响应结果支持丰富的校验机制
  • 基于 HAR 实现接口录制和用例生成功能(har2case
  • 结合 Locust 框架,无需额外的工作即可实现分布式性能测试
  • 执行方式采用 CLI 调用,可与 Jenkins 等持续集成工具完美结合
  • 测试结果统计报告简洁清晰,附带详尽统计信息和日志记录
  • 极强的可扩展性,轻松实现二次开发和 Web 平台化

这次学习笔记完成的有(部分开发需完善):

  • 继承 Requests 的全部特性,轻松实现 HTTP(S) 的各种测试需求
  • 采用 YAML/JSON 的形式描述测试场景,保障测试用例描述的统一性和可维护性
  • 借助辅助函数(ayo.py),在测试脚本中轻松实现复杂的动态计算逻辑
  • 测试前后支持完善的 hook 机制
  • 响应结果支持丰富的校验机制
  • 测试结果统计报告简洁清晰,附带详尽统计信息和日志记录
  • 多线程执行,解决多线程中依赖数据可能重复,混乱的问题。(优化)
  • 改写HTMLTestReportCN,更改部分样式,加入丰富的图表,request log,filter。(优化)
  • testcase层面的设置参数化执行次数(待完善)

技术栈:

基于python 大致需要了解 unittest,requests ,http ,闭包,装饰器,继承复写,元类。

与HttpRunner思想差异:

采用无参数化下一个yaml为一个测试类,有参数化下一个yaml为多个测试类,且一个testcase为一个testsuite的思想,弱化了testsuite的概念。关于复用上将采用引用testcase 组成另一个testcase的方式,增大复用性。

核心yaml驱动代码如下:

Yaml Runner

 

目前yaml用例格式

- config:
    name: TestClass_API
    description: Flask接口依赖测试
    variables:
    base_url:
    hooks:
      setup_hooks:
        - ${hook_test()}
      teardown_hooks:
        - ${hook_teardown_cls()}

- teststep:
    name: DepenceOne
    description: 测试接口A1==>获取变量One
    request:
        headers:
            User-Agent: ${ua_random()}
        contentType:
        data:
        extract:
            - One
        method: get
        url: http://localhost:9998/DepenceOne
    check:
      assertEqual:
        status: 200
      assertIn:
        content:
          - One
    setup_hooks:
      - ${hook_test()}
    teardown_hooks:
      - ${hook_teardown_cls()}
   
- teststep:
    name: DepenceTwo
    description: 测试接口A2==>获取变量Two
    request:
        url: http://localhost:9998/DepenceTwo
        headers:
            Content-Type: application/json
            User-Agent: ${ua_random()}
        contentType: json
        data:
           One: $One
           test: ${test($One)}
           test2: 123
        method: post
        extract:
            - Two
    check:
      assertEqual:
        status: 300
      assertIn:
        content:
          - Two
    setup_hooks:
      - ${hook_test()}
    teardown_hooks:
      - ${hook_teardown_cls()}

- teststep:
    name: DepenceThr
    description: 测试接口A3==>获取变量Thr
    request:
        headers:
            User-Agent: ${ua_random()}
        contentType:
        data:
        method: get
        extract:
            - Thr
        url: http://localhost:9998/DepenceThr
    check:
      assertEqual:
        status: 200
      assertIn:
        content:
          - Thr
    cycles: 5

如何将yaml用例转化成测试/类方法

Python 40行代码实现HttpRunner(动态创建类,动态添加方法,模板解析)详细请移步此处,核心方式type创建类,setattr创建成员方法。

生成测试类代码:用type元类创建继承unittest的测试类

   @classmethod
    def create_class(cls,testcase:str)->object:
        '''

        :param testcase: 单个yaml内的数据
        :return: yaml反射后的测试类
        '''
        try:
            class_name = eval(testcase)[0]['config']['name'] if isinstance(testcase, str) else testcase[0]['config']['name']
            globals()[class_name] = type(class_name, (unittest.TestCase,), dict())
            cls.classnames.append(class_name)
            eval(class_name).__doc__ = eval(testcase)[0]['config']['description'] if isinstance(testcase, str) else \
                eval(testcase)[0]['config']['description']  # 设置备注信息
            log.info(f'创建测试类成功==>{class_name}')

            if eval(testcase)[0]['config'].get('hooks'):
                 hook_dict = eval(testcase)[0]['config'].get('hooks')
                 for hook_type,func_list in hook_dict.items():
                    setattr(eval(class_name),hook_type,cls.cls_hooks(hook_type,func_list))

            return eval(class_name)

        except Exception:
            raise

关于类层面的hook:cls_hooks方法通过setattr创建测试类的而成员方法,实从而现添加类层面的fixture

   @classmethod
    def cls_hooks(cls,hook_type:str,func_list:list):

        @classmethod
        def setUpClass(cls):
            [prepare_variables(func) for func in func_list]

        @classmethod
        def tearDownClass(cls):
            [prepare_variables(func) for func in func_list]

        if hook_type=='setUpClass':
            log.success(f'{hook_type} 测试类,成功添加 {hook_type}成功:{func_list}')
            return setUpClass
        else:
            log.success(f'{hook_type} 测试类,成功添加 {hook_type}成功:{func_list}')
            return tearDownClass

 

关于方法层面的hook:判断参数中是否含有hooks只需在请求前/后 执行即可,例如前置条件

if hooks:
   if hooks.get('setup_hooks'):
      [prepare_variables(func,Variable,fixture=True) for func in hooks.get('setup_hooks')]

 

prepare_variables方法实现HttpRunner中的动态计算逻辑:包括数据替换方法执行,方法通过eval 来执行,且所有引用执行的方法必须要在tools.ayo中定义

import re
from tools.ayo import *

def prepare_variables(data,dependon_cls:object=None,fixture=False)->dict:
    '''

    :param data: 待清洗的数据/方法
    :return: 清洗后的数据dict/None
    '''
    re_func = r"\$\{(\w+)\(([\$\w\.\-/\s=,]*)\)\}"

    if not data:
        return
    if not fixture:
        if not (isinstance(data,dict)):
            data = eval(data)
        for key,value in data.items():
            value = str(value)
            if value.startswith('${'):
                func =re.match(re_func, value).group(1) # func
                func_data = re.match(re_func, value).group(2) # a=$d,$s
                variable_list = re.compile(r"(\$\w+)").findall(str(func_data)) # {'a':'${func(a=$d,$s)}',"b":"$d"} =>['$d', '$s']
                for variable in  variable_list:
                    func_data = func_data.replace(variable, str(getattr(dependon_cls,variable[1:])))
                    log.info(f'替换数据成功:>{variable}==>{str(getattr(dependon_cls,variable[1:]))}')
                data[key]= eval(f'{func}({func_data})')

            elif value.startswith('$') and '{' not in value:
                variable_list = re.compile(r"(\$\w+)").findall(str(value))  # {'a':'${func(a=$d,$s)}',"b":"$d"} =>['$d', '$s']
                for variable in variable_list:
                    data[key]= value.replace(variable, str(getattr(dependon_cls,variable[1:])))
                    log.info(f'替换数据成功:>{variable}==>{str(getattr(dependon_cls, variable[1:]))}')

        return data

    else:
        value = str(data)
        func = re.match(re_func, value).group(1)
        func_data = re.match(re_func, value).group(2)
        variable_list = re.compile(r"(\$\w+)").findall(str(func_data))  # {'a':'${func(a=$d,$s)}',"b":"$d"} =>['$d', '$s']
        if len(variable_list)>0:
            for variable in variable_list:
                func_data = func_data.replace(variable, str(getattr(dependon_cls, variable[1:])))
                eval(f'{func}({func_data})')
                log.info(f'fixture执行成功:>{variable}==>{str(getattr(dependon_cls, variable[1:]))}')
        else:
            eval(f'{func}()')
            log.info(f'fixture执行成功:>{func}')

生成测试方法代码: 还是利用python动态语言特性,setattr动态添加测试方法。

   @classmethod
    def create_def(cls,teststep:list,class_name:object,index,dependon_dict={})->None:
        '''
        :param testcase: 单个yaml内的数据
        :param class_name:  yaml反射的测试类
        :return:
        '''
        dependon_dict[str(index)] = f'test_{index}_func_{teststep["teststep"]["name"]}'
        if index > 0:  # 依赖case_name
            setattr(class_name,
                    f'test_{index}_func_{str(teststep["teststep"]["name"])}',
                    cls.getTestFunc(dependon_dict[str(index - 1)],
                                    check=teststep["teststep"]['check'],
                                    hooks=teststep["teststep"]['hooks'] if teststep["teststep"].get('hooks') else None,
                                    **teststep["teststep"]['request']))
        else:
            setattr(class_name,
                    f'test_{index}_func_{str(teststep["teststep"]["name"])}',
                    cls.getTestFunc(' ',
                                    check=teststep["teststep"]['check'],
                                    hooks=teststep["teststep"]['hooks'] if teststep["teststep"].get('hooks') else None,
                                    **teststep["teststep"]['request']))

        eval(f'{class_name.__name__}.test_{index}_func_{teststep["teststep"]["name"]}').__doc__ = \
            teststep["teststep"]['description']
        log.success(
            f'测试类:{class_name.__name__} =>创建测试方法成功==>test_{index}_func_{teststep["teststep"]["name"]}')

关于失败后自动逃过用例:采用@skip_depence_case()装饰器,传入一个依赖case的name,并在错误/失败结果中比对查找,如果存在即利用unittest.skipIf方法处理,实现依赖case失败/错误之后跳过处理。

def skip_depence_case(dependon=""):
    """
    :param dependon: 依赖的用例函数名,默认为空
    :return: wraper_func
    """
    def wraper_func(test_func):

        def inner_func(self):
            if dependon == test_func.__name__:
                raise ValueError(f"{dependon}不可自身依赖")
            failures = str([fail[0] for fail in self._outcome.result.failures])
            errors = str([error[0] for error in self._outcome.result.errors])
            skipped = str([error[0] for error in self._outcome.result.skipped])
            flag = (dependon in failures) or (dependon in errors) or (dependon in skipped)
            if dependon in failures:
                test = unittest.skipIf(flag, "{} failed".format(dependon))(test_func)
            elif dependon in errors:
                test = unittest.skipIf(flag, "{} error".format(dependon))(test_func)
            elif dependon in skipped :
                test = unittest.skipIf(flag, "{} skipped".format(dependon))(test_func)
            else:
                test = test_func
            return test(self)
        return inner_func
    return wraper_func

关于生成suite的方法:通过读取cycles,确定case执行次数,并复制在此之前所有的teststep,并再次基础上,加上最后一个step循环cycles次。

   @classmethod
    def create_testsuite(cls)->list:
        '''

        :return: suitelist
        '''
        yaml_data  = load_yaml_case() # yaml数据
        flag =False  # False:没有参数化  True:参数化suite
        for testcase in yaml_data.values():  #config提取用例名
            class_name = cls.create_class(testcase)
            dependon_dict = {}

            for index,teststep in enumerate(eval(testcase)[1:]):
                dependon_dict[str(index)] = f'test_{index}_func_{teststep["teststep"]["name"]}'

                if not teststep["teststep"].get('cycles'): # 无参数化时,创建测试方法
                    cls.create_def(teststep,class_name,index)

                else: # 有参数化,创建测试方法,遍历到第一个参数化step,后面step将会被忽略
                    cycles = teststep["teststep"]['cycles']
                    testcases = [eval(testcase)[:index+1][:] for _ in range(cycles+1)]

                    for index_,testcase_ in enumerate(testcases[1:]):
                        testcase_.append(eval(testcase)[index+1])   # 在此之前全部的step
                        testcase_[0]['config']['description'] += f'_{str(index_)}'  # 重新定义_doc_
                        testcase_[0]['config']['name'] += f'_{str(index_)}'     # 重定义class名字
                        class_name = cls.create_class(str(testcase_))

                        for index,teststep in enumerate(testcase_[1:]):
                            cls.create_def(teststep, class_name, index)

                        suite = unittest.TestLoader().loadTestsFromTestCase(class_name)
                        cls.suitelist.append(suite)
                        log.success(f'创建测试suite成功==>{suite}')
                        setattr(cls, 'threads', f'{len(cls.suitelist)}')  # 线程数

                    flag =True #停止到参数化那一步

            if not flag:
                suite = unittest.TestLoader().loadTestsFromTestCase(class_name)
                cls.suitelist.append(suite)
                log.success(f'创建测试suite成功==>{suite}')
                setattr(cls, 'threads', f'{len(cls.suitelist)}') #线程数
            else:break

        return cls.suitelist

 

关于多线程执行:改写HTMLTestReportCN run方法,从而实现多线程。

Python unittest并发执行依赖用例(修改少量源码)请移至此,再此基础上,修改部分代码,实现suite层面的keep session,以及多线程中重复依赖数据的覆盖/混淆。核心通过预先设置线程的name实现,(不要通过线程id…细细品味不多说)

try:
    Session = type('Session', (), dict())  # 创建Session类
    log.success('创建Session类成功==>"Session"')
except Exception as e:
    log.error(f'创建Session类失败==>{e}')

try:
    Variables = type('Variables', (), dict())  # 创建依赖类
    log.success('创建依赖类成功==>"Depence"')
except Exception as e:
    log.error(f'创建依赖类失败==>{e}')

不同线程向Session类,依赖类添加Session对象以及依赖类:

    def run(self, suitelist: list):
        "Run the given test case or test suite."
        result = _TestResult(self.verbosity)
        threadings = []
        a = 100000
        try:
            for suite in suitelist:
                t = threading.Thread(target=suite, args=(result,))
                t.setName(str(a))
                threadings.append(t)
                setattr(Session, str(a), requests.session())
                log.success(f'创建suite Session成功!==>:{getattr(Session, str(a))}')
                setattr(Variables, str(a), type(f'Variables{a}', (), dict()))
                log.success(f'创建suite 依赖类成功==>Variables{a}')
                a += 1
            for j in threadings:
                j.start()
                log.success(f'线程:{j._ident} 启动成功!')
            for j in threadings:
                j.join()
                log.success(f'线程:{j._ident} 执行完毕!')
            self.stopTime = datetime.datetime.now()
            self.generateReport(suitelist, result)
            return result
        except Exception as e:
            log.error(e)

 

之前在执行层面实现了skip,report层面添加 skip方法用于添加至图表,HTMLTestReportCN添加如下代码(仅部分):

 def addSkip(self, test, reason):
        self.skipped_count += 1
        TestResult.addSkip(self, test, reason)
        output = self.complete_output()
        self.result.append((3, test, '', reason,0))
        if self.verbosity > 1:
            sys.stderr.write('skip ')
            sys.stderr.write(str(test))
            sys.stderr.write('\n')
        else:
            sys.stderr.write('s')

关于html中的图表采用echarts,点击官网地址了解~

最后效果:

report
report log popup

 

 

 

20,810 次浏览

“全面实现HttpRunner并局部优化(一)”的1,692个回复

  1. Hmm it looks like your blog ate my first comment (it was extremely long) so I guess I’ll just sum it up what I had written and say, I’m thoroughly enjoying your blog. I too am an aspiring blog blogger but I’m still new to everything. Do you have any helpful hints for inexperienced blog writers? I’d certainly appreciate it.|

  2. It is truly a nice and helpful piece of information. I
    am glad that you simply shared this helpful info with us. Please stay
    us up to date like this. Thank you for sharing.

  3. hello there and thank you for your information – I have certainly picked up anything new from
    right here. I did however expertise some technical points using
    this web site, as I experienced to reload the web site a lot
    of times previous to I could get it to load correctly. I had been wondering if your hosting is OK?
    Not that I’m complaining, but slow loading instances times will often affect your placement
    in google and could damage your high-quality score if advertising and marketing with Adwords.

    Well I am adding this RSS to my email and can look out for much more of your respective interesting content.
    Make sure you update this again soon.

  4. I truly love your website.. Pleasant colors & theme.
    Did you develop this amazing site yourself? Please reply back as I’m
    trying to create my own personal website and want
    to know where you got this from or what the theme is called.
    Thanks!

  5. Sorry for off-topic, I’m thinking about creating an interesting internet site for students. Will possibly begin with submitting interesting facts such as”On average, 100 people choke to death on ball-point pens every year.”Please let me know if you know where I can find some related information like right here

    Bahsegel

  6. Sorry for off-topic, I am thinking about creating an interesting web-site for pupils. May possibly begin with submitting interesting information just like”Flies jump backwards during takeoff.”Please let me know if you know where I can find some related information like here

    https://jangosteve.com/

  7. I apologize for off-topic, I am thinking about building an instructive internet site for pupils. May possibly start with posting interesting information such as”More people are afraid of spiders than death. Amazingly, few people are afraid of Champagne corks even though you are more likely to be killed by one than by a spider.”Please let me know if you know where I can find some related information such as right here

    Bahsegel