Skip to content

09你的第一个API测试框架(一)

通过上一节课的学习,我们已经对搭建 Web 自动化测试框架非常熟悉了。

接下来,我将带你一步步搭建 API 测试框架,同样也是分为两个课时讲解,今天课时的主要内容是 Requests 和 pytest 的基本介绍,下一课时则是详细讲解 pytest 的使用,以及 pytest 集成测试报告,课时具体安排如下所示。

首先我们看下 API 测试框架和 Web 测试框架的区别。

两者唯一的区别在于测试请求的方式不同,Web 自动化测试框架是通过操作浏览器对目标对象进行操作的,而 API 测试框架通常是通过直接请求 HTTP 接口来完成的,特别是随着近几年微服务技术的普及,RESTFUL 风格的 HTTP 接口调用越来越多。

想要了解更多关于 HTTP 协议和 RESTFUL 的内容,可参考公众号 iTesting 的两篇文章《更好地理解 RSTFUL》《HTTP协议总结》

那么 HTTP 形式的 API, 有哪几种请求方式呢?

GET

GET 方法请求一个指定资源的表示形式,使用 GET 的请求应该只被用于获取数据。
HEAD

HEAD 方法请求一个与 GET 请求的响应相同的响应,但没有响应体。
POST

POST 方法用于将实体提交到指定的资源,通常导致在服务器上的状态变化或副作用。
PUT

PUT 方法用请求有效载荷替换目标资源的所有当前表示。
DELETE

DELETE 方法删除指定的资源。
CONNECT

CONNECT 方法建立一个到由目标资源标识的服务器的隧道。
OPTIONS

OPTIONS 方法用于描述目标资源的通信选项。
TRACE

TRACE 方法沿着到目标资源的路径执行一个消息环回测试。
PATCH

PATCH 方法用于对资源应用部分修改。

一般情况下,在测试中使用最多的请求方式是 GET、POST、PUT、DELETE 这四种。并且如果是通过代码方式发送请求,在 Python 里我们最经常用的就是 Requests 库。

如果我们要使用 Requests 库作为发送接口请求的命令,我们就必须了解下 Requests 是什么,以及具体怎么用。

Requests

那什么是 Request 呢?Requests 官方说它是一个简单而优雅的 HTTP 库

1.Requests 安装

Requests 的安装非常简单,只要在命令行里输入如下命令即可:

python
$ python -m pip install requests

2.Requests 使用

Requests 的使用也非常简单,下面我们就来看下。

  • 发送 get 请求
python
import requests
if __name__ == '__main__':
    # 发送get请求
    requests.get('https://httpbin.org/ip')

是不是非常简单?但在实际应用中,GET 接口请求常常要带参数 query string,而且有时候需要加 Header,鉴权(OAuth)甚至代理(Proxy),那么这部分接口请求如何发送呢?

python
import requests

if __name__ == '__main__':
    # 发送get请求 --带参数
    # 等同于直接访问https://httpbin.org/get?kevin=hello
    requests.get('https://httpbin.org/get', params={'kevin': 'hello'})

    # 当访问接口发生301跳转时,可以设置允许或者禁止跳转
    requests.get('http://github.com/', allow_redirects=False)

    # 发送get请求, 加proxy
    proxies = {'http': 'http://10.10.1.10:3128',
               'https': 'http://10.10.1.10:1080'}
    requests.get('https://httpbin.org/get', proxies=proxies)

    # 发送get请求,加鉴权 -- Basic Auth
    # 首先导入HTTPBasicAuth,一般导入语句写在py文件的最前面。
    from requests.auth import HTTPBasicAuth
    requests.get('https://api.github.com/user', auth=HTTPBasicAuth('user', 'password'))

    # 发送get请求,加鉴权 -- Digest Auth
    # 首先导入HTTPDigestAuth,一般导入语句写在py文件的最前面。
    from requests.auth import HTTPDigestAuth
    requests.get('https://api.github.com/user', auth=HTTPDigestAuth('user', 'password'))

    # OAuth 1 Authentication
    # 首先安装requests_oauthlib (可通过pip install)
    from requests_oauthlib import OAuth1
    url = 'https://api.twitter.com/1.1/account/verify_credentials.json'
    auth = OAuth1('YOUR_APP_KEY', 'YOUR_APP_SECRET', 'USER_OAUTH_TOKEN', 'USER_OAUTH_TOKEN_SECRET')
    requests.get(url, auth=auth)

以上是不同情况下的 get 请求,在测试中可以根据实际需求选择具体使用哪种方式。

  • 发送 post 请求

post 请求通常都会带数据 Payloads,当然也会需要 Header、OAuth,发送方式如下:

python
import requests

if __name__ == '__main__':
    url = 'https://httpbin.org/anything'
    headers = {'user-agent': 'my-app/  0.0.1'}
    payloads = {'iTesting': 'better to follow'}
    auth = {"username":"iTesting", "password": "Kevin"}

    # 直接post
    r = requests.post(url, data=payloads)
    # post带header
    r = requests.post(url, headers=headers, data=payloads)

    # post带鉴权, auth类型跟get请求支持的auth类型相同。
    r = requests.post(url, headers=headers, data=payloads, auth=HTTPBasicAuth('user', 'password'))
  • 发送 put 请求
python
import requests

if __name__ == '__main__':
    # 直接发送put请求 
    # 如需要加header,auth,即参考post请求
    r = requests.put('https://httpbin.org/put', data={'hello': 'iTesting'})
    print(r.text)
  • 发送 delete 请求
python
import requests

if __name__ == '__main__':
    # 直接发送delete请求
    r = requests.delete('https://httpbin.org/anything', data={'hello': 'iTesting'})
    print(r.text)

3.获取接口返回值

接口的请求通常会有返回值,在接口测试中,特别是在顺序访问多个接口,并且前一个接口的返回是后一个接口的入参时,常常需要把接口返回的结果保持下来解析,那么如何保持接口请求呢?

Requests 中提供了以下方式来保存接口返回值:

python
# -*- coding: utf-8 -*-
import requests
if __name__ == "__main__":
    s = requests.session()
    r = s.post('https://httpbin.org/anything', data={'hello': 'kevin'})
    # 返回文本型response
    print(r.text)
    # #返回文本型response,并用utf-8格式编码
    # # 当你用r.text得出的结果是不可读的内容例如包括类似xu'\xe1'或者有错误提示"'ascii' codec can't encode characters in position"时,可以用encode
    print(r.text.encode('utf-8'))
    # # 获取二进制返回值
    print(r.content)
    # # 获取请求返回码
    print(r.status_code)
    # 获取response的headers
    print(r.headers)
    # 获取请求返回的cookies
    s.get('http://google.com')
    print(request.cookies.get_dict())

获取接口返回值后,如果你的前一个接口的返回是下一个接口的入参,那么就可以根据需要采用以上方式的任意一个解析出你需要的返回值,然后传递给下一个接口即可。

4.Requests 保存 Session

以上 4 种 Requests 使用方式是直接发送接口请求,我们知道 HTTP 协议是无状态的协议,这也就导致每次接口请求都是独立的,也就意味着你的多个接口请求之间不能共用数据,比如登录态、cookie 等都是不能共用,这显然不符合我们的需求。

基于此,Requests 库提供了 Session 会话对象, 用来帮我们跨请求保持参数,使用 requests.Session() , 可以在一个 Session 实例的所有请求中保留 cookie,下面我们来看下 requests.Session() 的基本用法:

python
import requests
if __name__ == '__main__':
    # 初始化一个session对象
    s = requests.Session()

    # 第一个get,先设置一个session
    # httpbin这个网站允许我们通过如下方式设置,在set后写你需要的值即可
 s.get('https://httpbin.org/cookies/set/sessioncookie/iTestingIsGood')
 
    # 设置好后获取所有的cookies
    r = s.get('https://httpbin.org/cookies')

    # 打印,确定我们的cookies被保存了 
    print(r.text)
    # 结果如下
    # '{"cookies": {"sessioncookie": "iTestingIsGood"}}'

requests.Session() 的使用非常简单,首先你初始化一个 session 对象,接着你使用这个初始化后对象实例直接发起请求,在整个 session 内的所有请求之间是可以共享状态的。上个例子中我们就先初始化了 Session() 对象,假设是 s,然后通过 s.get() 方法去设置了一个 cookie,它的名字是 sessioncookie,它的值是 itestingIsGood。最后,我们去访问 cookies 接口,就拿到了我们刚设置的 cookie。通过 requests.Session() 的方式,我们就实现了 cookie 的保持。

现在,我们更改一下运行方式,注释掉 9 行语句设置 sessioncookie 的那条语句, 再次运行结果如下:

python
{
  "cookies": {}
}

你会发现,本次运行并没有拿到任何 cookie,由此可见,正是由于 requests.Session() ,cookie 才在两次请求中保持了。

一般在测试中,你可以通过直接请求登录接口便能拿到登录态,例如如下的形态:

python
# 本代码仅供演示用法
import requests

if __name__ == '__main__':
    s = requests.Session()
    # 登录获取登录态
    s.post(login_url, data=data, headers=headers, verify = False)
    # 登录态获取后,请求登录后才能访问的接口,也能请求成功。
    s.post('your-api'

为了更好地理解 requests.Session() 是如何保持登录态的,我们来看一个实际例子:

python
import requests
if __name__ == '__main__':
    api = 'https://gate.lagou.com/v1/entry/message/newMessageList'
    s = requests.Session()
    r = s.get(api)
    print(r.text)
    # 结果如下:
    # {"state": 1003, "message": "非法的访问"}

在这个例子中,我直接访问拉勾教育的一个接口,这个接口是用来获取当前账户有没有新的 Message 的,但由于我没有登录,所以我访问的结果是返回"非法的访问"。那么我的登录态怎么获取呢?

因为我们无法得知 lagou教育生产环境上的登录接口,故我们采用另外一个办法绕过,我首先采用人工登录的方式,然后打开浏览器 console,去 Applicaton → Cookies下查看 cookies:

经过尝试,我们得出 lagou 网站用于保持登录态的两个 cookie 的 key 是 _gid 和 gate_login_token。由此,更改我们的代码如下:

python
# -*- coding: utf-8 -*-
import requests
if __name__ == "__main__":
    url = 'https://gate.lagou.com/v1/entry/message/newMessageList'
    cookie = {'cookie': '_gid=GA1.2.438589688.1601450871; gate_login_token=475844a837230240e1e73e4ecfa34102e65fa8e5384801cca67bbe983a142abb;'}
    headers = {'x-l-req-header': '{deviceType: 9}'}
    s = requests.Session()
    # 直接带登录态发送请求
    r = s.get(url, cookies=cookie, headers=headers)
    # 不经过登录,也能访问登录后才能访问的接口
    print(r.text.encode('utf-8'))
    # {"state":1,"message":"成功","content":{"newMessageList":[],"newMessageCount":0}}

由此看出,通过在不同接口请求中传递维持登录态的 cookies,就可以实现登录态在多个接口中的传递。

unittest 框架集成 Requests

至此,我们已经对如何使用 Requests 发送接口请求了然于胸了。现在我把它嵌入到我们上一节讲到的 unittest 框架中去:

我们文件结构不变,仅仅只把原来调用 Selenium/WebDriver 的地方换成 Requests 即可。

先来看下我们之前的框架文件结构:

java
|--lagouTest
    |--tests
        |--test_baidu.py
        |--__init__.py
    |--common
        |--html_reporter.py
        |--__init__.py
    |--HTMLTestRunner.py
    |--main.py
    |--__init__.py
    |--txtReport.py

其他文件不用更改,我们仅需要在 tests 文件夹下面新建一个测试接口的文件,例如 test_lagou.py,如此我们的文件结构就变成了如下:

java
|--lagouTest
    |--tests
        |--test_baidu.py
        |--test_lagou.py
        |--__init__.py
    |--common
        |--html_reporter.py
        |--__init__.py
    |--HTMLTestRunner.py
    |--main.py
    |--__init__.py
    |--txtReport.py

test_lagou.py 下的代码如下:

python
# coding=utf-8
import json
import unittest
import requests

class TestLaGou(unittest.TestCase):
    def setUp(self):
        self.s = requests.Session()
        self.url = 'https://www.lagou.com'
    def test_visit_lagou(self):
        result = self.s.get(self.url)
        assert result.status_code == 200
        unittest.TestCase.assertIn(self, '拉勾', result.text)
    def test_get_new_message(self):
        # 此处需要一个方法登录获取登录的cookie,但因我们无法知道拉勾登录真实的API,故采用此方式登录
        message_url = 'https://gate.lagou.com/v1/entry/message/newMessageList'
        cookie = {
            'cookie': '_gid=GA1.2.438589688.1601450871; gate_login_token=475844a837230240e1e73e4ecfa34102e65fa8e5384801cca67bbe983a142abb;'}
        headers = {'x-l-req-header': '{deviceType: 9}'}
        # 直接带登录态发送请求
        result = self.s.get(message_url, cookies=cookie, headers=headers)
        assert result.status_code == 200
        assert json.loads(result.content)['message'] == '成功'
    def tearDown(self):
        self.s.close()

if __name__ == "__main__":
    unittest.main(verbosity=2)

在本文件里,我们定义了一个测试类 TestLaGou,然后在 setUp 方法里初始化了 requests.Session() 对象,接着定义了两个测试用例 test_visit_lagou 和 test_get_new_message,在这两个测试用例中,分别发送不同的接口请求并且断言,最后在测试结束后关闭了这个 Session 对象。

直接运行 main.py, 运行成功后,浏览器打开在项目根目录下生成的 test_report 文件,如下图所示:

可以看到 test_visit_lagou 和 test_get_new_message 这两条测试用例均运行成功了。

你可以看出用 unittest 集成 API 测试也非常简单?在此,给你留一个课后作业,那就是:仔细学习下 Appium 的基础知识,然后把 Appium 和 unittest 集成起来,生成一个可用于移动端测试的自动化框架。

这个时候,我们再来看下,我们的测试框架变成了什么样子?

(很多模块我们还没有完成,将在后续章节逐一完善)

通过前面章节的学习,我们依托测试框架核心模块 unittest,快速搭建了我们的 Web 自动化测试框架、API 自动化测试框架,但我们在 unittest 的使用中,发现了如下缺点:

  • 重复代码太多,比如 seUp(), tearDown() 每个测试类都需要。

  • unittest 数据驱动支持不好,并且不明显, case 一多就容易乱。

  • 有一定的学习成本,例如 unittest 里的 assertIn* 语法,便是 unittest 特有的。

学过测试框架设计原则的我们都知道, 一个好的框架必须做到避免重复代码。 那么测试框架核心模块,是不是只有 unittest 一个呢?有没有更好的测试框架核心模块来解决上述问题呢?

pytest

pytest 是一个成熟、全套的 python 自动化测试工具,旨在帮助你写出更好的程序。它可以用来做单元测试,也可以用来做功能测试、接口自动化测试;相比 unittest,它能支持更多、更全面的功能,有着以下特色和优势。

直接使用纯粹的 python 语言, 不需要你过多学习框架特定的语法,例如 self.assert* 等,以此减少你的学习成本;

  • pytest 框架不需要写诸如 setUp()、tearDown() 这样的方法,它可以直接开始测试;

  • pytest 可以自动识别测试用例,无须像 unittest 一样将测试用例放进 TestSuite 里组装;

  • test fixtures 包括数据参数化测试非常好用;

  • pytest 支持错误重试;

  • pytest 支持并发测试。

下面我们就一起看下,如何使用 pytest 来搭建我们的第一个接口测试框架?

俗话说"不积跬步,无以至千里"。在创建我们的第一个接口测试框架之前,我们先来看下 pytest的基础用法。

1. pytest 安装

pytest 不是 python 标准库,故使用时需要安装:

python
pip install -U pytest

安装好后,你可以再 terminal 里查看它的版本:

python
pytest --version
# pytest 6.1.0

2. pytest 简单使用

下面来看下,你的第一个 pytest 脚本怎么写:

python
# iTesting.py
# coding=utf-8
import pytest

class TestSample(object):
    # 测试用例默认以test开头
    def test_equal(self):
        assert 1 == 1
    def test_not_equal(self):
        assert 1 != 0

在这个测试文件(iTesting.py)里,我定义了一个测试类 TestSample,然后在这个测试类下面定义了两个测试用例,分别是 test_equal 和 test_not_equal,下面来运行下这个测试类:

powershell
# 以在Windows下执行为例
# 假设我们的测试目录在D:\_Automation\lagouAPITest
D:\_Automation\lagouAPITest>python -m pytest iTesting.py

运行后,你会看到如下结果:

python
=== test session starts ====
platform win32 -- Python 3.8.5, pytest-6.1.0, py-1.9.0, pluggy-0.13.1
rootdir: D:\_Automation\lagouAPITest
collected 2 items

iTesting.py ..                                                                                                                               [100%]
=== 2 passed in 0.01s ====

是不是非常简单?而且在整个测试中,你只需要有 python 的原生语法基础知识就好了,不需要再额外地学习。

3. pytest 直接运行 unittest 测试用例

不仅如此,pytest 还可以兼容 unittest,原来使用 unittest 框架写的代码,可以被 pytest 直接调用。

我们来看下如下 unittest 测试用例:

python
# 文件名tests/test_sample.py
# coding=utf-8
import unittest

#测试类必须要继承TestCase类
class TestSample(unittest.TestCase):
    #测试用例默认以test开头
    def test_equal(self):
        self.assertEqual(1, 1)
    def test_not_equal(self):
        self.assertNotEqual(1, 0)

if __name__ == '__main__':
    unittest.main()

在运行时,我们可以直接用如下方式调用:

python
D:\_Automation\lagouTest>python -m pytest tests/test_sample.py

如果你想运行整个测试用例集,或者你想把之前用 unittest 的测试用例全部换成用 pytest 执行,该如何操作呢?

我们把 main.py 文件里,所有关于执行测试用例的部分,从 unittest 执行更改为 pytest 执行,更改后的 main.py 函数如下:

python
# coding=utf-8
import pytest
import os
import glob
# 查找所有待执行的测试用例module,见《04|必知必会,打好Python基本功》
def find_modules_from_folder(folder):
    absolute_f = os.path.abspath(folder)
    md = glob.glob(os.path.join(absolute_f, "*.py"))
    return [f for f in md if os.path.isfile(f) and not f.endswith('__init__.py')]

if __name__ == "__main__":
    # 得出测试文件夹地址
    test_folder = os.path.join(os.path.dirname(__file__), 'tests')
    # 得出测试文件夹下的所有测试用例
    target_file = find_modules_from_folder(test_folder)
    # 直接运行所有的测试用例
    pytest.main([*target_file, '-v'])

然后在命令行下执行:

python
D:\_Automation\lagouTest>python main.py

你会发现,整个测试用例集用 pytest 开始执行了,执行结果如下:

正因为 pytest 完全兼容 unittest,以及具备刚刚介绍的那些诸多优点,才使得 pytest 风靡于整个 python 社区。

4. pytest 查找测试用例的原则

在我们介绍 pytest 的诸多使用方法之前,我先介绍下使用 pytest 查找测试用例的原则:

指定命令行参数时的查找原则

如果指定了命令行参数,则根据命令行参数执行。

这句话很好理解,像我们之前的测试里,均指定了测试要执行的 module(例如 "python -m pytest tests/test_sample.py"),故 pytest 只会查找 test_sample.py 文件。

未指定命令行参数时的查找原则

如果未指定命令行参数(即直接在命令行输入 pytest),则从 testpath(已配置)或从当前目录开始查找可用的测试用例, 其步骤如下:

  • 搜索由任何符合以下规则的文件 test_*.py 或 *_test.py 文件。

  • 找到后,从这些文件中,收集如下测试项:test 为前缀的函数;Test 为前缀的类里面的以 test 为前缀的函数。

我们举个例子来理解下这个原则, 假设我的项目结构如下:

注意:我有一个测试文件为 sample.py,里面包括两个测试用例,然后我 tests 文件夹下有两个 .py 文件共计 4 个测试用例,如果我在命令行里输入以下命令:

python
 D:\_Automation\lagouTest>pytest

你会发现运行结果如下:

仅仅有 4 个测试用例运行了,但不包括 sample.py 里的两个测试用例,这就是 pytest 默认查找在起作用,因为运行 pytest 时,我没有指定运行某个文件,所以 pytest 自动在当前目录下查找以"test_"开头或者以"_test" 结尾的 py 文件,显然sample.py 不符合这个规则,故被忽略了。

而我如果直接在命令行运行:

python
D:\_Automation\lagouTest>pytest sample.py

你将看到 sample.py 里的两个测试方法都被执行了(因为指定了运行文件)。

下面我们再一次更改,更改 sample.py 为 test_sample.py,然后把这个文件里的方法"test_equal"改成"equal_test",于是项目文件结构如下:

在命令行运行:

python
D:\_Automation\lagouTest>pytest

你可以看到运行结果如下:

共有 5 个测试被执行,其中不包括 equal_test 方法,因为它不是以 test 开头。

pytest 集成 Requests

知道了 pytest 如何使用,我们来看下,如何创建第一个 API 接口测试框架?

首先创建我们的项目文件结构:

html
|--lagouAPITest
    |--tests
        |--test_baidu.py
        |--test_lagou.py
        |--__init__.py
    |--common
        |--__init__.py
    |--__init__.py

其中:

  • 各个__init__.py 文件都是空文件。

  • tests 文件夹下的 test_baidu.py 是我们在第 7、8课时**"你的第一个 Web 测试框架"**里创建的,里面的内容我们保持不变,仍然以 unittest 作为测试框架的核心模块。

  • tests 文件夹下的另外一个文件 test_lagou.py 是我刚刚在unittest 框架集成 Requests这一小节建立的,当时我们是以 unittest 作为测试框架的核心驱动模块,现在我们把它更改为由 pytest 驱动。

更改后的 test_lagou.py 文件内容如下:

python
# coding=utf-8
import json
import requests

class TestLaGou:
    # 在pytest里,针对一个类方法的setup为setup_method,
    # setup_method作用同unittest里的setUp()
    def setup_method(self, method):
        self.s = requests.Session()
        self.url = 'https://www.lagou.com'
    def test_visit_lagou(self):
        result = self.s.get(self.url)
        assert result.status_code == 200
        assert '拉勾' in result.text
    def test_get_new_message(self):
        # 此处需要一个方法登录获取登录的cookie
        message_url = 'https://gate.lagou.com/v1/entry/message/newMessageList'
        cookie = {
            'cookie': '_gid=GA1.2.438589688.1601450871; gate_login_token=475844a837230240e1e73e4ecfa34102e65fa8e5384801cca67bbe983a142abb;'}
        headers = {'x-l-req-header': '{deviceType: 9}'}
        # 直接带登录态发送请求
        result = self.s.get(message_url, cookies=cookie, headers=headers)
        assert result.status_code == 200
        assert json.loads(result.content)['message'] == '成功'
    # 在pytest里,针对一个类方法的teardown为teardown_method,
    # teardown_method作用同unittest里的dearDown()
    def teardown_method(self, method):
        self.s.close()

在命令行中运行整个测试用例集:

html
D:\_Automation\lagouAPITest>pytest -v

运行结果如下所示:

整个测试用例集的 4 条测试用例全部被 pytest 识别到并执行了(有一条 case 被人为 skip了)。由此可见,使用 pytest 集成 Requests 非常简单,我们甚至不需要定义 main.py 文件也可以运行所有的测试用例。

小结

最后,我们回顾一下这一课时的主要内容。

我们知道 API 测试框架通常是通过直接请求 HTTP 接口来完成的,所以我先介绍了一个 HTTP 库------ Requests。通过上节课对 Web 自动化测试框架,我们将测试驱动模块由 Selenium/WebDriver 换成了 Requests,并借此搭建了第一个基于 unittest 的接口测试框架。但在 unittest 框架集成 Requests 的过程中,我们发现了 unittest 使用中的许多不便。

所以便向你介绍了另一个测试框架核心模块------pytest,简单讲解了 pytest 的安装、使用,以及 pytest 集成 Requests 的过程。下一课时我将详细讲解 pytest 的应用,以及如何生成 pytest 测试报告,将一步步带领你深入 pytest,在此过程中会带你完整搭建出一个基于 pytest 的接口自动化测试框架。