默认情况下, pytest会查找test开头或结尾的module, Test开头的class, 以及test开头的方法
于是我们可能会给某个功能定义一个module或者class, 不同的场景再定义不同的function
然而接口测试的基本套路无非是构造请求参数, 发送请求, 校验响应结果
所以那么多的function都重复地进行着这些步骤, 试想, 如果将整个流程封装起来, 岂不美哉
拿登录为例
每一个场景定义一个方法, 思路清晰, 只是写起来冗余
def test_login_with_correct_username_and_password():
pass
def test_login_with_unmatched_username_and_password():
pass
def test_login_with_invalid_username():
pass
...
无论是正确的登录, 还是错误的登录, 都调用同一个接口, 即入参可以锁定在username, password
不同的参数值, 将导致不同的响应结果, 所以考虑将输入与输出都参数化, 且彼此对应
username | password | status | msg | data | link |
---|---|---|---|---|---|
admin | changeme | 0 | login in | admin | / |
admin | admin | -101 | unmatched username and password | /login |
然后使用@pytest.mark.parametrize
加载测试数据
相较于前一种"简单实现", 同一个接口只需定义一个方法, 更多的精力将投入在数据准备上
虽然参数化已经大大减少了用例数, 但不同的接口依然做着重复的工作
因此下一步的目标是将接口测试的前后流程封装起来, 形成一条流水线
大体思路: 初始化加载 -> 收集测试文件 -> 从文件中提取测试用例 -> 执行用例
runner.py, 加载配置文件, 包括基本属性, 日志参数, 报告路径, 邮件信息等
报告使用了pytest-html, 需额外安装
Config类实现yml文件的解析, 返回dict
root_dir = os.path.dirname(__file__)
env_conf_file = os.path.join(root_dir, 'config', 'env.yml')
ENV = config.Config.load(env_conf_file)
if __name__ == '__main__':
logging.config.dictConfig(ENV.get('logging'))
logging.info('start to run pytest')
report_path = os.path.join(root_dir, ENV.get('report-path'))
pytest.main(['-q', '--html', report_path])
借用pytest的hook方法
定义收集对象以.yml
结尾, 且以sit
开头, 结果封装在Feature对象中
def pytest_collect_file(path, parent):
if path.ext == '.yml' and path.basename.startswith('sit'):
return model.Feature(path, parent)
def pytest_itemcollected(item):
logging.info('collect case -> {}'.format(item.name))
def pytest_runtest_call(item):
pass
Feature继承自pytest.File
collect
方法从Feature中提取出测试用例
这里大致将参数分为base(基本参数, 即共用), ins(入参,包括url, method, data等), outs(响应结果, 这里假定返回json数据)
class Feature(pytest.File):
def collect(self):
count = 1
feature = config.Config.load(self.fspath)
base = feature.get(tags.BASE)
use_session = feature.get(tags.USE_SESSION, True)
if feature.get(tags.SCENARIOS) is not None:
for s in feature.get(tags.SCENARIOS):
name = s.get(tags.DESC, '{}_{}'.format(self.name, count))
count += 1
ins = s.get(tags.INS)
outs = s.get(tags.OUTS)
if isinstance(ins, dict) and isinstance(outs, dict):
yield Scenario(name, self, base, ins, outs, use_session)
加载数据, 并构造Request
除登录接口外, 其余接口基本都有session校验, 所以需要特别注意
class Scenario(pytest.Item):
def __init__(self, name, parent, base=None, ins=None, outs=None, use_session=True):
super(Scenario, self).__init__(name, parent)
self.name = name
self.parent = parent
self.base = base
self.ins = ins
self.outs = outs
self.use_session = use_session
def _append_attrs(self, src, ext, tag=tags.HEADERS):
if isinstance(ext, dict) and ext.get(tag) is not None:
for key in ext.get(tag):
if key not in src:
src[key] = ext.get(tag).get(key)
def runtest(self):
url = (ENV.get(tags.ENV_BASE_URL, '') + self.base.get(tags.URL, '') + self.ins.get(tags.URL, '/')).replace('[^:]//', '/')
if not url.endswith('/'): url = url + '/'
headers = self.ins.get(tags.HEADERS, {})
self._append_attrs(headers, self.base, tags.HEADERS)
self._append_attrs(headers, ENV, tags.ENV_BASE_HEADER)
method = self.ins.get(tags.METHOD)
data = self.ins.get(tags.DATA)
expected = self.outs
req = requests.Request(url=url, headers=headers, method='POST')
if method is not None and str(method).upper() == 'GET':
req.method = 'GET'
req.params = data
else:
req.data = data
http_session = requests.session()
if self.use_session:
http_session = HttpSession()
prep = http_session.prepare_request(req)
else:
prep = req.prepare()
resp = http_session.send(prep)
asserts.assert_dict(resp.json(), expected)
假定响应结果均为json, 因些这也是受限之处
def assert_dict(actual, expected):
assert isinstance(actual, dict)
assert isinstance(expected, dict)
assert len(expected) > 0
for key in expected:
value = expected.get(key)
if isinstance(value, dict):
assert_dict(actual.get(key), value)
elif isinstance(value, (str, bool)):
assert value == actual.get(key)
elif isinstance(value, list):
# assert len(value) == len(actual.get(key))
for item in value:
assert item in actual.get(key)
sit_login.yml
use-session: false
base:
url: /login
scenarios:
-
desc: login with correct username and password
ins:
data:
username: admin
password: changeme
outs:
status: 0
msg: ok
data: admin
link: /
- desc: login with unmatched username and password
use-session: false
ins:
data:
username: admin
password: admin
outs:
status: -101
msg: unmatched username and password
link: /login
from flask import Flask, request, session, redirect, url_for, jsonify
app = Flask(__name__)
app.secret_key = b'_5#y2L"F4Q8z\n\xec]/'
users = [
{'name': 'Lucy', 'age': 18, 'favor': 'Music'},
{'name': 'Kate', 'age': 16, 'favor': 'Game'},
{'name': 'John', 'age': 20, 'favor': 'Math'},
]
@app.route('/')
def index():
return 'Hello world'
@app.route('/login/', methods=['GET', 'POST'])
def login_page():
if request.method == 'GET':
return 'login page'
elif request.method == 'POST':
try:
username = request.form['username']
password = request.form['password']
if username == 'admin' and password == 'changeme':
session['user'] = username
return make_json_resp(data=username, link='/')
else:
return make_json_resp(status=-101, msg='unmatched username and password', link='/login')
except KeyError:
return make_json_resp(status=-102, msg='invalid parameters', link='/login')
@app.route('/users/', methods=['GET', 'POST'])
def user_page():
if request.method == 'GET':
return make_json_resp(data=users, link='/users')
elif request.method == 'POST':
try:
name = request.form['name']
age = request.form['age']
favor = request.form['favor']
user = dict(name=name, age=age, favor=favor)
users.append(user)
return make_json_resp(data=user, msg='Add user {} successfully'.format(name), link='/users')
except KeyError:
return make_json_resp(status=-201, msg='invalid parameters')
elif request.method == 'DELETE':
try:
name = request.form['name']
count = 0
for user in users:
if name == user.get('name'):
count += 1
users.remove(user)
return make_json_resp(msg='delete {} user(s)'.format(count), link='/users')
except KeyError:
return make_json_resp(status=-202, msg='invalid parameters')
@app.before_request
def login_check():
if request.path == '/login/':
return
if session.get('user') is None:
return redirect(url_for('login'))
def make_json_resp(**kwargs):
result = {}
result['status'] = kwargs.get('status', 0)
result['msg'] = kwargs.get('msg', 'ok')
result['data'] = kwargs.get('data')
result['link'] = kwargs.get('link')
return jsonify(result)
if __name__ == '__main__':
app.run()