pytest:接口测试用例封装

1 前言

默认情况下, pytest会查找test开头或结尾的module, Test开头的class, 以及test开头的方法

于是我们可能会给某个功能定义一个module或者class, 不同的场景再定义不同的function

然而接口测试的基本套路无非是构造请求参数, 发送请求, 校验响应结果

所以那么多的function都重复地进行着这些步骤, 试想, 如果将整个流程封装起来, 岂不美哉

2 简单实现

拿登录为例

每一个场景定义一个方法, 思路清晰, 只是写起来冗余

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
	
	...

3 参数化

无论是正确的登录, 还是错误的登录, 都调用同一个接口, 即入参可以锁定在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加载测试数据

相较于前一种"简单实现", 同一个接口只需定义一个方法, 更多的精力将投入在数据准备上

4 流程化

虽然参数化已经大大减少了用例数, 但不同的接口依然做着重复的工作

因此下一步的目标是将接口测试的前后流程封装起来, 形成一条流水线

大体思路: 初始化加载 -> 收集测试文件 -> 从文件中提取测试用例 -> 执行用例

4.1 工程结构

pytest:接口测试用例封装_第1张图片

4.2 启动入口

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])

4.3 收集测试文件

借用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

4.4 提取测试用例

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)

4.5 执行测试

加载数据, 并构造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)

4.6 校验结果

假定响应结果均为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)

5 用例数据

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

6 基于Flask的接口环境

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()

你可能感兴趣的:(测试,python,python,集成测试)