1. 数据驱动

1.1 测试数据参数化

参数化测试 是一种 “ 数据驱动测试 “ (Data-DrivenTest),在同一个方法上测试不同的参数,以覆盖所有可能的预期分支的结果。它的测试数据可以与测试行为分离,被放入到文件、数据库或者外部介质中,再由测试程序读取。

Python 标准库中的 unittest 自身不支持参数化测试,为了解决这个问题,有人专门开发了两个库:一个是 ddt,一个是 parameterized。

数据驱动思想:数 据和用例进行分离,通过外部数据去生成测则试用例。

1.1.1 Ddt 库

DDT 正好是 “Data-Driven Tests” (数据驱动测试) 的缩写。安装方式:

1
pip install ddt

data

然后我们直接来看个案例:

1
2
3
4
5
6
7
8
9
10
11
12
13
import unittest
from ddt import ddt, data, unpack

@ddt
class MyTest(unittest.TestCase):
@data((3, 1), (1, 0), (1.2, 1.0))
@unpack
def test_values(self, first, second):
print(first, second)
self.assertTrue(first > second)

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

运行后可以发现上面代码通过参数化测试分离成了 3 条测试用例,但我们不用写重复的三段代码。

需要注意的是,@data 中传入参数的变量数量,要能与下面测试用例函数的传入参数数量保持一致。

我们再来看一个案例,下面这个案例 @data 中测试的参数就有 4 种,因此在下面函数的传入参数中也要定义四个变量来接受参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
import unittest
from ddt import ddt, data, unpack

@ddt
class MyTest(unittest.TestCase):
@data([1, 2, 3, 6], [2, 3, 4, 9], [3, 4, 5, 12])
@unpack
def test_add(self, testdata1, testdata2, testdate3, exceptdata):
sum = testdata1 + testdata2 + testdate3
self.assertEqual(sum, exceptdata)

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

@data 中传入的参数可以是以元组的形式,也可以是以列表的形式,总之要以序列的形式传入。

file_data

除了通过上面通过 data 的方式进行参数化外,我们还可以使用 file_data 装饰器来解析一个方式参数的文件。例如,我现在创建一个 json 文件,并往里面写入参数数据。

1
2
3
4
5
6
[
"1|2|3",
"3|2|5",
"3|5|8",
"5|5|10"
]

接着,我就可以通过 file_data 来读取这个 json 文件中的数据,然后拆分成多条用例使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import unittest
from ddt import ddt, file_data, unpack

@ddt
class MyTest(unittest.TestCase):
@file_data('data.json')
@unpack
def test_sum(self, value):
a, b, sum = value.split('|')
print(a, b, sum)
result = int(a) + int(b)
self.assertEqual(result, int(sum))

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

1.1.2 Parameterized 库

参数化驱动还有另一个库,还是需要通过 pip 先进行下载:

1
pip install parameterized

而这个库使用起来则更加便捷一些,但不能像 ddt 的 file_data 装饰器一样去解析 json 等文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
import unittest
from parameterized import parameterized

class MyTest(unittest.TestCase):
# 1.参数化参数:列表套元组
# 2.列表有几个元素,测试用例执行几次
# 3.元组元素的位置匹配测试用例的形参
@parameterized.expand([(3, 1), (1, 0), (1.5, 1.0)])
def test_values(self, first, second):
self.assertTrue(first > second)

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

1.2 Excel 存储测试数据

Python 可以使用 xIrd 库来读取 Excel 文件(对 xls,xlsx 格式文件都可以读取),使用 xlwt 库来生成 Excel 文件(只支持到 excel 2003,即 xls 文件),另外 openpyxl 库同时支持读、写 excel 文件,且主要针对 Excel2007 之后的版本(.xlsx)。

我们这里重点介绍一下最常用的 openpyxl 库的使用,首先还是需要先通过 pip 进行下载:

1
pip install openpyxl
  • 封装能力

我们可以将解析 excel 文件的能力都存放到一个 parse_excel.py 文件中,方便我们调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from openpyxl import load_workbook

class ParseExcel(object):
def __init__(self, excelPath, sheetName):
print(excelPath, sheetName)
try:
self.wb = load_workbook(excelPath) # 通过文件路径加载文件
print("File loaded successfully.")
except Exception as e:
print(f"Error loading file: {e}")
self.sheet = self.wb[sheetName] # 指定获取的 sheet 表
self.maxRowNum = self.sheet.max_row # 获取表中的行数

# 以列表的方式返回数据
def getDatasFromSheet(self):
dataList = []
# 遍历表中的每一行
for line in self.sheet.rows:
tmpList = []
tmpList.append(line[0].value) # 获取第一列值
tmpList.append(line[1].value) # 获取第二列值
dataList.append(tmpList)
return dataList[1:] # 返回不包含第一行的标题

上面代码获取的数据,假设只有两列值。

  • ddt + openpyxl

ddt 的 file_data 装饰器是无法解析 excel 文件的。

因此,我们可以在其它文件中,通过调用上面封装好的函数,以列表的形式获取到 excel 中的数据,然后传入 ddt 的 data 装饰器中,从而转化成参数执行用例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import unittest
from ddt import ddt, data
from parse_excel import ParseExcel

excelPath = r'测试数据.xlsx'
sheetName = '参数表'
excel = ParseExcel(excelPath, sheetName)

@ddt
class TestDemo(unittest.TestCase):
@data(*excel.getDatasFromSheet()) # * 号表示不定长的参数
def test_print(self, data):
username, password = tuple(data)
print(username, password) # 打印获取的两个数据

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

@unpack 装饰器:

通过上面代码可以发现,这次并没有使用 unpack 装饰器,这是因为这里我们不需要通过 unpack 进行参数的拆分。而是将参数全部传入到第一个 data 参数中,自行通过代码处理这个数据,将其拆分成 username 和 password 变量。

也就是说,如果不加 unpack 装饰器,则 data 装饰器获取到的数据都会传入到第一个参数中,不管你下面函数的入参定义了几个,都只会传入第一个。反之,加了 unpack 装饰器,则会自动与定义的入参一一对应起来。

我们可以对上面的代码加上 unpack 装饰器,但最终执行的结果还是一样的,只是 unpack 会自动帮我们拆分参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import unittest
from ddt import ddt, data, unpack
from parse_excel import ParseExcel

excelPath = r'测试数据.xlsx'
sheetName = '参数表'
excel = ParseExcel(excelPath, sheetName)

@ddt
class TestDemo(unittest.TestCase):
@data(*excel.getDatasFromSheet()) # * 号表示不定长的参数
@unpack
def test_print(self, username, password):
print(username, password) # 打印获取的两个数据

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

2. Mock 接入

2.1 Mock 背景

在一些特殊场景下,我们一些服务可能会比较不稳定或者还没有开发完,会出现一些不可控的因素,而上游急着需要调用下游的这些服务来观察自身服务是否符合预期。

那么我们可以通过 Mock 的方式,Mock 出下游(也就是通过模拟的方式,实现一个下游的替身出来),从而可以使用 Mock 的替身提前测试自身服务。

常见 Mock 适用场景:

  • 前后端联调:当前端需要的接口后端还在开发,就可以使用 mock 模拟后端返回我们需要的数据。
  • 单元测试:当我们所要测试的接口还未开发完,而我们还要完成测试用例代码的编写,我们可以先模拟这个接口进行测试。
  • 屏蔽无关的真实服务:专注于要测试服务本身,而其它服务又不在本次测试范围内,则可以使用 mock 只模拟出我们关注的服务。
  • 依赖第三方接口:当我们的接口需要调用第三方,而第三方接口我们还未得到权限或未购买时,可使用 mock 模拟进行测试。

注意 mock 只适合用于临时的测试,而不能代替真实场景的测试。

例如,我现在有个服务需要调用下游的支付服务,通过这个下游服务的返回值来决定之后该怎么执行:

  • 当返回值状态码为 200,则表示支付成功,我当前服务就可以执行后续逻辑
  • 当返回值状态码为 500,则表示支付失败,需要做一些异常的处理逻辑

如果此时下游的支付服务出现问题,或者没有开发完毕,无法提供这个返回值,而我此时有想验证一下我后续代码的处理逻辑,则可以通过 Mock 的方式模拟返回值的状态码,从而可以执行后续逻辑并进行观察。

2.2 Mock 使用

如果你的团队技术基础很好,开发能力很强,那么我建议你用对应语言的 Mock 框架,例如 Java 语言的 Mockito (https://github.com/mockito/mockito)框架和 Python 语言的 mock 框架(https://pypi.org/project/mock/)。

如果你的团队技术基础相对比较薄弱,那么我推荐你看看 moco,这个框架在开发 Mock 服务的时候,提供了一种不需要任何编程语言的方式,你可以通过撰写它约束的 Json 建立报务,并通过命令启动对应的服务,这就可以快速开发和启动运行你需要的 Mock 服务(项目地址:https://github.com/dreamhead/moco/)。

2.2.1 unittest.mock 类

官方文档:https://docs.python.org/dev/library/unittest.mock.html

Mock 类库是一个专门用于在 unittest 过程中制作(伪造)和修改(篡改)测试对象的类库,制作和修改的目的是避免这些对象在单元测试过程中依赖外部资源(网络资源,数据库连接,其其它服务以及耗时过长等)。

使用前须知

  • Python2.x 的版本,mock 是一个独立的模块,需要用 pip 安装
1
pip install -U mock
  • 从 Python3.3 以后的版本,mock 已经合并到 unittest 模块中了,是 unittest 单元测试的一部分,直接导入过来就行
1
from unittest import mock

案例一

直接举一个简单的例子,下面这段代码的 send_url 函数中的地址不连通,因此我们可以使用 mock 的方式模拟 send_url 的返回值。

1
2
3
4
5
6
7
8
9
10
from unittest import mock
import requests

def send_url():
url = 'http://127.0.0.1:5000/helloworld'
return requests.get(url)

send_url = mock.Mock(return_value='hello world')
response = send_url()
print(response)

如果我们想在 mock 的基础上,调用回真实的函数,那么就可以在 Mock 方法中,添加一个 side_effect 属性,让其指向想调用的真实函数,这样其实就等同于直接调用这个真实的函数了。

1
2
3
4
5
6
7
8
9
10
from unittest import mock
import requests

def send_url():
url = 'http://127.0.0.1:5000/helloworld'
return requests.get(url)

send_url = mock.Mock(return_value='hello world', side_effect=send_url())
response = send_url()
print(response)

案例二

mock 里面另一种实现方式,即 patch 装饰器的使用。patch() 作为函数收装饰器,将会创建模拟,并将其传递到装饰函数。

而这种方法,一般适用于单元测试当中,例如现在 functions.py 文件中我有一段功能函数的代码。

1
2
3
4
5
6
7
def add_and_multiply(x, y):
addition = x + y
multiple = multiply(x, y)
return (addition, multiple)

def multiply(x, y):
return x * y

而我在单元测试代码中,则可以针对上面的两个函数功能进行测试。

1
2
3
4
5
6
7
8
9
10
import unittest
import functions

class MyTestCase(unittest.TestCase):
def test_add_and_multiply(self):
x = 3
y = 5
addition, multiple = functions.add_and_multiply(x, y)
self.assertEqual(8, addition)
self.assertEqual(15, multiple)

但如果有一天这个 multiply 函数变得不稳定,可能返回的结果会有差错,那么我单元测试中的代码就会报错。

为了防止这种情况的发生,我们可以 mock multiply 函数的返回值,例如下面我将返回值始终 mock 成 15,那么我这个单元测试的代码就不会受到 multiply 函数不稳定的影响了,从而可以专注于当前代码逻辑的测试。

1
2
3
4
5
6
7
8
9
10
11
12
13
import unittest
import functions
from unittest.mock import patch

class MyTestCase(unittest.TestCase):
@patch("functions.multiply")
def test_mock_add_and_multiply(self, mock_multiply):
x = 3
y = 5
mock_multiply.return_value = 15
addition, multiple = functions.add_and_multiply(x, y)
self.assertEqual(8, addition)
self.assertEqual(15, multiple)

案例三

现在假设有一个支付服务的场景,在 pay.py 文件中,存放着支付场景的处理代码,但是 pay 函数功能目前还未开发完成,不过目前明确知道该接口的返回数据是怎么定义的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def pay():
'''
假设这里是一个支付的功能,未开发完
支付成功返回:{"result":"success", "msg":"支付成功"]
支付失败返回:{"result":"fail", "msg":"余额不足"}
'''
pass

def pay_status():
'''
根据支付的结果 success or fail,判断跳转哪个页面
'''
result = pay()
try:
if result["result"] == "success":
return "支付成功"
elif result["result"] == "fail":
return "支付失败"
else:
return "未知错误异常"
except:
return "Error,服务端返回异常!"

那么我们就可以模拟未开发完的接口的返回数据,从而可以检测相应返回数据的逻辑。

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
import unittest
from unittest import mock
import pay

class TestPayStatus(unittest.TestCase):
def test_01(self):
'''测试支付成功场景'''
# 方法一:mock 一个支付成功的数据
pay.pay = mock.Mock(return_value={"result": "success", "msg": "支付成功"})

# 根据支付结果测试页面跳转
status = pay.pay_status()
print(status)
self.assertEqual(status, "支付成功")

def test_01(self):
'''测试支付成功场景'''
# 方法一mock 一个支付成功的数据
pay.pay = mock.Mock(return_value={"result": "fail", "msg": "余额不足"})

# 根据支付结果测试页面跳转
status = pay.pay_status()
print(status)
self.assertEqual(status, "支付失败")

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

同样,我们也可以使用 patch 装饰器来 mock 数据。

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
import unittest
from unittest import mock
import pay

class TestPayStatus(unittest.TestCase):
@mock.patch("pay.pay")
def test_01(self, mock_pay):
'''测试支付成功场景'''
# 方法二:mock.patch 装饰器模拟返回结果
mock_pay.return_value = {"result": "success", "msg": "支付成功"}

# 根据支付结果测试页面跳转
status = pay.pay_status()
print(status)
self.assertEqual(status, "支付成功")

@mock.patch("pay.pay")
def test_02(self, mock_pay):
'''测试支付成功场景'''
# 方法二:mock.patch 装饰器模拟返回结果
mock_pay.return_value = {"result": "fail", "msg": "支付失败"}

# 根据支付结果测试页面跳转
status = pay.pay_status()
print(status)
self.assertEqual(status, "支付失败")

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

2.2.2 Mock 技术栈选型

  • 简单是第一要素,需要将学习成本降到最低
  • 处理速度比完美的 mock 服务更重要
  • mock 服务要能轻量化启动,并且容易销毁

3. 测试报告

Unittest 测试默认结果输出是比较简单的,即使通过指定 -v 参数得到详细输出,也就只是在默认输出内容的基础上,额外显示了用例名称,还达不到一份测试报告的标准,而是一份日志信息。

3.1 HTMLTestRunner

最新版:https://github.com/oldani/HtmlTestRunner

汉化版:https://github.com/wangquansheng/HTMLTestRunner\_cn

如果想要生成 HTML 格式的报告,那么就需要额外借助第三方库(如 HTMLTestRunner)来操作。

HTMLTestRunner 是一个第三方的 unittest HTML 报告库,首先我们下载 HTMLTestRunner.py,并放到对应目录下或者放到 …/python\Lib 目录下。

Tips:

这里我们使用的是汉化版的报告库。

然后可以在写测试用例的文件中导入这个 HTMLTestRunner.py 文件。

  • HTMLTestRunner 类说明:
    • stream:指定报告的路径
    • title:报告的标题
    • description:报告的描述
  • run() 方法说明:
    • suit:运行的测试套件
    • rerun:重跑次数
    • save_last_run:是否保存最后一个结果
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
import unittest
from test_project.lib.HTMLTestRunner import HTMLTestRunner

class TestDemo(unittest.TestCase):
"""测试用例说明"""
def test_success(self):
"""执行成功"""
self.assertEqual(2 + 3, 5)

@unittest.skip("skip case")
def test_skip(self):
pass

def test_fail(self):
self.assertEqual(5, 6)

def test_error(self):
self.assertEqual("a", 6)

class TestDemo2(unittest.TestCase):
def test_success(self):
self.assertEqual(4 + 5, 9)

class TestDemo3(unittest.TestCase):
def test_fail(self):
self.assertEqual(3, 9)


if __name__ == '__main__':
suit = unittest.TestSuite()
suit.addTest(TestDemo("test_success"))
suit.addTest(TestDemo("test_skip"))
suit.addTest(TestDemo("test_fail"))
suit.addTest(TestDemo("test_error"))
suit.addTest(TestDemo2("test_success"))
suit.addTest(TestDemo3("test_fail"))

with(open('./result.html', 'wb')) as fp:
runner = HTMLTestRunner(
stream=fp,
title='<TestDemo>-unittest测试框架报告',
description='第一个报告示例',
)
runner.run(suit, rerun=3, save_last_run=True)

我们运行后,在当前目录下就会生成一个 result.html 文件,将其以浏览器的方式打开,就能看到一份视图化的测试报告了。

3.2 BeautifulReport

项目地址:https://github.com/TesterlifeRaymond/BeautifulReport

仓库地址:https://www.cnpython.com/pypi/beautifulreport

BeautifulReport 是一个基于 unittest.TestReport 模块实现的测试用例模板,可以把我们每次测试中的结果通过 BeautifulReport 整合成一个可视化的报表。

安装

1
pip install BeautifulReport

案例

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
import os
import unittest
from BeautifulReport import BeautifulReport

class TestDemo(unittest.TestCase):
"""测试用例说明"""
def test_success(self):
"""执行成功"""
self.assertEqual(2 + 3, 5)

@unittest.skip("skip case")
def test_skip(self):
pass

def test_fail(self):
self.assertEqual(5, 6)

def test_error(self):
self.assertEqual("a", 6)

class TestDemo2(unittest.TestCase):
def test_success(self):
self.assertEqual(4 + 5, 9)

class TestDemo3(unittest.TestCase):
def test_fail(self):
self.assertEqual(3, 9)


if __name__ == '__main__':
suite = unittest.TestSuite()
suite.addTests(unittest.TestLoader().loadTestsFromTestCase(TestDemo))

path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'report')
if not os.path.exists(path):
os.mkdir(path)
runner = BeautifulReport(suite)
runner.report(filename='测试报告', description='生成BeautifulReport报告', log_path='report')