浅谈基于Pytest框架的自动化测试开发实践

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

在开始本文之前,我想跟大家澄清两个概念,一个是测试框架一个是测试工具。很多人容易把他们搞混了,测试框架是诸如Unittest、Pytest、TestNG这类,而测试工具指的则是Selenium、Appium、Jmeter这类。

测试框架的作用是,帮助我们管理测试用例、执行测试用例、参数化、断言、生成测试报告等基础性工作,让我们将精力用在测试用例的编写上。好的测试框架应该具有很高的扩展性,支持二次开发,并能够支持多种类型的自动化测试。

测试工具的作用是为了完成某一类型的测试,比如Selenium用于对WEB UI进行自动化测试,Appium用来对APP进行自动化测试,Jmeter可以用来进行API自动化测试和性能测试。另外,Java语言中OkHttp库,Python语言中的requests库,这些HTTP的client也可以看做是一种API测试工具。

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

请相信我,使用Pytest会让你的测试工作非常高效。

01 — Pytest核心功能

在开始使用Pytest之前,先来了解一下Pytest的核心功能,根据官方网站介绍,它具有如下功能和特点:

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

Pytest的安装方法与安装其他的python软件无异,直接使用pip安装即可。

1

$ pip install -U pytest

安装完成后,可以通过下面方式验证是否安装成功:

1

$ py.test --help

如果能够输出帮助信息,则表示安装成功了。

接下来,通过开发一个API自动化测试项目,详细介绍以上这些功能是如何使用的。

02 — 创建测试项目

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

1

2

$ mkdir api_pytest

$ pipenv --python 3.7.7

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

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

1

$ pipenv install pytest requests

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

1

2

3

4

$ mkdir data

$ mkdir tests

$ mkdir config

$ mkdir utils

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

1

2

3

4

5

6

7

8

9

10

$ 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个校验点:

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

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

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

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收集到哪些测试用例:

1

2

3

4

5

6

7

8

9

10

$ py.test --collect-only

====================================================== test session starts =======================================================

platform darwin -- Python 3.7.7, pytest-5.4.1, py-1.8.1, pluggy-0.13.1

rootdir: /Users/chunming.liu/learn/api_pytest

collected 1 item                                                                                                                

<Module tests/test_in_theaters.py>

  <Class TestInTheaters>

      <Function test_in_theaters>

===================================================== no tests ran in 0.10s ======================================================

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

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

04 — 执行测试用例

下面来运行这个测试:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

43

44

45

46

47

48

49

50

$ py.test tests/

====================================================== test session starts =======================================================

platform darwin -- Python 3.7.7, pytest-5.4.1, py-1.8.1, pluggy-0.13.1

rootdir: /Users/chunming.liu/learn/api_pytest

collected 1 item

tests/test_in_theaters.py .                                                                                                [100%]

======================================================= 1 passed in 0.61s ========================================================

(api_pytest) MBC02X21W4G8WN:api_pytest chunming.liu$ py.test tests/

====================================================== test session starts =======================================================

platform darwin -- Python 3.7.7, pytest-5.4.1, py-1.8.1, pluggy-0.13.1

rootdir: /Users/chunming.liu/learn/api_pytest

collected 1 item

tests/test_in_theaters.py F                                                                                                [100%]

============================================================ FAILURES ============================================================

________________________________________________ TestInTheaters.test_in_theaters _________________________________________________

self = <test_in_theaters.TestInTheaters object at 0x110eee9d0>

    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["total"] == len(response["subjects"])

>       assert response["title"] == "正在上映的电影-上海", "实际的标题是:{}".format(response["title"])

E       AssertionError: 实际的标题是:正在上映的电影-北京

E       assert '正在上映的电影-北京' == '正在上映的电影-上海'

E         - 正在上映的电影-上海

E         ?         ^^

E         + 正在上映的电影-北京

E         ?         ^^

tests/test_in_theaters.py:20: AssertionError

==================================================== short test summary info =====================================================

FAILED tests/test_in_theaters.py::TestInTheaters::test_in_theaters - AssertionError: 实际的标题是正在上映的电影-北京

======================================================= 1 failed in 0.96s ========================================================

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

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

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

1

2

3

4

5

6

7

8

9

10

$ 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,内容如下:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

---

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,里面包含的是一条完整的测试数据。一个完整的测试数据由三部分组成:

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

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

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

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

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中。至此,整个项目的目录结构应该是如下所示:

1

2

3

4

5

6

7

8

9

10

11

$ 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用法的例子:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

# 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列表中的第二个元组。这就是参数化的效果,同一个脚本可以使用不同的输入参数执行测试。

1

2

3

4

5

6

7

8

9

10

============================= test session starts ==============================

platform darwin -- Python 3.7.7, pytest-5.4.1, py-1.8.1, pluggy-0.13.1 -- /Users/chunming.liu/.local/share/virtualenvs/api_pytest-wCozfXSU/bin/python

cachedir: .pytest_cache

rootdir: /Users/chunming.liu/learn/api_pytest/tests

collecting ... collected 2 items

test_time.py::test_timedistance_v0[a0-b0-expected0] PASSED    [ 50%]

test_time.py::test_timedistance_v0[a1-b1-expected1] PASSED    [100%]

============================== 2 passed in 0.02s ===============================

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

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

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

    @pytest.mark.parametrize("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,这个值作为测试用例的名称将打印到测试结果中。

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

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

---

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

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

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

$ export PYTHONPATH=/Users/chunming.liu/learn/api_pytest

$ py.test tests/test_in_theaters.py

====================================================== test session starts =======================================================

platform darwin -- Python 3.7.7, pytest-5.4.1, py-1.8.1, pluggy-0.13.1

rootdir: /Users/chunming.liu/learn/api_pytest, inifile: pytest.ini

collected 2 items                                                                                                               

tests/test_in_theaters.py F.                                                                                               [100%]

============================================================ FAILURES ============================================================

___________________________________ TestInTheaters.test_in_theaters[验证响应中start和count与请求中的参数一致] ___________________________________

self = <test_in_theaters.TestInTheaters object at 0x102659510>, 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': '正在上映的电影-上海'}}

    @pytest.mark.parametrize("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"])

E       AssertionError: 实际的标题是:正在上映的电影-北京

E       assert '正在上映的电影-北京' == '正在上映的电影-上海'

E         - 正在上映的电影-上海

E         ?         ^^

E         + 正在上映的电影-北京

E         ?         ^^

tests/test_in_theaters.py:20: AssertionError

==================================================== short test summary info =====================================================

FAILED tests/test_in_theaters.py::TestInTheaters::test_in_theaters[\u9a8c\u8bc1\u54cd\u5e94\u4e2dstart\u548ccount\u4e0e\u8bf7\u6c42\u4e2d\u7684\u53c2\u6570\u4e00\u81f4]

================================================== 1 failed, 1 passed in 0.69s ===================================================

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

1

2

[pytest]

disable_test_id_escaping_and_forfeit_all_rights_to_community_support = True

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

FAILED tests/test_in_theaters.py::TestInTheaters::test_in_theaters[验证响应中start和count与请求中的参数一致] - AssertionError: ...

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

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

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

$ tree

.

├── Pipfile

├── Pipfile.lock

├── config

├── data

│   └── test_in_theaters.yaml

├── pytest.ini

├── tests

│   ├── test_in_theaters.py

│   └── test_time.py

└── utils

    └── commlib.py

4 directories, 7 files

07 — 测试配置管理

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

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

1

2

3

4

5

├── config

│   ├── prod

│   │   └── config.yaml

│   └── test

│       └── config.yaml

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

1

2

host:

  douban: http://api.douban.com

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

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

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:

1

2

3

4

5

6

7

8

9

@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”]:

1

2

3

4

5

6

7

8

class TestInTheaters(object):

    @pytest.mark.parametrize("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函数,可以接受命令行选项的参数,写法如下:

1

2

3

4

5

6

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:

1

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

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

1

2

[pytest]

addopts = --env prod

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

08 — 测试的准备与收尾

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

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

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

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

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

1

2

3

4

5

6

@pytest.fixture(scope="function")

    def preparation(self):

        print("在数据库中准备测试数据")

        test_data = "在数据库中准备测试数据"

        yield test_data

        print("清理测试数据")

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

1

2

3

4

5

6

7

8

9

10

11

12

13

$ pipenv py.test -s -q --tb=no tests/test_in_theaters.py

====================================================== test session starts =======================================================

platform darwin -- Python 3.7.7, pytest-5.4.1, py-1.8.1, pluggy-0.13.1

rootdir: /Users/chunming.liu/learn/api_pytest, inifile: pytest.ini

collected 2 items                                               

tests/test_in_theaters.py 在数据库中准备测试数据

F清理测试数据

在数据库中准备测试数据

.清理测试数据

==================================================== short test summary info =====================================================

FAILED tests/test_in_theaters.py::TestInTheaters::test_in_theaters[验证响应中start和count与请求中的参数一致] - AssertionError: ...

================================================== 1 failed, 1 passed in 0.81s ===================================================

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

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

09 — 标记与分组

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

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

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

下面来运行这个测试:

1

2

3

4

5

6

7

8

$ py.test -s -q --tb=no tests/test_marks.py

====================================================== test session starts =======================================================

platform darwin -- Python 3.7.7, pytest-5.4.1, py-1.8.1, pluggy-0.13.1

rootdir: /Users/chunming.liu/learn/api_pytest, inifile: pytest.ini

collected 4 items                                                                                                               

tests/test_marks.py s.xx

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

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

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

1

2

3

4

5

6

@pytest.mark.slow

    def test_slow(self):

        """

        自定义标签

        """

        assert 0

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

1

2

$ 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中注册一下:

1

2

3

[pytest]

markers =

    slow: marks tests as slow (deselect with '-m "not slow"')

10 — 并发执行

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

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

1

2

3

4

5

6

7

8

9

10

11

12

$ py.test -s -q --tb=no -n auto tests/

====================================================== test session starts =======================================================

platform darwin -- Python 3.7.7, pytest-5.4.1, py-1.8.1, pluggy-0.13.1

rootdir: /Users/chunming.liu/learn/api_pytest, inifile: pytest.ini

plugins: xdist-1.31.0, forked-1.1.3

gw0 [10] / gw1 [10] / gw2 [10] / gw3 [10] / gw4 [10] / gw5 [10] / gw6 [10] / gw7 [10]

s.FxxF..F.

==================================================== short test summary info =====================================================

FAILED tests/test_marks.py::TestMarks::test_slow - assert 0

FAILED tests/test_smtpsimple.py::test_ehlo - assert 0

FAILED tests/test_in_theaters.py::TestInTheaters::test_in_theaters[验证响应中start和count与请求中的参数一致] - AssertionError: ...

======================================= 3 failed, 4 passed, 1 skipped, 2 xfailed in 1.91s ========================================

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

这里提一下,pytest生态中,有很多第三方插件很好用,更多的插件可以在这里Search results · PyPI-查看和搜索,当然我们也可以开发自己的插件。

11 — 测试报告

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

1

$ py.test -s -q --junitxml=./report.xml tests/

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

12 — 总结

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

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

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

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

6 directories, 12 files

​现在我也找了很多测试的朋友,做了一个分享技术的交流群,共享了很多我们收集的技术文档和视频教程。
如果你不想再体验自学时找不到资源,没人解答问题,坚持几天便放弃的感受
可以加入我们一起交流。而且还有很多在自动化,性能,安全,测试开发等等方面有一定建树的技术大牛
分享他们的经验,还会分享很多直播讲座和技术沙龙
可以免费学习!划重点!开源的!!!
qq群号:485187702【暗号:csdn11】

最后感谢每一个认真阅读我文章的人,看着粉丝一路的上涨和关注,礼尚往来总是要有的,虽然不是什么很值钱的东西,如果你用得到的话可以直接拿走! 希望能帮助到你!【100%无套路免费领取】

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/223620.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

JFrog Artifactory—高性能软件制品管理仓库

产品概述 JFrog Artifactory是一个可扩展的通用二进制存储库管理器&#xff0c;可在整个应用程序开发和交付过程中自动管理工件和依赖项。JFrog Artifactory支持大多数开发语言&#xff0c;是整个DevOps流水线中大多数软件包、容器映像和Helm图表的单一数据源。Artifactory对元…

二叉搜索树——模拟

对于一个无穷的满二叉排序树&#xff08;如图&#xff09;&#xff0c;节点的编号是1,2,3&#xff0c;…。对于一棵树根为X的子树&#xff0c;沿着左节点一直往下到最后一层&#xff0c;可以获得该子树编号最小的节点&#xff1b;沿着右节点一直往下到最后一层&#xff0c;可以…

Java TCP协议实现一对一聊天与UDP协议实现群聊案例

JavaTCP协议实现一对一聊天与UDP协议实现群聊案例 1.TCP协议实现一对一聊天 1.1服务端运行结果 1.2客服端运行结果 1.3代码汇总 服务端 package twentyone;import java.io.IOException; import java.io.BufferedReader; import java.io.InputStreamReader; import java.io.…

2023.12.5 关于 Spring Boot 统一数据格式返回

目录 引言 统一数据格式 实例理解 特殊 String 类型处理 实例理解 分析返回的流程 补充知识 分析报错原因 解决方案一 解决方案二 最终测试 引言 统一数据格式能 方便前端程序员更好的接收和解析后端返回的数据统一数据格式能 降低约定前后端交互接口的成本&#xf…

Vue2中v-html引发的安全问题

前言&#xff1a;v-html指令 1.作用&#xff1a;向指定节点中渲染包含html结构的内容。 2.与插值语法的区别&#xff1a; (1).v-html会替换掉节点中所有的内容&#xff0c;{{xx}}则不会。 (2).v-html可以识别html结构。 3.严重注意&#xff1a;v-html有安全性问题&#xff0…

搭梯子之后电脑连接WIFI打不开浏览器网页:远程计算机或者设备不接受连接

问题描述&#xff1a; 打不开网页&#xff0c;但是能正常使用微信等app windows网络诊断&#xff1a; 远程计算机或者设备不接受连接 解决办法&#xff1a; 电脑搜索【internet选项】 进入连接&#xff0c;点击局域网设置&#xff0c;将里面的代理服务器选项关掉就可以正常打开…

总结|哪些平台有大模型知识库的Web API服务

截止2023/12/6 笔者个人的调研&#xff0c;有三家有大模型知识库的web api服务&#xff1a; 平台类型文档数量文档上传并解析的结构api情况返回页码文心一言插件版多文档有问答api&#xff0c;文档上传是通过网页进行上传有&#xff0c;而且是具体的chunk id&#xff0c;需要设…

【Java】实现顺序表基本的操作(数据结构)

文章目录 前言顺序表1、打印顺序表2、增加元素3、在任意位置增加元素4、判断是否包含某个元素5、查找某个元素对于的位置6、获取任意位置的元素7、将任意位置的元素设为value8、删除第一次出现的关键字9、获取顺序表长度10、清空顺序表总结 前言 在了解顺序表之前我们要先了解…

强化学习第1天:强化学习概述

☁️主页 Nowl &#x1f525;专栏《机器学习实战》 《机器学习》 &#x1f4d1;君子坐而论道&#xff0c;少年起而行之 ​​ 文章目录 介绍 强化学习要素 强化学习任务示例 环境搭建&#xff1a;gym 基本用法 环境信息查看 创建智能体 过程可视化 完整代码 结语…

LLM大语言模型(一):ChatGLM3-6B本地部署

目录 前言 本机环境 ChatGLM3代码库下载 模型文件下载 修改为从本地模型文件启动 启动模型网页版对话demo 超参数设置 GPU资源使用情况 &#xff08;网页对话非常流畅&#xff09; 前言 LLM大语言模型工程化&#xff0c;在本地搭建一套开源的LLM&#xff0c;方便后续的…

一致性哈希详解

目录 一. 前言 二. 一致性哈希算法 三. Redis Cluster 的一致性哈希算法 四. Java 实现的一致性哈希 五. 分库分表中一致性哈希实践 5.1. 基于 hash 环一致性哈希算法的分库分表 5.2. 美团一致性哈希算法 5.3. 平均分布方案 一. 前言 普通的 hash 算法&#xff08;hash…

Ubuntu 20.04 安装 mysql8 LTS

Ubuntu 20.04 安装 mysql8 LTS sudo apt-get update sudo apt-get install mysql-server mysql --version mysql Ver 8.0.35-0ubuntu0.20.04.1 for Linux on x86_64 ((Ubuntu)) Ubuntu20.04 是自带了 MySQL8. 几版本的&#xff0c;低于 20.04 则默认安装是 MySQL5.7.33 s…

Day03 linux高级系统编程--进程

概念 进程与程序的区别 进程&#xff1a;一个正在运行的代码就叫做进程&#xff0c;是动态的&#xff0c;会占用内存 程序&#xff1a;一段封装好的待运行的代码或可执行文件&#xff0c;是静态的&#xff0c;会占用磁盘空间 单道与多道程序 单道&#xff1a;程序一个一个…

[NAND Flash 2.1] NAND Flash 闪存改变了现代生活

依公知及经验整理&#xff0c;原创保护&#xff0c;禁止转载。 专栏 《深入理解NAND Flash》 <<<< 返回总目录 <<<< ​ 1989年NAND闪存面世了&#xff0c;它曾经且正在改变了我们的日常生活。 NAND 闪存发明之所以伟大&#xff0c;是因为&#xff0c…

Hello World

世界上最著名的程序 from fastapi import FastAPIapp FastAPI()app.get("/") async def root():return {"message": "Hello World"}app.get("/hello/{name}") async def say_hello(name: str):return {"message": f"…

Vue2与Vue3的语法对比

Vue2与Vue3的语法对比 Vue.js是一款流行的JavaScript框架&#xff0c;通过它可以更加轻松地构建Web用户界面。随着Vue.js的不断发展&#xff0c;Vue2的语法已经在很多应用中得到了广泛应用。而Vue3于2020年正式发布&#xff0c;带来了许多新的特性和改进&#xff0c;同时也带来…

D. In Love

贪心&#xff0c;维护最靠左的右端点以及最靠右的左端点 // Problem: D. In Love // Contest: Codeforces - Codeforces Round 905 (Div. 3) // URL: https://codeforces.com/contest/1883/problem/D // Memory Limit: 256 MB // Time Limit: 2000 ms // // Powered by CP Edi…

一:C语言常见概念

一&#xff1a;C语言常见概念 1.认识C语言&#xff1a; ​ C语言是人和计算机交流的语言 ​ C语言是一门面向过程的语言&#xff0c;而C&#xff0c;Java&#xff0c;Python等是一门面向对象的语言 ​ 软件开发&#xff08;项目&#xff09;&#xff1a;面向过程面向对象 …

芯片半导体科普

我们在日常工作和生活中&#xff0c;经常会使用到各种各样的电子或电器产品&#xff0c;例如电脑、手机、电视、冰箱、洗衣机等。 这些产品&#xff0c;如果我们把它拆开&#xff0c;都会看到类似下面这样的一块绿色板子。 有时候是蓝色或黑色的 大家都知道&#xff0c;这个绿…

Android开发之横屏模式布局

创建一个同名的layout文件 Android Studio会创建res/layout-land目录&#xff0c;并放入一个名为activity_main.xml的新布局文件中。要查看新建文件和文件夹&#xff0c;可把项目工具窗口切换至Project视角模式&#xff1b;要查看文件汇总&#xff0c;请切回Android视角模式。…