• 简单基于Excel的数据驱动接口测试框架


    数据格式

    文件test_data.xlsx

    文件test_data.xlsx

    代码V1

    import openpyxl
    import requests
    
    
    def run_excel(excel_file):
        excel = openpyxl.load_workbook(excel_file)
        sheet = excel.active
        for index, line in enumerate(sheet.values):
            if index == 0:  # 跳过标题行
                continue
            name, method, url, data, headers, verify, *_ = line  # 解包,舍弃第7列以后的值
            # 处理请求头
            if headers:
                headers = {line.split(':')[0].strip(): line.split(':')[1].strip()
                           for line in headers.split('\n')}
            # 处理请求数据,为支持中文数据,需要将文本按utf-8编码为bytes
            if data is not None:
                data = data.encode('utf-8')
                
            # 发送请求
            print(f'请求第{index + 1}行接口: {name}')
            
            res = requests.request(method, url, data=data, headers=headers)
            print('响应:', res.text)
            
            # 处理断言
            result = 'PASS'
            if verify:
                lines = verify.split('\n')  # 按行分割转为列表
                for line in lines:
                    if not line:  # 跳过空行
                        continue
                    try:
                        assert eval(line)  # 使用eval()来计算表达式的值
                    except AssertionError:
                        print("断言出错")
                        result = "FAIL"
                        break
                    except Exception as ex:
                        print("断言异常:", ex)
                        result = "ERROR"
                        break
                    finally:
                        print('执行断言:', line, '结果:', result)
            sheet.cell(index + 1, 7).value = result  # 在当前行第7列写入结果
        excel.save(excel_file)  # 保存并覆盖原文件
    
    
    if __name__ == '__main__':
        run_excel('data.xlsx')
    

    代码V2-改为面向对象

    import openpyxl
    import requests
    
    
    class TestCase:
        def __init__(self, index, name, method, url, data, headers, verify, result):
            self.index = index
            self.name = name
            self.method = method
            self.url = url
            self.data = data
            self.headers = headers
            self.verify = verify
            self.result = result
    
        @staticmethod
        def _handle_data(data):
            if data is not None:
                return data.encode('utf-8')
    
        @staticmethod
        def _handle_headers(headers):
            if headers:
                return {line.split(':')[0].strip(): line.split(':')[1].strip()
                           for line in headers.split('\n')}
       
        @staticmethod
        def _handle_verify(verify):
            if verify:
                return [line for line in verify.split('\n') if line.strip()]  # 按行分割转为列表
                    
        def _send_request(self):
            # 发送请求
            print(f'请求接口: {self.name}')
            data = self._handle_data(self.data)
            headers = self._handle_headers(self.headers)
            res = requests.request(self.method, self.url, data=data, headers=headers)
            print('响应:', res.text)
            return res
    
        def _do_verify(self, res):
            result = "PASS"
            if self.verify:
                lines = self.verify.split('\n')  # 按行分割转为列表
                for line in lines:
                    if not line:  # 跳过空行
                        continue
                    try:
                        assert eval(line)  # 使用eval()来计算表达式的值
                    except AssertionError:
                        print("断言出错")
                        result = "FAIL"
                        break
                    except Exception as ex:
                        print("断言异常:", ex)
                        result = "ERROR"
                        break
                    finally:
                        print('执行断言:', line, '结果:', result)
            return result
    
        def run(self):
            res = self._send_request()
            result = self._do_verify(res)
            return result
    
    class Runner:
        def __init__(self, excel_file):
            self.excel_file = excel_file
            
        def load_testcases(self):
            excel = openpyxl.load_workbook(self.excel_file)
            self.sheet = excel.active
            testcases = []
            for index, line in enumerate(self.sheet.values):
                if index == 0:  # 跳过标题行
                    continue
                testcases.append(TestCase(index, *line))
            return testcases
        
        def write_result(self, index, result, result_col=7):
            self.sheet.cell(index + 1, result_col).value = result
    
        def run(self):
            testcases = self.load_testcases()
            for testcase in testcases:
                result = testcase.run()
                self.write_result(testcase.index, result)
                
                
    if __name__ == '__main__':
        Runner('test_data.xlsx').run()
    

    V3-使用pytest框架

    参考:https://doc.pytest.org/en/latest/example/nonpython.html#a-basic-example-for-specifying-tests-in-yaml-files

    import openpyxl
    import requests
    import pytest
    
    
    def pytest_collect_file(parent, file_path):
        if file_path.suffix == '.xlsx' and file_path.name.startswith("test"):
            return ExcelFile.from_parent(parent, path=file_path)
        
        
    class ExcelFile(pytest.File):
        def collect(self):
            excel = openpyxl.load_workbook(self.path)
            sheet = excel.active
            for row in sheet.iter_rows(2, values_only=True):
                name, *values = row
                yield ExcelTest.from_parent(self, name=name, values=values)
                
                
    class ExcelTest(pytest.Item):
        def __init__(self, name, parent, values):
            super().__init__(name, parent)
            self.values = values
            self.verify = None
            self.s = requests.Session()  # 请求会话,注意self.session是pytest框架的执行会话
            
        def prepare_request(self)->requests.PreparedRequest:
            method, url, data, headers, self.verify, *_ = self.values
            if headers:
                headers = {line.split(':')[0].strip(): line.split(':')[1].strip()
                           for line in headers.split('\n')}
            if data:
                data = data.encode('utf-8')
                
            req = requests.Request(method, url, headers=headers, data=data).prepare()
            return req
        
        def do_verify(self, **context):
            locals().update(context)
            if self.verify:
                lines = self.verify.split('\n')  # 按行分割转为列表
                for line in lines:
                    if line:
                        assert eval(line)
            
        def runtest(self):
            print('运行', self.name, self.values)
            req = self.prepare_request()
            res = self.s.send(req)
            print(res)
            self.do_verify(res=res)
    

    命令行运行 python3 -m pytest -vs,运行结果如下:

    platform darwin -- Python 3.8.9, pytest-7.1.0, pluggy-1.0.0 -- /Users/superhin/venvs/wkcrm-apitest-yaml/bin/python3
    cachedir: .pytest_cache
    rootdir: /Users/superhin/Projects/wkcrm-apitest-yaml
    collected 4 items                                                                                                                                                                  
    
    test_data.xlsx::get请求 运行 get请求 ['get', 'https://httpbin.org/get?a=1&b=2', None, None, 'res.status_code==200', None]
    <Response [200]>
    PASSED
    test_data.xlsx::post-form 运行 post-form ['post', 'https://httpbin.org/post', 'name=Kevin&age=1', 'Content-Type: application/x-www-form-urlencoded', "res.status_code==200\nres.json['form']['name']=='Kevin'", None]
    <Response [200]>
    PASSED
    test_data.xlsx::post-json 运行 post-json ['post', 'https://httpbin.org/post', '{"name": "Kevin", "age": 1}', 'Content-Type: application/json', None, None]
    <Response [200]>
    PASSED
    test_data.xlsx::post-xml 运行 post-xml ['post', 'https://httpbin.org/post', '<xml>hello</xml>', 'Content-Type: application/xml', 'res.json()["data"]=="<xml>hello</xml>"', None]
    <Response [200]>
    PASSED
    

    V4-修改为使用requests.hooks, 增加数据提取

    数据格式增加一列register,如下图

    代码如下

    from string import Template
    
    import openpyxl
    import requests
    import pytest
    
    context = {}
    
    
    def pytest_collect_file(parent, file_path):
        if file_path.suffix == '.xlsx' and file_path.name.startswith("test"):
            return ExcelFile.from_parent(parent, path=file_path)
        
        
    class ExcelFile(pytest.File):
        def collect(self):
            excel = openpyxl.load_workbook(self.path)
            sheet = excel.active
            for row in sheet.iter_rows(2, values_only=True):
                name, *values = row
                yield ExcelTest.from_parent(self, name=name, values=values)
                
                
    class ExcelTest(pytest.Item):
        def __init__(self, name, parent, values):
            super().__init__(name, parent)
            self.values = values
            self.s = requests.Session()  # 请求会话,注意self.session是pytest框架的执行会话
        
        def send_request(self):
            method, url, data, headers, self.verify, self.register, *_ = map(lambda x: Template(x).safe_substitute(context) if isinstance(x, str) else x, self.values)
            print('self.register', self.register)
            
            if headers:
                headers = {line.split(':')[0].strip(): line.split(':')[1].strip()
                           for line in headers.split('\n')}
            if data:
                data = data.encode('utf-8')
    
            res = self.s.request(method, url, headers=headers, data=data, hooks={'response': [self.print_res, self.register_var, self.verify_res]})
            return res
        
        def register_var(self, res, *args, **kwargs):
            if self.register:
                for line in self.register.split('\n'):
                    key, expr = line.split('=')
                    context[key.strip()] = eval(expr.strip())
            
        def verify_res(self, res, *args, **kwargs):
            locals().update(context)
            if self.verify:
                for line in self.verify.split('\n'):
                    if line:
                        assert eval(line)
                        
        def print_res(self, res, *args, **kwargs):
            print(res.text)
            
        def runtest(self):
            print('运行', self.name, self.values)
            self.send_request()
    

    V5-改为用例多步骤

    数据格式

    特性及变更

    • 支持插件pytest-base-url来配置base_url
    • 改为使用ChainMap并支持使用环境变量

    已知问题

    • 每个用例都需要编写登录步骤
    • 单元格中编写JSON没有提示非常容易出错
    • 响应断言res.json()['code']==0这种形式书写较麻烦
    • 步骤不支持命名

    实现代码 conftest.py

    from string import Template
    from collections import ChainMap
    import os
    
    import openpyxl
    import requests
    import pytest
    
    context = ChainMap({}, os.environ)
    
    
    def pytest_collect_file(parent, file_path):
        if file_path.suffix == '.xlsx' and file_path.name.startswith("test"):
            return ExcelFile.from_parent(parent, path=file_path)
    
    
    class ExcelFile(pytest.File):
        def collect(self):
            excel = openpyxl.load_workbook(self.path)
            sheet = excel.active
            name = None
            case_name = None
            steps = []
            for row in sheet.iter_rows(2, values_only=True):
                name, *values = row
                if name:
                    steps = [values]
                    if case_name:
                        yield ExcelTest.from_parent(self, name=case_name, steps=steps)
                    case_name = name
                else:
                    steps.append(values)
            if case_name:
                yield ExcelTest.from_parent(self, name=case_name, steps=steps)
    
    
    class ExcelTest(pytest.Item):
        def __init__(self, name, parent, steps):
            super().__init__(name, parent)
            self.steps = steps
            self.s = requests.Session()  # 请求会话,注意self.session是pytest框架的执行会话
            self.base_url = self.config.getoption('--base-url') or self.config.getini('base_url')
    
        def send_request(self, values):
            method, url, data, headers, self.verify, self.register, *_ = map(
                lambda x: Template(x).safe_substitute(context) if isinstance(x, str) else x, values)
            if not url.startswith('http') and self.base_url:
                url = f'{self.base_url}{url}'
    
            if headers:
                headers = {line.split(':')[0].strip(): line.split(':')[1].strip()
                           for line in headers.split('\n')}
            if data:
                data = data.encode('utf-8')
            print('发送请求', url, headers, data)
            res = self.s.request(method, url, headers=headers, data=data,
                                 hooks={'response': [self.print_res, self.register_var, self.verify_res]})
            return res
    
        def register_var(self, res, *args, **kwargs):
            if self.register:
                for line in self.register.split('\n'):
                    key, expr = line.split('=')
                    context[key.strip()] = eval(expr.strip())
    
        def verify_res(self, res, *args, **kwargs):
            locals().update(context)
            if self.verify:
                for line in self.verify.split('\n'):
                    if line:
                        assert eval(line)
    
        def print_res(self, res, *args, **kwargs):
            print(res.text)
    
        def runtest(self):
            print('运行', self.name)
            for values in self.steps:
                self.send_request(values)
    

    运行方法

    1. 需要在www.72crm.com申请用户,并开通组织
    2. 在命令行运行
    CRM_USER=你的用户名 CRM_PASSWORD=你的密码 python3 -m pytest -qs --base-url=https://www.72crm.com
    

    也可配合pytest-html或allure-pytest生成报告
    pip install allure-pytest并下载allure-commandline工具并配置PATH后,运行

    CRM_USER=你的用户名 CRM_PASSWORD=你的密码 python3 -m pytest -q --base-url=https://www.72crm.com --alluredir=allure-results
    allure server allure-results
    

    显示报告如下

    V6-完善参数检查

    数据格式

    import warnings
    from string import Template
    from collections import ChainMap
    import os
    import json5
    
    import openpyxl
    import requests
    import pytest
    
    context = ChainMap({}, os.environ)
    
    ALLOWED_HTTP_METHODS = {'GET', 'POST', 'HEAD', 'PUT', 'DELETE', 'PATCH', 'TRACE', 'OPTIONS'}
    
    ALLOWED_DATA_TYPES = {'FORM', 'JSON', 'RAW', 'BINARY'}
    
    def pytest_collect_file(parent, file_path):
        if file_path.suffix == '.xlsx' and file_path.name.startswith("test"):
            return ExcelFile.from_parent(parent, path=file_path)
    
    
    class ExcelFile(pytest.File):
        def collect(self):
            excel = openpyxl.load_workbook(self.path)
            sheet = excel.active
            case_name = None
            steps = []
            for row in sheet.iter_rows(2, values_only=True):
                name, *values = row
                if name:
                    steps = [values]
                    if case_name:
                        yield ExcelTest.from_parent(self, name=case_name, steps=steps)
                    case_name = name
                else:
                    steps.append(values)
            if case_name:
                yield ExcelTest.from_parent(self, name=case_name, steps=steps)
    
    
    class DataError(Exception):
        """用例数据格式错误"""
    
    class ConfigMissing(Exception):
        """配置缺失"""
    
    class JSON5DecodeError(Exception):
        """JSON5解析出错"""
        
    class FileNotExistError(Exception):
        """文件不存在"""
    
    class ExcelTest(pytest.Item):
        def __init__(self, name, parent, steps):
            super().__init__(name, parent)
            self.steps = steps
            self.s = requests.Session()  # 请求会话,注意self.session是pytest框架的执行会话
            try:
                self.base_url = self.config.getoption('--base-url') or self.config.getini('base_url')
            except ValueError:
                self.base_url = None  # TODO 处理getini异常
        
        def handle_method(self, method):
            if not isinstance(method, str) or method.upper() not in ALLOWED_HTTP_METHODS:
                raise DataError(f'请求方法:{method} 必须为字符串, 且必须为GET, POST, HEAD, PUT, DELETE, PATCH, TRACE, OPTIONS其中之一')
            return method
            
        def handle_url(self, url):
            if not isinstance(url, str) or not url.startswith('http') and not url.startswith('/'):
                raise DataError(f'接口URL:{url} 必须为字符串, 且必须以http开头或以/开头')
            url = self.render(url)
            if url.startswith('http'):
                return url
            if not self.base_url:
                raise ConfigMissing('命令行缺失--base-url参数, 或缺失base_url配置')
            
        def handle_data_type(self, data_type):
            if not isinstance(data_type, str) or data_type.upper() not in ALLOWED_DATA_TYPES:
                raise DataError(f'请求方法:{data_type} 必须为字符串, 且必须为FORM, JSON, RAW, BINARY其中之一')  # TODO 处理 data_type为空但data有数据
            return data_type
            
        def handle_json_data(self, data):
            try:
                data = json5.loads(data)
            except ValueError:
                raise JSON5DecodeError('data数据按JSON5解码转字典或列表出错')
            else:
                return data
            
        def handle_raw_data(self, data):
            return data.encode('utf-8')
            
        def handle_form_data(self, data):
            _data, _files = {}, {}
            for row in data.split('\n'):
                if not row:
                    continue
                try:
                    key, value = row.split('=', 1)
                except ValueError:
                    raise DataError(f'form格式请求数据行: {row} 应以=号分割')
                else:
                    key, value = key.strip(), value.strip()
                    if not value.startswith('FILE:'):
                        _data[key] = value
                    else:
                        file_path = value.lstrip('FILE:').strip()
                        if not os.path.isfile(file_path):
                            raise FileNotExistError(f'data数据中文件路径:{file_path}不存在')
                        _files['key'] = open(file_path, 'rb')  # todo 三元数组
            return _data, _files
        
        def handle_binary_data(self, data):
            if not data.startswith('FILE:'):
                raise DataError(f'Binary格式data数据:{data} 应以FILE:开头')
            file_path = data.lstrip('FILE:').strip()
            if not os.path.isfile(file_path):
                raise FileNotExistError(f'data数据中文件路径:{file_path}不存在')
            return open(file_path, 'rb')
            
        def handle_data(self, data, data_type):
            if data is None:
                return data
            if not isinstance(data, str):
                raise DataError('data应为空或字符串')
            data = self.render(data.strip())
    
            if data_type is None:
                warnings.warn('data_type缺失')
                if data.startswith('{') or data.startswith('['):
                    data_type = 'JSON'
                elif '=' in data.split('\n')[0]:
                    data_type = 'FORM'
                elif data.startswith('FILE:'):
                    data_type = 'BINARY'
                else:
                    data_type = 'RAW'
                    print(f'使用data_type={data_type}')
                
            if data_type.upper() == 'JSON':
                return dict(json=self.handle_json_data(data))
            elif data_type.upper() == 'FORM':
                data, files = self.handle_form_data(data)
                return dict(data=data, files=files)
            elif data_type.upper() == 'RAW':
                return dict(data=self.handle_raw_data(data))
            elif data_type.upper() == 'BINARY':
                return dict(data=self.handle_binary_data(data))
            else:
                raise ValueError('data_type仅支持FORM, JSON, RAW, BINARY其中之一')
            
        def render(self, text):
            global context
            return Template(text).safe_substitute(context)
            
        def handle_headers(self, headers):
            if headers is None:
                return headers
            if not isinstance(headers, str):
                raise DataError('headers应为空或者字符串')
            headers = self.render(headers)
            _headers = {}
            for row in headers.split('\n'):
                if not row:
                    continue
                try:
                    key, value = row.split(':', 1)
                except ValueError:
                    raise DataError(f'form格式请求数据行: {row} 应以:号分割')
                else:
                    _headers[key] = value
            
        def get_request(self, data):
            method, url, data_type, data, headers, self.verify, self.register, *_ = data
            request = dict(
                method = self.handle_method(method),
                url = self.handle_url(url),
                headers = self.handle_headers(headers)
            )
            request.update(self.handle_data(data, data_type))
            return request
            
            
        def send_request(self, data):
            request = self.get_request(data)
            print('发送请求', request)
            res = self.s.request(**request,
                                 hooks={'response': [self.print_res, self.register_var, self.verify_res]})
            return res
    
        def register_var(self, res, *args, **kwargs):
            if self.register:
                for line in self.register.split('\n'):
                    key, expr = line.split('=')
                    context[key.strip()] = eval(expr.strip())
    
        def verify_res(self, res, *args, **kwargs):
            locals().update(context)
            if self.verify:
                for line in self.verify.split('\n'):
                    if line:
                        assert eval(line)
    
        def print_res(self, res, *args, **kwargs):
            print(res.text)
    
        def runtest(self):
            print('运行', self.name)
            for data in self.steps:
                self.send_request(data)
    
    

    TODO

    • 接口分层
    • 并发
    • 用例Tag
  • 相关阅读:
    Vue、Node 全栈,结合使用获取数据
    Day3.18组件案例-发表评论功能
    Day3.17父组件向子组件传方法
    Day3.16父组件向子组件传值
    Day3.15组件切换动画
    Day3.14组件切换方式二
    把旧系统迁移到.Net Core 2.0 日记 (19) --UI转用adminLTE
    NopCommerce 更改发票字体
    本地可以发邮件,阿里云服务器发送邮件失败,25端口被禁用
    WIFI 万能钥匙万玉权:团队之中要有跨三界之外的“闲人” [转]
  • 原文地址:https://www.cnblogs.com/superhin/p/16006295.html
Copyright © 2020-2023  润新知