其他测试框架 基于 Pytest 框架的自动化测试开发实践 (万字长文入门篇)

random 测试交流27166字数 22413阅读模式

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

在开始本文之前,我想跟大家澄清两个概念,一个是测试框架一个是测试工具。很多人容易把他们搞混了,测试框架是诸如 Unittest、Pytest、TestNG 这类,而测试工具指的则是 Selenium、Appium、Jmeter 这类。文章源自玩技e族-https://www.playezu.com/240340.html

测试框架的作用是,帮助我们管理测试用例、执行测试用例、参数化、断言、生成测试报告等基础性工作,让我们将精力用在测试用例的编写上。好的测试框架应该具有很高的扩展性,支持二次开发,并能够支持多种类型的自动化测试。文章源自玩技e族-https://www.playezu.com/240340.html

测试工具的作用是为了完成某一类型的测试,比如 Selenium 用于对 WEB UI 进行自动化测试,Appium 用来对 APP 进行自动化测试,Jmeter 可以用来进行 API 自动化测试和性能测试。另外,Java 语言中 OkHttp 库,Python 语言中的 requests 库,这些 HTTP 的 client 也可以看做是一种 API 测试工具。文章源自玩技e族-https://www.playezu.com/240340.html

澄清了这两个概念,说一下本文的目的。其实网上已经有很多教程,包括官方文档,都是以介绍 Pytest 的功能为出发点,罗列了各种功能的使用方法,大家看完之后会感觉都明白了,但是还是不知道如何与实际项目相结合,真正落地用起来。本文不以介绍 Pytest 工具本身为目的,而是以一个实际的 API 测试项目为例,通过单元测试框架 Pytest 和 Python 的 Requests 库相结合,将 Pytest 功能应用到实际的测试工程实践中,教大家将 Pytest 用起来。文章源自玩技e族-https://www.playezu.com/240340.html

请相信我,使用 Pytest 会让你的测试工作非常高效。文章源自玩技e族-https://www.playezu.com/240340.html

01 — Pytest 核心功能

在开始使用 Pytest 之前,先来了解一下 Pytest 的核心功能,根据官方网站介绍,它具有如下功能和特点:文章源自玩技e族-https://www.playezu.com/240340.html

  1. 非常容易上手,入门简单,文档丰富,文档中有很多实例可以参考。
  2. 能够支持简单的单元测试和复杂的功能测试。
  3. 支持参数化。
  4. 能够执行全部测试用例,也可以挑选部分测试用例执行,并能重复执行失败的用例。
  5. 支持并发执行,还能运行由 nose, unittest 编写的测试用例。
  6. 方便、简单的断言方式。
  7. 能够生成标准的 Junit XML 格式的测试结果。
  8. 具有很多第三方插件,并且可以自定义扩展。
  9. 方便的和持续集成工具集成。

Pytest 的安装方法与安装其他的 python 软件无异,直接使用 pip 安装即可。文章源自玩技e族-https://www.playezu.com/240340.html

$ pip install -U pytest

安装完成后,可以通过下面方式验证是否安装成功:文章源自玩技e族-https://www.playezu.com/240340.html

$ py.test --help

如果能够输出帮助信息,则表示安装成功了。文章源自玩技e族-https://www.playezu.com/240340.html

接下来,通过开发一个 API 自动化测试项目,详细介绍以上这些功能是如何使用的。文章源自玩技e族-https://www.playezu.com/240340.html

02 — 创建测试项目

先创建一个测试项目目录 api_pytest,为这个项目创建虚拟环境。关于虚拟环境的创建,可以参考这篇文章《利用 pyenv 和 pipenv 管理多个相互独立的 Python 虚拟开发环境》。这里我们直接介绍如何使用,执行下面两条命令:

$ mkdir api_pytest
$ pipenv --python 3.7.7

这样,项目目录和虚拟环境就创建完成了。

接着,安装依赖包,第一个是要安装 pytest,另外本文是以 API 自动化测试为例,因此还要安装一下 HTTP 的 client 包 requests。

$ pipenv install pytest requests

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

$ mkdir data
$ mkdir tests
$ mkdir config
$ mkdir utils

现在,项目的目录结构应该是如下这样:

$ tree
.
├── Pipfile
├── Pipfile.lock
├── config
├── data
├── tests
└── utils
​
4 directories, 2 files

至此测试项目就创建完成了。接着编写测试用例。

03 — 编写测试用例

在这部分,我们以测试豆瓣电影列表 API 和电影详情 API 为例,编写测试用例。

这两个 API 信息如下:
| 接口 | 示例 |
| -- |--|
| 电影列表 | http://api.douban.com/v2/movie/in_theaters?apikey=0df993c66c0c636e29ecbb5344252a4a&start=0&count=10|
| 电影详情 |https://api.douban.com/v2/movie/subject/30261964?apikey=0df993c66c0c636e29ecbb5344252a4a |

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

  1. 验证请求中的 start 与响应中的 start 一致。
  2. 验证请求中的 count 与响应中的 count 一致。
  3. 验证响应中的 title 是"正在上映的电影 - 上海"。

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

import requests


class TestInTheaters(object):
def test_in_theaters(self):
host = "http://api.douban.com"
path = "/v2/movie/in_theaters"
params = {"apikey": "0df993c66c0c636e29ecbb5344252a4a",
"start": 0,
"count": 10
}
headers = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.149 Safari/537.36"
}
r = requests.request("GET", url=host + path, headers=headers, params=params)
response = r.json()
assert response["count"] == params["count"]
assert response["start"] == params["start"]
assert response["title"] == "正在上映的电影-上海", "实际的标题是:{}".format(response["title"])

你可能会问,这就是测试用例了?这就是基于 Pytest 的测试用例了吗?答案是肯定的。基于 Pytest 编写自动化测试用例,与编写平常的 Python 代码没有任何区别,唯一的区别在于文件名、函数名或者方法名要以 test_开头或者_test 结尾,类名以 Test 开头。

Pytest 会在 test_*.py 或者 *test.py 文件中,寻找 class 外边的 test开头的函数,或者 Test 开头的 class 里面的 test_开头的方法,将这些函数和方法作为测试用例来管理。可以通过下面的命令,查看 Pytest 收集到哪些测试用例:

$py.test--collect-only======================================================testsessionstarts=======================================================platformdarwin--Python3.7.7,pytest-5.4.1,py-1.8.1,pluggy-0.13.1rootdir:/Users/chunming.liu/learn/api_pytestcollected1item<Moduletests/test_in_theaters.py><ClassTestInTheaters><Functiontest_in_theaters>=====================================================notestsranin0.10s======================================================

从结果中看到,一共有一条测试用例,测试用例位于 tests/test_in_theaters.py 这个 module 里面 TestInTheaters 这个类中的 test_in_theaters 这个方法。

在 Pytest 中断言使用的是 Python 自带的 assert 语句,非常简单。

04 — 执行测试用例

下面来运行这个测试:

$py.testtests/======================================================testsessionstarts=======================================================platformdarwin--Python3.7.7,pytest-5.4.1,py-1.8.1,pluggy-0.13.1rootdir:/Users/chunming.liu/learn/api_pytestcollected1itemtests/test_in_theaters.py.[100%]=======================================================1passedin0.61s========================================================(api_pytest)MBC02X21W4G8WN:api_pytestchunming.liu$py.testtests/======================================================testsessionstarts=======================================================platformdarwin--Python3.7.7,pytest-5.4.1,py-1.8.1,pluggy-0.13.1rootdir:/Users/chunming.liu/learn/api_pytestcollected1itemtests/test_in_theaters.pyF[100%]============================================================FAILURES============================================================________________________________________________TestInTheaters.test_in_theaters_________________________________________________self=<test_in_theaters.TestInTheatersobjectat0x110eee9d0>deftest_in_theaters(self):host="http://api.douban.com"path="/v2/movie/in_theaters"params={"apikey":"0df993c66c0c636e29ecbb5344252a4a","start":0,"count":10}headers={"User-Agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.149 Safari/537.36"}r=requests.request("GET",url=host+path,headers=headers,params=params)response=r.json()assertresponse["count"]==params["count"]assertresponse["start"]==params["start"]assertresponse["total"]==len(response["subjects"])>assertresponse["title"]=="正在上映的电影-上海","实际的标题是:{}".format(response["title"])EAssertionError:实际的标题是:正在上映的电影-北京Eassert'正在上映的电影-北京'=='正在上映的电影-上海'E-正在上映的电影-上海E?E+正在上映的电影-北京E?tests/test_in_theaters.py:20:AssertionError====================================================shorttestsummaryinfo=====================================================FAILEDtests/test_in_theaters.py::TestInTheaters::test_in_theaters-AssertionError:实际的标题是正在上映的电影-北京=======================================================1failedin0.96s========================================================

这个命令执行时,会在 tests/目录里面寻找测试用例。执行测试的时候,如果不指定测试用例所在目录,Pytest 会在当前的目录下,按照前面介绍的规则寻找测试用例并执行。

通过上面的测试输出,我们可以看到该测试过程中,一共收集到了一个测试用例,测试结果是失败的(标记为 F),并且在 FAILURES 部分输出了详细的错误信息,通过这些信息,我们可以分析测试失败的原因。上面测试用例的失败原因是在断言 title 的时候出错了,预期的 title 是 “正在上映的电影 - 上海”,但是实际是 “正在上映的电影 - 北京”,预期和实际的对比非常直观。

执行测试用例的方法还有很多种,都是在 py.test 后面添加不同的参数即可,我在下面罗列了一下:

$ py.test               # run all tests below current dir
$ py.test test_module.py   # run tests in module
$ py.test somepath      # run all tests below somepath
$ py.test -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
$ py.test 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

上面这些用法,通过注释很容易理解。在测试执行过程中,这些方法都有机会被用到,最好掌握一下。

05

数据与脚本分离

03 小节的测试用例,将测试数据和测试代码放到了同一个 py 文件中,而且是同一个测试方法中,产生了紧耦合,会导致修改测试数据或测试代码时,可能会相互影响,不利于测试数据和测试脚本的维护。比如,为测试用例添加几组新的测试数据,除了准备测试数据外,还要修改测试代码,降低了测试代码的可维护性。

另外接口测试往往是数据驱动的测试,测试数据和测试代码放到一起也不方便借助 Pytest 做参数化。

将测试代码和测试数据分离已经是测试领域中的共识了。在 data/目录下创建一个用于存放测试数据的 Yaml 文件 test_in_theaters.yaml,内容如下:

---
tests:
- case: 验证响应中start和count与请求中的参数一致
http:
method: GET
path: /v2/movie/in_theaters
headers:
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.149 Safari/537.36
params:
apikey: 0df993c66c0c636e29ecbb5344252a4a
start: 0
count: 10
expected:
response:
title: 正在上映的电影-上海
count: 10
start: 0

熟悉 Yaml 格式的同学,应该很容易看懂上面测试数据文件的内容。这个测试数据文件中,有一个数组 tests,里面包含的是一条完整的测试数据。一个完整的测试数据由三部分组成:

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

http 这个请求对象包含了被测接口的所有参数,包括请求方法、请求路径、请求头、请求参数。
expected 表示预期结果,上面的测试数据中,只列出了对请求响应的预期值,实际测试中,还可以列出对数据库的预期值。

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

import requests
import yaml


def get_test_data(test_data_path):
case = []  # 存储测试用例名称
    http = []  # 存储请求对象
    expected = []  # 存储预期结果
    with open(test_data_path) 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/chunming.liu/learn/api_pytest/data/test_in_theaters.yaml")
list_params=list(parameters)

class TestInTheaters(object):
def test_in_theaters(self):
host = "http://api.douban.com"
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()
assert response["count"] == list_params[0][2]['response']["count"]
assert response["start"] == list_params[0][2]['response']["start"]
assert response["total"] == len(response["subjects"])
assert response["title"] == list_params[0][2]['response']["title"], "实际的标题是:{}".format(response["title"])

注意,读取 Yaml 文件,需要安装 PyYAML 包。

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

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

通常情况下,读取测试数据的函数不会定义在测试用例文件中,而是会放到 utils 包中,比如放到 utils/commonlib.py 中。至此,整个项目的目录结构应该是如下所示:

$ tree
.
├── Pipfile
├── Pipfile.lock
├── config
├── data
│   └── test_in_theaters.yaml
├── tests
│   └── test_in_theaters.py
└── utils
└── commlib.py

这样,我们修改测试脚本,就修改 test_in_theaters.py,变更测试数据,就修改 test_in_theaters.yaml。但是目前看,感觉好像并没有真正看到测试数据和脚本分离的厉害之处,或者更加有价值的地方,那么我们接着往下看。

06 — 参数化

上面我们将测试数据和测试脚本相分离,如果要为测试用例添加更多的测试数据,往 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)),
]


@user1ize("a,b,expected", testdata)
def test_timedistance_v0(a, b, expected):
diff = a - b
assert diff == expected

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

=============================testsessionstarts==============================platformdarwin--Python3.7.7,pytest-5.4.1,py-1.8.1,pluggy-0.13.1--/Users/chunming.liu/.local/share/virtualenvs/api_pytest-wCozfXSU/bin/pythoncachedir:.pytest_cacherootdir:/Users/chunming.liu/learn/api_pytest/testscollecting...collected2itemstest_time.py::test_timedistance_v0[a0-b0-expected0]PASSED[50%]test_time.py::test_timedistance_v0[a1-b1-expected1]PASSED[100%]==============================2passedin0.02s===============================

照猫画虎,对我们自己的测试项目中的测试脚本进行如下修改。

import pytest
import requests

from utils.commlib import get_test_data

cases, list_params = get_test_data("/Users/chunming.liu/learn/api_pytest/data/test_in_theaters.yaml")


class TestInTheaters(object):
@user2ize("case,http,expected", list(list_params), ids=cases)
def test_in_theaters(self, case, http, expected):
host = "http://api.douban.com"
r = requests.request(http["method"],
url=host + http["path"],
headers=http["headers"],
params=http["params"])
response = r.json()
assert response["count"] == expected['response']["count"]
assert response["start"] == expected['response']["start"]
assert response["title"] == expected['response']["title"], "实际的标题是:{}".format(response["title"])

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

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

---
tests:
- case: 验证响应中start和count与请求中的参数一致
http:
method: GET
path: /v2/movie/in_theaters
headers:
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.149 Safari/537.36
params:
apikey: 0df993c66c0c636e29ecbb5344252a4a
start: 0
count: 10
expected:
response:
title: 正在上映的电影-上海
count: 10
start: 0
- case: 验证响应中title是"正在上映的电影-北京"
http:
method: GET
path: /v2/movie/in_theaters
headers:
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.149 Safari/537.36
params:
apikey: 0df993c66c0c636e29ecbb5344252a4a
start: 1
count: 5
expected:
response:
title: 正在上映的电影-北京
count: 5
start: 1

现在我们执行一下测试脚本,看看效果:

$exportPYTHONPATH=/Users/chunming.liu/learn/api_pytest$py.testtests/test_in_theaters.py======================================================testsessionstarts=======================================================platformdarwin--Python3.7.7,pytest-5.4.1,py-1.8.1,pluggy-0.13.1rootdir:/Users/chunming.liu/learn/api_pytest,inifile:pytest.inicollected2itemstests/test_in_theaters.pyF.[100%]============================================================FAILURES============================================================___________________________________TestInTheaters.test_in_theaters[验证响应中startcount与请求中的参数一致]___________________________________self=<test_in_theaters.TestInTheatersobjectat0x102659510>,case='验证响应中start和count与请求中的参数一致'http={'headers':{'User-Agent':'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_5) AppleWebKit/537.36 (KHTML, like Gecko) Chr...T','params':{'apikey':'0df993c66c0c636e29ecbb5344252a4a','count':10,'start':0},'path':'/v2/movie/in_theaters'}expected={'response':{'count':10,'start':0,'title':'正在上映的电影-上海'}}@user4ize("case,http,expected",list(list_params),ids=cases)deftest_in_theaters(self,case,http,expected):host="http://api.douban.com"r=requests.request(http["method"],url=host+http["path"],headers=http["headers"],params=http["params"])response=r.json()assertresponse["count"]==expected['response']["count"]assertresponse["start"]==expected['response']["start"]>assertresponse["title"]==expected['response']["title"],"实际的标题是:{}".format(response["title"])EAssertionError:实际的标题是:正在上映的电影-北京Eassert'正在上映的电影-北京'=='正在上映的电影-上海'E-正在上映的电影-上海E?E+正在上映的电影-北京E?tests/test_in_theaters.py:20:AssertionError====================================================shorttestsummaryinfo=====================================================FAILEDtests/test_in_theaters.py::TestInTheaters::test_in_theaters[u9a8cu8bc1u54cdu5e94u4e2dstartu548ccountu4e0eu8bf7u6c42u4e2du7684u53c2u6570u4e00u81f4]==================================================1failed,1passedin0.69s===================================================

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

[pytest]
disable_test_id_escaping_and_forfeit_all_rights_to_community_support = True

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

FAILEDtests/test_in_theaters.py::TestInTheaters::test_in_theaters[验证响应中startcount与请求中的参数一致]-AssertionError:...

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

现在,自动化测试项目的目录结构应该是如下这样:

$tree.├──Pipfile├──Pipfile.lock├──config├──data└──test_in_theaters.yaml├──pytest.ini├──tests├──test_in_theaters.py└──test_time.py└──utils└──commlib.py4directories,7files

07 — 测试配置管理

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

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

├── config
│   ├── prod
│   │   └── config.yaml
│   └── test
│       └── config.yaml

在 config.yaml 中存放不同环境的配置信息,以前面的例子为例,应该是这样:

host:douban:http://api.douban.com

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

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:

@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/chunming.liu/learn/api_pytest/config/test/config.yaml

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

class TestInTheaters(object):
@user8ize("case,http,expected", list(list_params), ids=cases)
def test_in_theaters(self, env, case, http, expected):
r = requests.request(http["method"],
url=env["host"]["douban"] + http["path"],
headers=http["headers"],
params=http["params"])
response = r.json()

这样就达到了测试配置文件与测试脚本相互分离的效果,如果需要修改 host,只需要修改配置文件即可,测试脚本文件就不用修改了。修改完成后执行测试的方法不变。

上面的 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 里面的参数。

08 — 测试的准备与收尾

很多时候,我们需要在测试用例执行前做数据库连接的准备,做测试数据的准备,测试执行后断开数据库连接,清理测试脏数据这些工作。通过 07 小节大家对于通过 env 这个 fixture 函数,如何在测试开始前的开展准备工作有所了解,本小节将介绍更多内容。

@pytest.fixture函数的 scope 可能的取值有 function,class,module,package 或 session。他们的具体含义如下:

  1. function,表示 fixture 函数在测试方法执行前和执行后执行一次。
  2. class,表示 fixture 函数在测试类执行前和执行后执行一次。
  3. module,表示 fixture 函数在测试脚本执行前和执行后执行一次。
  4. package,表示 fixture 函数在测试包(文件夹)中第一个测试用例执行前和最后一个测试用例执行后执行一次。
  5. session,表示所有测试的最开始和测试结束后执行一次。

通常,数据库连接和断开、测试配置文件的读取等工作,是需要放到 session 级别的 fixture 函数中,因为这些操作针对整个测试活动只需要做一次。而针对测试数据的准备,通常是 function 级别或者 class 级别的,因为测试数据针对不同的测试方法或者测试类往往都不相同。

在 TestInTheaters 测试类中,模拟一个准备和清理测试数据的 fixture 函数 preparation,scope 设置为 function:

@pytest.fixture(scope="function")
def preparation(self):
print("在数据库中准备测试数据")
test_data = "在数据库中准备测试数据"
yield test_data
print("清理测试数据")

在测试方法中,将 preparation 作为参数,通过下面的命令执行测试:

$pipenvpy.test-s-q--tb=notests/test_in_theaters.py======================================================testsessionstarts=======================================================platformdarwin--Python3.7.7,pytest-5.4.1,py-1.8.1,pluggy-0.13.1rootdir:/Users/chunming.liu/learn/api_pytest,inifile:pytest.inicollected2itemstests/test_in_theaters.py在数据库中准备测试数据F清理测试数据在数据库中准备测试数据.清理测试数据====================================================shorttestsummaryinfo=====================================================FAILEDtests/test_in_theaters.py::TestInTheaters::test_in_theaters[验证响应中startcount与请求中的参数一致]-AssertionError:...==================================================1failed,1passedin0.81s===================================================

通过输出可以看到在每一条测试用例执行前后,各执行了一次 “在数据库中准备测试数据” 和 “清理测试数据”。如果 scope 的值改为 class,执行测试用例的输出信息将是下面这样:

tests/test_in_theaters.py 在数据库中准备测试数据
F.清理测试数据
在测试类执行前后各执行一次 “在数据库中准备测试数据” 和 “清理测试数据”。

09 — 标记与分组

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

import sys

import pytest


class TestMarks(object):
@pytest.mark.skip(reason="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版本不执行这条测试用例
:return:
"""
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

下面来运行这个测试:

$py.test-s-q--tb=notests/test_marks.py======================================================testsessionstarts=======================================================platformdarwin--Python3.7.7,pytest-5.4.1,py-1.8.1,pluggy-0.13.1rootdir:/Users/chunming.liu/learn/api_pytest,inifile:pytest.inicollected4itemstests/test_marks.pys.xx============================================1passed,1skipped,2xfailedin0.06s=============================================

从结果中可以看到,第一条测试用例 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:markstestsasslow(deselectwith'-m "not slow"')

10 — 并发执行

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

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

$py.test-s-q--tb=no-nautotests/======================================================testsessionstarts=======================================================platformdarwin--Python3.7.7,pytest-5.4.1,py-1.8.1,pluggy-0.13.1rootdir:/Users/chunming.liu/learn/api_pytest,inifile:pytest.iniplugins:xdist-1.31.0,forked-1.1.3gw0[10]/gw1[10]/gw2[10]/gw3[10]/gw4[10]/gw5[10]/gw6[10]/gw7[10]s.FxxF..F.====================================================shorttestsummaryinfo=====================================================FAILEDtests/test_marks.py::TestMarks::test_slow-assert0FAILEDtests/test_smtpsimple.py::test_ehlo-assert0FAILEDtests/test_in_theaters.py::TestInTheaters::test_in_theaters[验证响应中startcount与请求中的参数一致]-AssertionError:...=======================================3failed,4passed,1skipped,2xfailedin1.91s========================================

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

这里提一下,pytest 生态中,有很多第三方插件很好用,更多的插件可以在这里https://pypi.org/search/?q=pytest-查看和搜索,当然我们也可以开发自己的插件。

11 — 测试报告

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

$py.test-s-q--junitxml=./report.xmltests/

现在应用更加广泛的测试报告是 Allure,可以方便的与 Pytest 集成,大家可以参考我的另外一篇公众号文章《用 Pytest+Allure 生成漂亮的 HTML 图形化测试报告》。

12 — 总结

本文章以实际项目出发,介绍了如何编写测试用例、如何参数化、如何进行测试配置管理、如何进行测试的准备和清理,如何进行并发测试并生成报告。根据本文的介绍,你能够逐步搭建起一套完整的测试项目。

本文并没有对 Pytest 的细节和比较高阶的内容做充分介绍,以后再进行专题介绍,这篇文章主要目的是让大家能够将 Pytest 用起来。更高阶的内容,公众号后续文章还将继续对其进行介绍。至此,我们的自动化测试项目完整目录结构如下:

$tree.├──Pipfile├──Pipfile.lock├──config├──prod└──config.yaml└──test└──config.yaml├──data└──test_in_theaters.yaml├──pytest.ini├──tests├──conftest.py├──test_in_theaters.py├──test_marks.py├──test_smtpsimple.py└──test_time.py└──utils└──commlib.py6directories,12files

参考资料

[1] https://docs.pytest.org/en/latest/

[2] https://www.guru99.com/pytest-tutorial.html

玩技站长微信
添加好友自动发送入群邀请
weinxin
rainbow-shownow
玩技官方公众号
官方微信公众号
weinxin
PLAYEZU
 
  • 版权提示:本站仅供存储任何法律责任由作者承担▷诈骗举报◁▷新闻不符◁▷我要投稿◁
    风险通知:非原创文章均为网络投稿真实性无法判断,侵权联系2523030730
    免责声明:内容来自用户上传发布或新闻客户端自媒体,切勿!切勿!切勿!添加联系方式以免受骗。
  • 原创转载:https://www.playezu.com/240340.html
    转载说明: 点我前往阅读>>>
评论  27  访客  27
    • xiaoxiangyu0927
      xiaoxiangyu0927 9

      直接修改为 @pytest.mark.parametrize 就没有问题了

      • xiaoxiangyu0927
        xiaoxiangyu0927 9

        user1ize、user2ize 的具体实现方法是什么呢?文章中没有看到

        • 为了美好的老年生活努力
          为了美好的老年生活努力 9

          pytest+yaml 设计接口自动化框架过程记录 https://gitee.com/your_dad_died/pytest_api_yaml

          • 为了美好的老年生活努力
            为了美好的老年生活努力 9

            https://gitee.com/your_dad_died/pytest_api_yaml 这个可以测试多个文件接口

            • cherry_blossom
              cherry_blossom 9

              请问:如果是很多个测试接口,如何一一与 yaml 中的数据对应呢

              • Jay_
                Jay_ 9

                写的真垃圾。

                • 一六二二四
                  一六二二四 9

                  咨询个问题 大佬 ,pytest 如何 参数传递?比如 test_1 的 response 的出参作为 test_2 的入参这种要怎么写?

                匿名

                发表评论

                匿名网友
                确定