数据格式
文件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框架
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)
运行方法
- 需要在www.72crm.com申请用户,并开通组织
- 在命令行运行
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