背景

之前已经在社区开源过 HttpRunnerManager,由于之前设计过于简单,导致后面功能扩展带来了很大局限性,HttpRunner 很多特性也不能很好的完全实现,本人是@debugtalk的忠实粉丝,一直都在探索 HttpRunner 最佳体现形式,结合自己使用 HttpRunner 的实践和痛点,这次给大家带来了一款小而美的测试工具,目前雏形已经完成,计划后续开源在社区,提前发表出来也是希望使用 HttpRunner 的童鞋们多多给提提需求

预期目标

  1. 方便后续自行扩展,根据自己需求进行改造
  2. 支持 CLI 命令行启动,通过参数指定文件夹可以把 HttpRunner 脚本直接 web 化
  3. 支持平台化部署,可以导入导出标准 yaml 或 json 格式脚本
  4. 小白使用应该也能快速上手

技术选型

本次采用前后端分离方式,后端还是采用了自己最熟悉的 django 开发,结合 django_restful 提供 restful 接口, 前端采用了目前比较热的 vue.js + element-ui

设计思路

  1. 为了迎合对于 HttpRunner 版本快速迭代和易于扩展要求,首先在前端工程结构设计方面我们可以分为 pages, router, store, restful assets 这几层

其中 restful 管理所有前后端交互的接口,pages 里对于 HttpRunner 的所有关键字都采用组件形式进行模块化封装,如下所示:

后端还是采用了 MVT 模式,没有特意进行文件夹划分,所有数据表放置在 models.py 所有类视图在 views.py, 其他操作都置于 utils 包下

数据交互

简单的增删改查就不说了,关于 HttpRunner 数据结构和前端数据结构是重点,一开始我们必须约定好双方格式而且实现两个解析器

  1. 后端数据结构及解析类:

    class Format(object):
    """
    解析标准HttpRunner脚本 前端->后端
    """
    
    def __init__(self, body):
        """
        body => {
                    header: header -> [{key:'', value:'', desc:''},],
                    request: request -> {
                        form: formData - > [{key: '', value: '', type: 1, desc: ''},],
                        json: jsonData -> {},
                        params: paramsData -> [{key: '', value: '', type: 1, desc: ''},]
                        files: files -> {"fields","binary"}
                    },
                    extract: extract -> [{key:'', value:'', desc:''}],
                    validate: validate -> [{expect: '', actual: '', comparator: 'equals', type: 1},],
                    variables: variables -> [{key: '', value: '', type: 1, desc: ''},],
                    hooks: hooks -> [{setup: '', teardown: ''},],
                    url: url -> string
                    method: method -> string
                    name: name -> string
                }
        """
    
        try:
            self.name = body.pop('name')
            self.url = body.pop('url')
            self.method = body.pop('method')
            self.times = body.pop('times')
    
            self.headers = body['header'].pop('header')
            self.params = body['request']['params'].pop('params')
            self.data = body['request']['form'].pop('data')
            self.json = body['request'].pop('json')
            self.files = body['request']['files'].pop('files')
    
            self.variables = body['variables'].pop('variables')
            self.extract = body['extract'].pop('extract')
            self.validate = body.pop('validate').pop('validate')
            self.setup_hooks = body['hooks'].pop('setup_hooks')
            self.teardown_hooks = body['hooks'].pop('teardown_hooks')
    
            self.desc = {
                "header": body['header'].pop('desc'),
                "data": body['request']['form'].pop('desc'),
                "files": body['request']['files'].pop('desc'),
                "params": body['request']['params'].pop('desc'),
                "extract": body['extract'].pop('desc'),
                "variables": body['variables'].pop('desc'),
            }
    
            self.relation = body.pop('nodeId')
            self.project = body.pop('project')
    
            self.testcase = None
        except KeyError:
            pass
    
    def parse_test(self):
        """
        返回标准化HttpRunner "desc" 字段运行需去除
        """
        test = {
            "name": self.name,
            "times": self.times,
            "request": {
                "url": self.url,
                "method": self.method
            },
            "desc": self.desc
        }
    
        if self.headers:
            test["request"]["headers"] = self.headers
        if self.params:
            test["request"]["params"] = self.params
        if self.data:
            test["request"]["data"] = self.data
        if self.json:
            test["request"]["json"] = self.json
        if self.files:
            test["request"]["files"] = self.files
    
        if self.extract:
            test["extract"] = self.extract
        if self.validate:
            test['validate'] = self.validate
        if self.variables:
            test["variables"] = self.variables
        if self.setup_hooks:
            test['setup_hooks'] = self.setup_hooks
        if self.teardown_hooks:
            test['teardown_hooks'] = self.teardown_hooks
    
        self.testcase = test
    
  2. 前端数据解析类:

    class Parse(object):
    """
    标准HttpRunner脚本解析至前端 后端->前端
    """
    
    def __init__(self, body):
        """
        body: =>{
                "name": "get token with $user_agent, $os_platform, $app_version",
                "request": {
                    "url": "/api/get-token",
                    "method": "POST",
                    "headers": {
                        "app_version": "$app_version",
                        "os_platform": "$os_platform",
                        "user_agent": "$user_agent"
                    },
                    "json": {
                        "sign": "${get_sign($user_agent, $device_sn, $os_platform, $app_version)}"
                    },
                    "extract": [
                        {"token": "content.token"}
                    ],
                    "validate": [
                        {"eq": ["status_code", 200]},
                        {"eq": ["headers.Content-Type", "application/json"]},
                        {"eq": ["content.success", true]}
                    ],
                    "setup_hooks": [],
                    "teardown_hooks": []
                }
        """
        self.name = body.get('name', '')
        self.times = body.get('times', 1)  # 如果导入没有times 默认为1
        self.request = body.get('request')  # header files params json data
        self.variables = body.get('variables')
        self.extract = body.get('extract')
        self.validate = body.get('validate')
        self.setup_hooks = body.get('setup_hooks', [])
        self.teardown_hooks = body.get('teardown_hooks', [])
        self.desc = body.get('desc')
    
        self.testcase = None
    
    @staticmethod
    def __get_type(content):
        """
        返回data_type 默认string
        """
        var_type = {
            "str": 1,
            "int": 2,
            "float": 3,
            "bool": 4,
            "list": 5,
            "dict": 6,
        }
    
        key = str(type(content).__name__)
    
        if key in ["list", "dict"]:
            content = json.dumps(content)
        else:
            content = str(content)
        return var_type[key], content
    
    def parse_http(self):
        """
        标准前端脚本格式
        """
        init = {
            "key": "",
            "value": "",
            "desc": ""
        }
    
        init_p = {
            "key": "",
            "value": "",
            "desc": "",
            "type": 1
        }
    
        #  初始化test结构
        test = {
            "name": self.name,
            "times": self.times,
            "url": self.request['url'],
            "method": self.request['method'],
            "header": [init],
            "request": {
                "data": [init_p],
                "params": [init_p],
                "json_data": ''
            },
            "validate": [{
                "expect": "",
                "actual": "",
                "comparator": "equals",
                "type": 1
            }],
            "variables": [init_p],
            "extract": [init],
            "hooks": [{
                "setup": "",
                "teardown": ""
            }]
        }
    
        if self.request.get('headers'):
            test["header"] = []
            for key, value in self.request.pop('headers').items():
                test['header'].append({
                    "key": key,
                    "value": value,
                    "desc": self.desc["header"][key]
                })
    
        if self.request.get('data'):
            test["request"]["data"] = []
            for key, value in self.request.pop('data').items():
                obj = Parse.__get_type(value)
    
                test['request']['data'].append({
                    "key": key,
                    "value": obj[1],
                    "type": obj[0],
                    "desc": self.desc["data"][key]
                })
    
        if self.request.get('params'):
            test["request"]["params"] = []
            for key, value in self.request.pop('params').items():
                test['request']['params'].append({
                    "key": key,
                    "value": value,
                    "type": 1,
                    "desc": self.desc["params"][key]
                })
    
        if self.request.get('json'):
            test["request"]["json_data"] = \
                json.dumps(self.request.pop("json"), indent=4,
                           separators=(',', ': '), ensure_ascii=False)
    
        if self.extract:
            test["extract"] = []
            for content in self.extract:
                for key, value in content.items():
                    test['extract'].append({
                        "key": key,
                        "value": value,
                        "desc": self.desc["extract"][key]
                    })
    
        if self.variables:
            test["variables"] = []
            for content in self.variables:
                for key, value in content.items():
                    obj = Parse.__get_type(value)
                    test["variables"].append({
                        "key": key,
                        "value": obj[1],
                        "desc": self.desc["variables"][key],
                        "type": obj[0]
                    })
    
        if self.validate:
            test["validate"] = []
            for content in self.validate:
                for key, value in content.items():
                    obj = Parse.__get_type(value[1])
                    test["validate"].append({
                        "expect": obj[1],
                        "actual": value[0],
                        "comparator": key,
                        "type": obj[0]
                    })
    
        if self.setup_hooks or self.teardown_hooks:
            test["hooks"] = []
            if len(self.setup_hooks) > len(self.teardown_hooks):
                for index in range(0, len(self.setup_hooks)):
                    teardown = ""
                    if index < len(self.teardown_hooks):
                        teardown = self.teardown_hooks[index]
                    test["hooks"].append({
                        "setup": self.setup_hooks[index],
                        "teardown": teardown
                    })
            else:
                for index in range(0, len(self.teardown_hooks)):
                    setup = ""
                    if index < len(self.setup_hooks):
                        setup = self.setup_hooks[index]
                    test["hooks"].append({
                        "setup": setup,
                        "teardown": self.teardown_hooks[index]
                    })
    
        self.testcase = test
    

页面交互

为了方便解析标准 HttpRunner 脚本结构和平台化,脚本以项目为单位管理,分为 debugtalk.py, 自动化测试, 环境管理, API 管理

  1. debugtalk.py 即一个项目的驱动库,当然要支持在线编写调试
  2. API 管理,此 API 并非 HttpRunner 格式,其实录入的还是 test 结构体,因为用例是 api 组成,而且 httprunner 的业务用例是一个个 test 组成,所以我们后面可以页面拖拽 api 组成用例集,可以理解为一个 yaml 文件
  3. 自动化测试就是挑选 API 组成测试用例集了,拖拽接口,至此拖拽排序
  4. 环境管理,其实就是 httprunner 的 config 结构体,目前打算是运行时选择环境,不在用例集里面引入

实际页面

1. 项目列表页面

2. 一个小小的数据库管理工具

3.项目结构和概况

4.debugtalk.py 在线编辑和调试

5.API 管理,采用类似 postman 的文件夹形式,支持无限级目录

6.标准 HttpRunner 脚本编辑页面,类型支持比较完善了,这里给几张小图





7.自动化测试,拖拽 API 组成测试用例集

8.环境管理

还没开始做呢!

结束语

以上就是目前完成的部分内容,核心就是先录入 api(实质是 test 结构),然后可以拖拽 api 组成用例集,httprunner 分为 testcase-api-suite,suite 我一直不是很理解,所以暂时没有实现;希望各位 HttpRunner 天使用户们多多指导


↙↙↙阅读原文可查看相关链接,并与作者交流