基于 Pytest 框架的接口自动化测试开发实践(包教会!!!)

一、pytest的基本介绍

1、Pytest概念

Pytest是Python的一种易用、高效和灵活的单元测试框架,可以支持单元测试和功能测试。本文不以介绍Pytest工具本身为目的,而是以一个实际的API测试项目为例,将Pytest的功能应用到实际的测试工程实践中,教大家将Pytest用起来。

在开始本文前,请务必弄清楚测试框架和测试工具的概念,测试框架例如Unittest、Pytest、TestNG这类,而测试工具指的则是Selenium、Appium、Jmeter这类。

测试框架的作用是更好的帮助我们管理和执行测试用例、实现参数化、断言、生成测试报告等基础性工作,更偏于让我们把精力用于测试用例的编写上,一个良好的测试框架具有高扩展性,支持二次开发,并且支持多种类型的自动化测试。

测试工具的作用是为了完成某一类型的测试,例如Selenium应用于对WEB UI进行自动化测试,Appium用来对App进行自动化测试,Jmeter可以用来实现API自动化测试和性能测试。另外软件源生带的API测试工具例如Java中的OKHttp库,HTTP的cilent以及Python中的request库。

2、Pytest核心功能

1)入门简单,易上手,文档资源丰富,可参考实例众多

2)支持参数化,断言方式也简单方便

3)支持简单的单元测试与复杂的功能测试,并方便与持续集成工具集成

4)测试用例可根据自己的需求执行全部或者部分,且失败的用例可以重复执行

5)能运行nose、unittest编写的测试用例,且支持并发执行

6)能生成标准的Junit XML格式的测试结果

7)具有众多第三方插件,可以自定义扩展

二、软件的安装与配置

1、首先得安装pip命令,pip命令的安装与卸载可以参考:https://blog.csdn.net/LYX_WIN/article/details/107854335

2、Pytest的安装:

pip install -U pytest

报错了:

DEPRECATION: Python 2.7 reached the end of its life on January 1st, 2020. Please upgrade your Python as Python 2.7 is no longer maintained. pip 21.0 will drop support for Python 2.7 in January 2021. More details about Python 2 support in pip can be found at https://pip.pypa.io/en/latest/development/release-process/#python-2-support

弃用Python 2.7将于2020年1月1日到期

请升级您的Python,因为在该日期之后将不再维护Python 2.7。

未来的pip版本将放弃对Python 2.7的支持。

3、解决方法:

先去官网下载最新的版本,一般3.7以上:https://www.python.org/ftp/python

安装完成后使用如下命令安装:

pip3 install -U pytest

使用如下命令查看,若有帮助信息,则说明安装成功

$ pytest --help

4、在pycharm中配置切换最新版本

三、创建测试项目

1、先创建一个测试项目目录api_pytest,并为项目创建虚拟项目

$ mkdir pytest

2、依赖包安装

pip3 install pytest requests

或者:

再或者在编写代码过程中也可以导入:

导入完成:

接下来如法炮制继续导入jsonpath、yaml、pytest的包。

3、现在我们创建一个data目录用来存放测试数据,一个tests目录用来存放测试脚本,一个config目录用来存放配置文件,一个utils目录从来存放工具。

mkdir  data tests config utils

4、编写测试用例

这两个API信息如下:
|接口 | 示例|
|--|--|
| 获取可预订列表 | http://cn.ejee.site/api/booking/queryReserveList.do |

我们先写可预订列表API的自动化测试用例,设置3个校验点:

       a、 验证请求中的type与响应中的type一致。

       b、验证请求中的channel与响应中的name一致。

       c、验证响应中的domain是'testdomainadd0002.com'。 

在tests目录中创建个test_in_booking_list.py,里面编写测试用例,内容如下:

#!/usr/bin/python
# -*- coding:utf-8 -*-
import requests
import jsonpath


class TestInBookingList(object):
    def test_in_booking_list(self):
        host = "http://cn.ejee.site"
        path = "/api/booking/queryReserveList.do"
        params = {"clientId": "jie",
                  "sign": "45b4cd33de5106aa9241108268fbb97a",
                  "type": "PreRelease",
                  "channel": "GuoYu"
                  }
        headers = {
            "Content-Type": "application/x-www-form-urlencoded"
        }
        r = requests.request("POST", url=host + path, headers=headers, params=params)
        response = r.json()
        # 验证请求中的type与响应中的type一致。
        responseType = jsonpath.jsonpath(response, "$..type")[0]
        assert responseType == params["type"]
        # 验证请求中的channel与响应中的name一致。
        responseName = jsonpath.jsonpath(response, "$..name")[0]
        assert responseName == params["channel"]
        # 验证响应中的domain是'testdomainadd0001.com'。
        responseDomain = jsonpath.jsonpath(response, "$..domain")[0]
        assert responseDomain == "testdomainadd0001.com", "实际的可预订域名是:{}".format(responseDomain)

5、执行测试用例:

$ pytest tests/test_in_booking_list.py
============================= test session starts ==============================
platform darwin -- Python 3.8.5, pytest-6.0.1, py-1.9.0, pluggy-0.13.1
rootdir: /Users/leiyuxing/pytest
collected 1 item

tests/test_in_booking_list.py F                                          [100%]

=================================== FAILURES ===================================
____________________ TestInBookingList.test_in_booking_list ____________________

self = <test_in_booking_list.TestInBookingList object at 0x7f9449815eb0>

    def test_in_booking_list(self):
        host = "http://cn.ejee.site"
        path = "/api/booking/queryReserveList.do"
        params = {"clientId": "jie",
                  "sign": "45b4cd33de5106aa9241108268fbb97a",
                  "type": "PreRelease",
                  "channel": "GuoYu"
                  }
        headers = {
            "Content-Type": "application/x-www-form-urlencoded"
        }
        r = requests.request("POST", url=host + path, headers=headers, params=params)
        response = r.json()
        print(response)
        # 验证请求中的type与响应中的type一致。
        responseType = jsonpath.jsonpath(response, "$..type")[0]
        assert responseType == params["type"]
        # 验证请求中的channel与响应中的name一致。
        responseName = jsonpath.jsonpath(response, "$..name")[0]
        assert responseName == params["channel"]
        # 验证响应中的domain是'testdomainadd0001.com'。
        responseDomain = jsonpath.jsonpath(response, "$..domain")[0]
>       assert responseDomain == "testdomainadd0001.com", "实际的可预订域名是:{}".format(responseDomain)
E       AssertionError: 实际的可预订域名是:testdomainadd0002.com
E       assert 'testdomainadd0002.com' == 'testdomainadd0001.com'
E         - testdomainadd0001.com
E         ?                 ^
E         + testdomainadd0002.com
E         ?                 ^

tests/test_in_booking_list.py:30: AssertionError
----------------------------- Captured stdout call -----------------------------
{'pageSize': 20, 'totalPageNum': 1, 'totalItemNum': 1, 'currentPageNum': 1, 'data': [{'domain': 'testdomainadd0002.com', 'bookEndTime': '2020-08-25 22:50:00', 'type': 'PreRelease', 'deleteTime': '2020-08-11', 'channels': [{'id': '91', 'name': 'GuoYu', 'price': 1.0, 'transferPrice': 69.0}], '_map': {}}], 'code': '200', 'msg': '操作成功'}
=========================== short test summary info ============================
FAILED tests/test_in_booking_list.py::TestInBookingList::test_in_booking_list
============================== 1 failed in 0.32s ===============================

这个命令执行时后,会在tests/目录下寻找指定的测试用例。通过上面的执行,我们一共收到一个测试用例,测试结果是失败的(标记为F),并且在FAILURES部分输出了详细的错误信息,通过这些信息,我们可以分析测试失败的原因,上面测试用例失败的原因是断言domain的时候出错了,预期的domain是"testdomainadd0001.com",但实际是"testdomainadd0002.com",从上面结果也可以看出预期结果对比非常直观。

执行测试用例的方法还有很多种,都是在pytest后面添加不同的参数即可,主要有以下几种:

$ pytest               # run all tests below current dir

$ pytest test_module.py   # run tests in module

$ pytest somepath      # run all tests below somepath

$ pytest -k stringexpr # only run tests with names that match the

                      # the "string expression", e.g. "MyClass and not method"

                      # will select TestMyClass.test_something

                      # but not TestMyClass.test_method_simple

$ pytest test_module.py::test_func # only run tests that match the "node ID",

                                    # e.g "test_mod.py::test_func" will select

                                    # only test_func in test_mod.py

6、数据与脚本分离

将测试数据和测试代码放到同一个py文件中,而且是同一个测试方法中,产生了紧耦合,这样在修改测试数据或测试代码的时候,影响和改动都较大,不利于测试数据和代码的维护。并且接口测试往往是数据驱动的测试,测试数据和代码放一块也不利于借助pytest进行参数化

在data/目录下创建一个用于存放测试数据的Yaml文件test_in_booking_list.yaml,内容如下:

tests:
  - case: 验证获取可预订列表
    http:
      method: POST
      path: /api/booking/queryReserveList.do
      headers:
        Content-Type: application/x-www-form-urlencoded
      params:
        clientId: jie
        sign: 45b4cd33de5106aa9241108268fbb97a
        type: PreRelease
        channel: GuoYu
    expected:
      response:
        data:
          type: PreRelease
          channel: GuoYu
          domain: testdomainadd0002.com

这个测试数据文件中,有一个数组tests,里面包含的是一条完整的测试数据。一个完整的测试数据由三部分组成:

  1. case,表示测试用例名称。
  2. http,表示请求对象。
  3. expected,表示预期结果。

http这个请求对象包含了被测接口的所有参数,包括请求方法、请求路径、请求头、请求参数。

expected表示预期结果,上面的测试数据中,只列出了对请求响应的预期值,实际测试中,还可以列出对数据库的预期值。

测试脚本也要做相应的改造,需要读取test_in_booking_list.yaml文件获取请求数据和预期结果,然后通过requests发出请求。修改后的测试代码如下:

#!/usr/bin/python
# -*- coding:utf-8 -*-
import requests
import jsonpath
import yaml


def get_test_data(test_data_path):
    case = []  # 存储测试用例的名称
    http = []  # 存储请求对象
    expected = []  # 存储预期结果
    with open(test_data_path, encoding='utf-8') as f:
        dat = yaml.load(f.read(), Loader=yaml.SafeLoader)
        test = dat['tests']
        for td in test:
            case.append(td.get('case', ''))
            http.append(td.get('http', {}))
            expected.append(td.get('expected', {}))
        parameters = zip(case, http, expected)
        return case, parameters


cases, parameters = get_test_data("/Users/leiyuxing/pytest/data/test_in_booking_list.yaml")
list_params = list(parameters)


class TestInBookingList(object):
    def test_in_booking_list(self):
        host = "http://cn.ejee.site"
        r = requests.request(list_params[0][1]["method"],
                             url=host + list_params[0][1]["path"],
                             headers=list_params[0][1]["headers"],
                             params=list_params[0][1]["params"])
        response = r.json()
        # 验证请求中的type与响应中的type一致。
        responseType = jsonpath.jsonpath(response, "$..type")[0]
        assert responseType == list_params[0][2]['response']["data"]["type"]
        # 验证请求中的channel与响应中的name一致。
        responseName = jsonpath.jsonpath(response, "$..name")[0]
        assert responseName == list_params[0][2]['response']["data"]["channel"]
        # 验证响应中的domain是'testdomainadd0001.com'。
        responseDomain = jsonpath.jsonpath(response, "$..domain")[0]
        assert responseDomain == list_params[0][2]['response']["data"]["domain"], "实际的可预订域名是:{}".format(responseDomain)

测试脚本中定义了一个读取测试数据的函数get_test_data,通过这个函数从测试数据文件test_in_booking_list.yaml中读取到了测试用例名称case,请求对象http和预期结果expected。这三部分分别是一个列表,通过zip将他们压缩到一起。

测试方法test_in_booking_list并没有太大变化,只是发送请求所使用的测试数据不是写死的,而是来自于测试数据文件了。

通常情况下,读取测试数据的函数不会定义在测试用例文件中,而是会放到utils包中,比如放到utils/commontools.py中

7、参数化

上面我们将测试数据和测试脚本相分离,如果要为测试用例添加更多的测试数据,往tests数组中添加更多的同样格式的测试数据即可。这个过程叫作参数化。

参数化的意思是对同一个接口,使用多种不同的输入对其进行测试,以验证是否每一组输入参数都能得到预期结果。Pytest提供了pytest.mark.paramtrize这种方式来进行参数化,我们先看下官方网站提供的介绍pytest.mark.paramtrize用法的例子:

# content of tests/test_time.py
import pytest
​
from datetime import datetime, timedelta
​
testdata = [
    (datetime(2001, 12, 12), datetime(2001, 12, 11), timedelta(1)),
    (datetime(2001, 12, 11), datetime(2001, 12, 12), timedelta(-1)),
]
​
​
@pytest.mark.parametrize("a,b,expected", testdata)
def test_timedistance_v0(a, b, expected):
    diff = a - b
    assert diff == expected

执行上面的脚本将会得到下面的输出,测试方法test_timedistance_v0被执行了两遍,第一遍执行用的测试数据是testdata列表中的第一个元组,第二遍执行时用的测试数据是testdata列表中的第二个元组。这就是参数化的效果,同一个脚本可以使用不同的输入参数执行测试。

现在对我们自己的测试项目中的测试脚本进行如下修改:

#!/usr/bin/python
# -*- coding:utf-8 -*-

import requests
import jsonpath
import pytest

from utils.commontools import get_test_data

cases, list_params = get_test_data("/Users/leiyuxing/pytest/data/test_in_booking_list.yaml")


class TestInBookingList(object):
    @pytest.mark.parametrize("case,http,expected", list(list_params), ids=cases)
    def test_in_booking_list(self, case, http, expected):
        host = "http://cn.ejee.site"
        r = requests.request(http["method"],
                             url=host + http["path"],
                             headers=http["headers"],
                             params=http["params"])
        response = r.json()
        # 验证请求中的type与响应中的type一致。
        responseType = jsonpath.jsonpath(response, "$..type")[0]
        assert responseType == expected['response']["data"]["type"]
        # 验证请求中的channel与响应中的name一致。
        responseName = jsonpath.jsonpath(response, "$..name")[0]
        assert responseName == expected['response']["data"]["channel"]
        # 验证响应中的domain是'testdomainadd0001.com'。
        responseDomain = jsonpath.jsonpath(response, "$..domain")[0]
        assert responseDomain == expected['response']["data"]["domain"], "实际的可预订域名是:{}".format(responseDomain)

但此时运行的时候你会发现无法从units中读取方法,这是py的一个bug:

$ pytest tests/
============================= test session starts ==============================
platform darwin -- Python 3.8.5, pytest-6.0.1, py-1.9.0, pluggy-0.13.1
rootdir: /Users/leiyuxing/pytest
collected 0 items / 1 error

==================================== ERRORS ====================================
________________ ERROR collecting tests/test_in_booking_list.py ________________
ImportError while importing test module '/Users/leiyuxing/pytest/tests/test_in_booking_list.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
/Library/Frameworks/Python.framework/Versions/3.8/lib/python3.8/importlib/__init__.py:127: in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
tests/test_in_booking_list.py:7: in <module>
    from utils.commontools import get_test_data
E   ModuleNotFoundError: No module named 'utils'
=========================== short test summary info ============================
ERROR tests/test_in_booking_list.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
=============================== 1 error in 0.31s ===============================

解决方法:在test_in_theaters.py中加入:

import os, sys

sys.path.append(os.getcwd())

再次运行就成功了:(这个搞了我好久,真的要注意!!!)

在测试方法上面添加了一个装饰器@pytest.mark.parametrize,装饰器会自动对list(list_params)解包并赋值给装饰器的第一参数。装饰器的第一个参数中逗号分隔的变量可以作为测试方法的参数,在测试方法内就可以直接获取这些变量的值,利用这些值发起请求和进行断言。装饰器还有一个参数叫ids,这个值作为测试用例的名称将打印到测试结果中。

在执行修改后的测试脚本前,我们在测试数据文件再增加2组测试数据,现在测试数据文件中,包含了3组测试数据:

tests:
  - case: 验证获取可预订列表--全部为预期结果
    http:
      method: POST
      path: /api/booking/queryReserveList.do
      headers:
        Content-Type: application/x-www-form-urlencoded
      params:
        clientId: jie
        sign: 45b4cd33de5106aa9241108268fbb97a
        type: PreRelease
        channel: GuoYu
    expected:
      response:
        data:
          type: PreRelease
          channel: GuoYu
          domain: testdomainadd0002.com
  - case: 验证获取可预订列表--域名非预期结果
    http:
      method: POST
      path: /api/booking/queryReserveList.do
      headers:
        Content-Type: application/x-www-form-urlencoded
      params:
        clientId: jie
        sign: 45b4cd33de5106aa9241108268fbb97a
        type: PreRelease
        channel: GuoYu
    expected:
      response:
        data:
          type: PreRelease
          channel: GuoYu
          domain: testdomainadd0001.com
  - case: 验证获取可预订列表--类型非预期结果
    http:
      method: POST
      path: /api/booking/queryReserveList.do
      headers:
        Content-Type: application/x-www-form-urlencoded
      params:
        clientId: jie
        sign: 45b4cd33de5106aa9241108268fbb97a
        type: PreRelease
        channel: GuoYu
    expected:
      response:
        data:
          type: Delete
          channel: GuoYu
          domain: testdomainadd0002.com

执行用例查看结果:

$ pytest tests/
============================= test session starts ==============================
platform darwin -- Python 3.8.5, pytest-6.0.1, py-1.9.0, pluggy-0.13.1
rootdir: /Users/leiyuxing/pytest
collected 1 item

tests/test_in_booking_list.py .                                          [100%]

============================== 1 passed in 0.58s ===============================
leiyuxingdeMacBook-Pro-2:pytest leiyuxing$ pytest tests/
============================= test session starts ==============================
platform darwin -- Python 3.8.5, pytest-6.0.1, py-1.9.0, pluggy-0.13.1
rootdir: /Users/leiyuxing/pytest
collected 3 items

tests/test_in_booking_list.py .FF                                        [100%]

=================================== FAILURES ===================================
_ TestInBookingList.test_in_booking_list[\u9a8c\u8bc1\u83b7\u53d6\u53ef\u9884\u8ba2\u5217\u8868--\u57df\u540d\u975e\u9884\u671f\u7ed3\u679c] _

self = <test_in_booking_list.TestInBookingList object at 0x7fd6930e4fa0>
case = '验证获取可预订列表--域名非预期结果'
http = {'headers': {'Content-Type': 'application/x-www-form-urlencoded'}, 'method': 'POST', 'params': {'channel': 'GuoYu', 'c...: 'jie', 'sign': '45b4cd33de5106aa9241108268fbb97a', 'type': 'PreRelease'}, 'path': '/api/booking/queryReserveList.do'}
expected = {'response': {'data': {'channel': 'GuoYu', 'domain': 'testdomainadd0001.com', 'type': 'PreRelease'}}}

    @pytest.mark.parametrize("case,http,expected", list(list_params), ids=cases)
    def test_in_booking_list(self, case, http, expected):
        host = "http://cn.ejee.site"
        r = requests.request(http["method"],
                             url=host + http["path"],
                             headers=http["headers"],
                             params=http["params"])
        response = r.json()
        # 验证请求中的type与响应中的type一致。
        responseType = jsonpath.jsonpath(response, "$..type")[0]
        # s=list_params[0][2]['response']["data"]["type"]
        assert responseType == expected['response']["data"]["type"]
        # 验证请求中的channel与响应中的name一致。
        responseName = jsonpath.jsonpath(response, "$..name")[0]
        assert responseName == expected['response']["data"]["channel"]
        # 验证响应中的domain是'testdomainadd0001.com'。
        responseDomain = jsonpath.jsonpath(response, "$..domain")[0]
>       assert responseDomain == expected['response']["data"]["domain"], "实际的可预订域名是:{}".format(responseDomain)
E       AssertionError: 实际的可预订域名是:testdomainadd0002.com
E       assert 'testdomainadd0002.com' == 'testdomainadd0001.com'
E         - testdomainadd0001.com
E         ?                 ^
E         + testdomainadd0002.com
E         ?                 ^

tests/test_in_booking_list.py:33: AssertionError
_ TestInBookingList.test_in_booking_list[\u9a8c\u8bc1\u83b7\u53d6\u53ef\u9884\u8ba2\u5217\u8868--\u7c7b\u578b\u975e\u9884\u671f\u7ed3\u679c] _

self = <test_in_booking_list.TestInBookingList object at 0x7fd6930f5b50>
case = '验证获取可预订列表--类型非预期结果'
http = {'headers': {'Content-Type': 'application/x-www-form-urlencoded'}, 'method': 'POST', 'params': {'channel': 'GuoYu', 'c...: 'jie', 'sign': '45b4cd33de5106aa9241108268fbb97a', 'type': 'PreRelease'}, 'path': '/api/booking/queryReserveList.do'}
expected = {'response': {'data': {'channel': 'GuoYu', 'domain': 'testdomainadd0002.com', 'type': 'Delete'}}}

    @pytest.mark.parametrize("case,http,expected", list(list_params), ids=cases)
    def test_in_booking_list(self, case, http, expected):
        host = "http://cn.ejee.site"
        r = requests.request(http["method"],
                             url=host + http["path"],
                             headers=http["headers"],
                             params=http["params"])
        response = r.json()
        # 验证请求中的type与响应中的type一致。
        responseType = jsonpath.jsonpath(response, "$..type")[0]
        # s=list_params[0][2]['response']["data"]["type"]
>       assert responseType == expected['response']["data"]["type"]
E       AssertionError: assert 'PreRelease' == 'Delete'
E         - Delete
E         + PreRelease

tests/test_in_booking_list.py:27: AssertionError
=========================== short test summary info ============================
FAILED tests/test_in_booking_list.py::TestInBookingList::test_in_booking_list[\u9a8c\u8bc1\u83b7\u53d6\u53ef\u9884\u8ba2\u5217\u8868--\u57df\u540d\u975e\u9884\u671f\u7ed3\u679c]
FAILED tests/test_in_booking_list.py::TestInBookingList::test_in_booking_list[\u9a8c\u8bc1\u83b7\u53d6\u53ef\u9884\u8ba2\u5217\u8868--\u7c7b\u578b\u975e\u9884\u671f\u7ed3\u679c]
========================= 2 failed, 1 passed in 0.56s ==========================

从结果看,Pytest收集到了3个items,测试脚本执行了3遍,第一遍执行用第一组测试数据,结果是成功),第二遍执行用第二组测试数据,结果是失败(F),第三遍执行第三组数据,结果是失败(F)。执行完成后的summary info部分,看到了一些Unicode编码,这里其实是ids的内容,因为是中文,所以默认这里显示Unicode编码。为了显示中文,需要在测试项目的根目录下创建一个Pytest的配置文件pytest.ini,在其中添加如下代码:

[pytest]
disable_test_id_escaping_and_forfeit_all_rights_to_community_support = True

再次执行测试脚本,在测试结果的summary_info部分,则会显示正确中文内容了:

=========================== short test summary info ============================
FAILED tests/test_in_booking_list.py::TestInBookingList::test_in_booking_list[验证获取可预订列表--域名非预期结果]
FAILED tests/test_in_booking_list.py::TestInBookingList::test_in_booking_list[验证获取可预订列表--类型非预期结果]

按照这种参数化的方法,如果想修改或者添加测试数据,只需要修改测试数据文件即可。

7、测试配置管理

host是写在测试脚本中的,这种硬编码方式显然是不合适的。这个host在不同的测试脚本都会用到,应该放到一个公共的地方来维护。如果需要对其进行修改,那么只需要修改一个地方就可以了。根据我的实践经验,将其放到config文件夹中,是比较好的

除了host外,其他与测试环境相关的配置信息也可以放到config文件夹中,比如数据库信息、kafka连接信息等,以及与测试环境相关的基础测试数据,比如测试账号。很多时候,我们会有不同的测试环境,比如dev环境、test环境、stg环境、prod环境等。我们可以在config文件夹下面创建子目录来区分不同的测试环境。

在config文件夹下建立文件并写入如下文件:

host:
  guoyu: http://cn.ejee.site

将测试配置信息从脚本中拆分出来,就需要有一种机制将其读取到,才能在测试脚本中使用。Pytest提供了fixture机制,通过它可以在测试执行前执行一些操作,在这里我们利用fixture提前读取到配置信息。我们先对官方文档上的例子稍加修改,来介绍fixture的使用。请看下面的代码:

#!/usr/bin/python
# -*- coding:utf-8 -*-
import pytest


@pytest.fixture
def smtp_connection():
    import smtplib
    connection = smtplib.SMTP_SSL("smtp.163.com", 465, timeout=5)
    yield connection
    print("teardown smtp")
    connection.close()


def test_ehlo(smtp_connection):
    response, msg = smtp_connection.ehlo()
    assert response == 250
    assert 0

这段代码中,smtp_connection被装饰器@pytest.fixture装饰,表明它是一个fixture函数。这个函数的功能是连接163邮箱服务器,返回一个连接对象。当test_ehlo的最后一次测试执行完成后,执行print("teardown smtp")和connection.close()断开smtp连接。

fixture函数名可以作为测试方法test_ehlo的参数,在测试方法内部,使用fixture函数名这个变量,就相当于是在使用fixture函数的返回值。

回到我们读取测试配置信息的需求上,在自动化测试项目tests/目录中创建一个文件conftest.py,定义一个fixture函数env:

#!/usr/bin/python
# -*- coding:utf-8 -*-
import os

import pytest
import yaml


@pytest.fixture(scope="session")
def env(request):
    config_path = os.path.join(request.config.rootdir,
                               "config",
                               "test",
                               "config.yaml")
    with open(config_path) as f:
        env_config = yaml.load(f.read(), Loader=yaml.SafeLoader)
    return env_config

conftest.py文件是一个plugin文件,里面可以实现Pytest提供的Hook函数或者自定义的fixture函数,这些函数只在conftest.py所在目录及其子目录中生效。scope="session"表示这个fixture函数的作用域是session级别的,在整个测试活动中开始前执行,并且只会被执行一次。除了session级别的fixture函数,还有function级别、class级别等。

env函数中有一个参数request,其实request也是一个fixture函数。在这里用到了它的request.config.rootdir属性,这个属性表示的是pytest.ini这个配置文件所在的目录,因为我们的测试项目中pytest.ini处于项目的根目录,所以config_path的完整路径就是:

/Users/leiyuxing/pytest/config/test/config.yaml

将env作为参数传入测试方法test_in_booking_list中,将测试方法内的host改为env["host"]["guoyu"]:

class TestInBookingList(object):
    @pytest.mark.parametrize("case,http,expected", list(list_params), ids=cases)
    def test_in_booking_list(self, env, case, http, expected):
        r = requests.request(http["method"],
                             url=env["host"]["guoyu"] + http["path"],
                             headers=http["headers"],
                             params=http["params"])
        response = r.json()

上面的env函数实现中,有点点小缺憾,就是读取的配置文件是固定的,读取的都是test环境的配置信息,我们希望在执行测试时,通过命令行选项,可指定读取哪个环境的配置,以便在不同的测试环境下开展测试。Pytest提供了一个叫作pytest_addoption的Hook函数,可以接受命令行选项的参数,写法如下:

def pytest_addoption(parser):
    parser.addoption("--env",
                     action="store",
                     dest="environment",
                     default="test",
                     help="environment: test or prod")

pytest_addoption的含义是,接收命令行选项--env选项的值,存到environment变量中,如果不指定命令行选项,environment变量默认值是test。将上面代码也放入conftest.py中,并修改env函数,将os.path.join中的"test"替换为request.config.getoption("environment"),这样就可以通过命令行选项来控制读取的配置文件了。比如执行test环境的测试,可以指定--env test:

$ py.test --env test tests/test_in_theaters.py

如果不想每次都在命令行上指定--env,还可以将其放入pyest.ini中:

[pytest]
addopts = --env prod

命令行上的参数会覆盖pyest.ini里面的参数。

8、标记与分组

通过pytest.mark可以给测试用例打上标记,常见的应用场景是:针对某些还未实现的功能,将测试用例主动跳过不执行。或者在某些条件下,测试用例跳过不执行。还有可以主动将测试用例标记为失败等等。针对三个场景,pytest提供了内置的标签,我们通过具体代码来看一下:

#!/usr/bin/python
# -*- coding:utf-8 -*-
import sys

import pytest


class TestMarks(object):
    @pytest.mark.skip(resaon="not implementation")
    def test_the_unknown(self):
        """""
        跳过不执行,因为被测逻辑还没有被实现
        """""
        assert 0

    @pytest.mark.skipif(sys.version_info < (3, 7), reason="requires python3.7 or higher")
    def test_skipif(self):
        """""
        低于python3.7版本不执行这条测试用例
        """""
        assert 1

    @pytest.mark.xfail
    def test_xfail(self):
        """""
        Indicate that you expect it to fail
        这条用例失败时,测试结果被标记为xfail(expected to fail),并且不打印错误信息。
        这条用例执行成功时,测试结果被标记为xpassed(unexpectedly passing)
        """""
        assert 0

    @pytest.mark.xfail(run=False)
    def test_xfail_not_run(self):
        """""
        run=False表示这条用例不用执行
        """""
        assert 0

执行结果:

$ pytest tests/test_marks.py
============================= test session starts ==============================
platform darwin -- Python 3.8.5, pytest-6.0.1, py-1.9.0, pluggy-0.13.1
rootdir: /Users/leiyuxing/pytest, configfile: pytest.ini
collected 4 items

tests/test_marks.py s.xx                                                 [100%]

=================== 1 passed, 1 skipped, 2 xfailed in 0.07s ====================

从结果中可以看到,第一条测试用例skipped了,第二条测试用例passed了,第三条和第四条测试用例xfailed了。

除了内置的标签,还可以自定义标签并加到测试方法上:

@pytest.mark.slow
    def test_slow(self):
        """
        自定义标签
        """
        assert 0

这样就可以通过-m过滤或者反过滤,比如只执行被标记为slow的测试用例:

$ py.test -s -q --tb=no -m "slow" tests/test_marks.py
$ py.test -s -q --tb=no -m "not slow" tests/test_marks.py

对于自定义标签,为了避免出现PytestUnknownMarkWarning,最好在pytest.ini中注册一下:

[pytest]
markers =
    slow: marks tests as slow (deselect with '-m "not slow"')

9、并发执行

插件安装:相关学习连接可参考https://www.cnblogs.com/poloyy/p/12694861.html

$ pip3 install pytest-xdist -i http://pypi.douban.com/simple/ --trusted-host pypi.douban.com

如果自动化测试用例数量成千上万,那么并发执行它们是个很好的主意,可以加快整体测试用例的执行时间。

pyest有一个插件pytest-xdist可以做到并发执行,安装之后,执行测试用例通过执行-n参数可以指定并发度,通过auto参数自动匹配CPU数量作为并发度。并发执行本文的所有测试用例:

$ pytest -s -q --tb=no -n auto tests/
bringing up nodes...
.FsxxFFF.F
=========================== short test summary info ============================
FAILED tests/test_in_booking_list.py::TestInBookingList::test_in_booking_list[验证获取可预订列表--域名非预期结果]
FAILED tests/test_marks.py::TestMarks::test_slow - assert 0
FAILED tests/test_marks.py::TestMarks::test_ku - assert 0
FAILED tests/test_in_booking_list.py::TestInBookingList::test_in_booking_list[验证获取可预订列表--类型非预期结果]
FAILED tests/test_smtpsimple.py::test_ehlo - assert 0
5 failed, 2 passed, 1 skipped, 2 xfailed in 3.81s

可以非常直观的感受到,并发执行比顺序执行快得多。但是并发执行需要注意的是,不同的测试用例之间不要有测试数据的相互干扰,最好不同的测试用例使用不同的测试数据。

10、关于日志打印问题

1、这也是pytest不方便的一个地方,如果要用print打印的时候需要添加命令格式:

pytest -s tests/

2、如果需要用logger打印需要添加如下代码,不然就算你引入包也不生效:

logger = logging.getLogger('')  # 获取日志
logger.setLevel(logging.DEBUG)  # 获取日志等级
ch = logging.StreamHandler()  # 获取日志句柄
ch.setLevel(logging.DEBUG)    # 设置日志等级
formatter = logging.Formatter('%(asctime)s-%(name)s-%(levelname)s-%(message)s')
ch.setFormatter(formatter) # 将显示格式绑定给ch句柄
logger.addHandler(ch)
logger.info('test info')
logger.debug('test debug')

11.测试报告

Pytest可以方便的生成测试报告,通过指定--junitxml参数可以生成XML格式的测试报告,junitxml是一种非常通用的标准的测试报告格式,可以用来与持续集成工具等很多工具集成:

$ pytest -s -q --junitxml=./report.xml tests/
.FFs.xxFFFteardown smtp

=================================== FAILURES ===================================
__________ TestInBookingList.test_in_booking_list[验证获取可预订列表--域名非预期结果] __________

self = <test_in_booking_list.TestInBookingList object at 0x7ff6c9172dc0>
env = {'host': {'guoyu': 'http://cn.ejee.site'}}, case = '验证获取可预订列表--域名非预期结果'
http = {'headers': {'Content-Type': 'application/x-www-form-urlencoded'}, 'method': 'POST', 'params': {'channel': 'GuoYu', 'c...: 'jie', 'sign': '45b4cd33de5106aa9241108268fbb97a', 'type': 'PreRelease'}, 'path': '/api/booking/queryReserveList.do'}
expected = {'response': {'data': {'channel': 'GuoYu', 'domain': 'testdomainadd0001.com', 'type': 'PreRelease'}}}

    @pytest.mark.parametrize("case,http,expected", list(list_params), ids=cases)
    def test_in_booking_list(self, env, case, http, expected):
        r = requests.request(http["method"],
                             url=env["host"]["guoyu"] + http["path"],
                             headers=http["headers"],
                             params=http["params"])
        response = r.json()
        # 验证请求中的type与响应中的type一致。
        responseType = jsonpath.jsonpath(response, "$..type")[0]
        # s=list_params[0][2]['response']["data"]["type"]
        assert responseType == expected['response']["data"]["type"]
        # 验证请求中的channel与响应中的name一致。
        responseName = jsonpath.jsonpath(response, "$..name")[0]
        assert responseName == expected['response']["data"]["channel"]
        # 验证响应中的domain是'testdomainadd0001.com'。
        responseDomain = jsonpath.jsonpath(response, "$..domain")[0]
>       assert responseDomain == expected['response']["data"]["domain"], "实际的可预订域名是:{}".format(responseDomain)
E       AssertionError: 实际的可预订域名是:testdomainadd0002.com
E       assert 'testdomainadd0002.com' == 'testdomainadd0001.com'
E         - testdomainadd0001.com
E         ?                 ^
E         + testdomainadd0002.com
E         ?                 ^

tests/test_in_booking_list.py:32: AssertionError
__________ TestInBookingList.test_in_booking_list[验证获取可预订列表--类型非预期结果] __________

self = <test_in_booking_list.TestInBookingList object at 0x7ff6c91e7520>
env = {'host': {'guoyu': 'http://cn.ejee.site'}}, case = '验证获取可预订列表--类型非预期结果'
http = {'headers': {'Content-Type': 'application/x-www-form-urlencoded'}, 'method': 'POST', 'params': {'channel': 'GuoYu', 'c...: 'jie', 'sign': '45b4cd33de5106aa9241108268fbb97a', 'type': 'PreRelease'}, 'path': '/api/booking/queryReserveList.do'}
expected = {'response': {'data': {'channel': 'GuoYu', 'domain': 'testdomainadd0002.com', 'type': 'Delete'}}}

    @pytest.mark.parametrize("case,http,expected", list(list_params), ids=cases)
    def test_in_booking_list(self, env, case, http, expected):
        r = requests.request(http["method"],
                             url=env["host"]["guoyu"] + http["path"],
                             headers=http["headers"],
                             params=http["params"])
        response = r.json()
        # 验证请求中的type与响应中的type一致。
        responseType = jsonpath.jsonpath(response, "$..type")[0]
        # s=list_params[0][2]['response']["data"]["type"]
>       assert responseType == expected['response']["data"]["type"]
E       AssertionError: assert 'PreRelease' == 'Delete'
E         - Delete
E         + PreRelease

tests/test_in_booking_list.py:26: AssertionError
_____________________________ TestMarks.test_slow ______________________________

self = <test_marks.TestMarks object at 0x7ff6c92d9790>

    @pytest.mark.slow
    def test_slow(self):
        """
        自定义标签
        """
>       assert 0
E       assert 0

tests/test_marks.py:44: AssertionError
______________________________ TestMarks.test_ku _______________________________

self = <test_marks.TestMarks object at 0x7ff6c92d9fd0>

    @pytest.mark.ku
    def test_ku(self):
        """
        自定义标签
        """
>       assert 0
E       assert 0

tests/test_marks.py:51: AssertionError
__________________________________ test_ehlo ___________________________________

smtp_connection = <smtplib.SMTP_SSL object at 0x7ff6c9272ca0>

    def test_ehlo(smtp_connection):
        response, msg = smtp_connection.ehlo()
        assert response == 250
>       assert 0
E       assert 0

tests/test_smtpsimple.py:18: AssertionError
=============================== warnings summary ===============================
/Users/leiyuxing/pyse/lib/python3.8/site-packages/_pytest/junitxml.py:446
  /Users/leiyuxing/pyse/lib/python3.8/site-packages/_pytest/junitxml.py:446: PytestDeprecationWarning: The 'junit_family' default value will change to 'xunit2' in pytest 6.0. See:
    https://docs.pytest.org/en/stable/deprecations.html#junit-family-default-value-change-to-xunit2
  for more information.
    _issue_warning_captured(deprecated.JUNIT_XML_DEFAULT_FAMILY, config.hook, 2)

-- Docs: https://docs.pytest.org/en/stable/warnings.html
------------ generated xml file: /Users/leiyuxing/pytest/report.xml ------------
=========================== short test summary info ============================
FAILED tests/test_in_booking_list.py::TestInBookingList::test_in_booking_list[验证获取可预订列表--域名非预期结果]
FAILED tests/test_in_booking_list.py::TestInBookingList::test_in_booking_list[验证获取可预订列表--类型非预期结果]
FAILED tests/test_marks.py::TestMarks::test_slow - assert 0
FAILED tests/test_marks.py::TestMarks::test_ku - assert 0
FAILED tests/test_smtpsimple.py::test_ehlo - assert 0
5 failed, 2 passed, 1 skipped, 2 xfailed, 1 warning in 2.08s

生成的测试报告xml文件可用excel打开:

12、总结

本文章以实际项目出发,介绍了如何编写测试用例、如何参数化、如何进行测试配置管理、如何进行测试的准备和清理,如何进行并发测试并生成报告。至此我的自动化测试项目完整目录结构如下:

$ tree
.
├── config
│   └── test
│       └── config.yaml
├── data
│   └── test_in_booking_list.yaml
├── pytest.ini
├── report.xml
├── tests
│   ├── __pycache__
│   │   ├── conftest.cpython-37-pytest-6.0.1.pyc
│   │   ├── conftest.cpython-38-pytest-6.0.1.pyc
│   │   ├── test_in_booking_list.cpython-37-pytest-6.0.1.pyc
│   │   ├── test_in_booking_list.cpython-38-pytest-6.0.1.pyc
│   │   ├── test_marks.cpython-38-pytest-6.0.1.pyc
│   │   └── test_smtpsimple.cpython-38-pytest-6.0.1.pyc
│   ├── conftest.py
│   ├── test_in_booking_list.py
│   ├── test_marks.py
│   └── test_smtpsimple.py
└── utils
    ├── __pycache__
    │   ├── commontools.cpython-37.pyc
    │   └── commontools.cpython-38.pyc
    └── commontools.py

7 directories, 17 files

参考资料:https://testerhome.com/topics/23441

猜你喜欢

转载自blog.csdn.net/LYX_WIN/article/details/108148548