作者:yukkizhang,腾讯 CSIG 测试工程师
本文直接从常用的 Python 单元测试框架出发,分别对几种框架进行了简单的介绍和小结,然后介绍了 Mock 的框架,以及测试报告生成方式,并以具体代码示例进行说明,最后列举了一些常见问题。
一、常用 Python 单测框架
若你不想安装或不允许第三方库,那么 unittest
是最好也是唯一的选择。反之,pytest
无疑是最佳选择,众多 Python 开源项目(如大名鼎鼎的 requests)都是使用 pytest
作为单元测试框架。甚至,连 nose2
在官方文档上都建议大家使用 pytest
。我们知道,nose 已经进入了维护模式,取代者是 nose2。相比 nose2,pytest 的生态无疑更具优势,社区的活跃度也更高。
总体来说,unittest 用例格式复杂,兼容性无,插件少,二次开发方便。pytest 更加方便快捷,用例格式简单,可以执行 unittest 风格的测试用例,较好的兼容性,插件丰富。
二、unittest
1. 基本概念
unittest 中最核心的四个概念是:**test fixture、test case、test suite、test runner **。
test fixture:表示执行一个或多个测试所需的准备,以及任何关联的清理操作。例如这可能涉及创建临时或代理数据库、目录或启动服务器进程。
test case:测试用例是最小的测试单元。它检查特定的输入集的响应。单元测试提供了一个基类测试用例,可用于创建新的测试用例。
test suite:测试套件是测试用例、测试套件或两者的集合,用于归档需要一起执行的测试。
test runner:是一个用于执行和输出结果的组件。这个运行器可能使用图形接口、文本接口,或返回一个特定的值表示运行测试的结果。
2. 编写规则
编写单元测试时,我们需要编写一个测试类,从
unittest.TestCase
继承。以
test
开头的方法就是测试方法,不以test
开头的方法不被认为是测试方法,测试的时候不会被执行。对每一类测试都需要编写一个
test_xxx()
方法。
3. 简单示例
3.1 目录结构
$ tree .
.
├── README.md
├── requirements.txt
└── src
├── demo
│ └── calculator.py
└── tests
└── demo
├── __init__.py
├── test_calculator_unittest.py
└── test_calculator_unittest_with_fixture.py
3.2 计算器实现代码
class Calculator:
def add(self, a, b):
return a + b
def sub(self, a, b):
return a - b
def mul(self, a, b):
return a * b
def div(self, a, b):
return a / b
3.3 计算器测试代码
import unittest
from src.demo.calculator import Calculator
class TestCalculator(unittest.TestCase):
def test_add(self):
c = Calculator()
result = c.add(3, 5)
self.assertEqual(result, 8)
def test_sub(self):
c = Calculator()
result = c.sub(10, 5)
self.assertEqual(result, 5)
def test_mul(self):
c = Calculator()
result = c.mul(5, 7)
self.assertEqual(result, 35)
def test_div(self):
c = Calculator()
result = c.div(10, 5)
self.assertEqual(result, 2)
if __name__ == '__main__':
unittest.main()
3.4 执行结果
Ran 4 tests in 0.002s
OK
4. 用例前置和后置
基于 unittest 的四个概念的理解,上述简单用例,可以修改为:
import unittest
from src.demo.calculator import Calculator
class TestCalculatorWithFixture(unittest.TestCase):
# 测试用例前置动作
def setUp(self):
print("test start")
# 测试用例后置动作
def tearDown(self):
print("test end")
def test_add(self):
c = Calculator()
result = c.add(3, 5)
self.assertEqual(result, 8)
def test_sub(self):
c = Calculator()
result = c.sub(10, 5)
self.assertEqual(result, 5)
def test_mul(self):
c = Calculator()
result = c.mul(5, 7)
self.assertEqual(result, 35)
def test_div(self):
c = Calculator()
result = c.div(10, 5)
self.assertEqual(result, 2)
if __name__ == '__main__':
# 创建测试套件
suit = unittest.TestSuite()
suit.addTest(TestCalculatorWithFixture("test_add"))
suit.addTest(TestCalculatorWithFixture("test_sub"))
suit.addTest(TestCalculatorWithFixture("test_mul"))
suit.addTest(TestCalculatorWithFixture("test_div"))
# 创建测试运行器
runner = unittest.TestRunner()
runner.run(suit)
5. 参数化
标准库的 unittest 自身不支持参数化测试,可以通过第三方库来支持:parameterized 和 ddt。
其中 parameterized 只需要一个装饰器@parameterized.expand
,ddt 需要三个装饰器@ddt、@data、@unpack
,它们生成的 test 分别有一个名字,ddt 会携带具体的参数信息。
5.1 parameterized
import unittest
from parameterized import parameterized, param
from src.demo.calculator import Calculator
class TestCalculator(unittest.TestCase):
@parameterized.expand([
param(3, 5, 8),
param(1, 2, 3),
param(2, 2, 4)
])
def test_add(self, num1, num2, total):
c = Calculator()
result = c.add(num1, num2)
self.assertEqual(result, total)
if __name__ == '__main__':
unittest.main()
执行结果:
test_add_0 (__main__.TestCalculator) ... ok
test_add_1 (__main__.TestCalculator) ... ok
test_add_2 (__main__.TestCalculator) ... ok
----------------------------------------------------------------------
Ran 3 tests in 0.000s
OK
5.2 ddt
import unittest
from ddt import data, unpack, ddt
from src.demo.calculator import Calculator
@ddt
class TestCalculator(unittest.TestCase):
@data((3, 5, 8),(1, 2, 3),(2, 2, 4))
@unpack
def test_add(self, num1, num2, total):
c = Calculator()
result = c.add(num1, num2)
self.assertEqual(result, total)
if __name__ == '__main__':
unittest.main()
执行结果:
test_add_1__3__5__8_ (__main__.TestCalculator) ... ok
test_add_2__1__2__3_ (__main__.TestCalculator) ... ok
test_add_3__2__2__4_ (__main__.TestCalculator) ... ok
----------------------------------------------------------------------
Ran 3 tests in 0.000s
OK
6. 断言
unittest 提供了丰富的断言,常用的包括:
assertEqual、assertNotEqual、assertTrue、assertFalse、assertIn、assertNotIn 等。
具体可以直接看源码提供的方法:
三、nose
nose 已经进入维护模式,从github nose上可以看到,nose 最近的一次代码提交还是在 2016 年 5 月 4 日。
继承 nose 的是 nose2,但要注意的是,nose2 并不支持 nose 的全部功能,它们的区别可以看这里。nose2 的主要目的是扩展 Python 的标准单元测试库 unittest,因此它的定位是“带插件的 unittest”。nose2 提供的插件,例如测试用例加载器,覆盖度报告生成器,并行测试等内置插件和第三方插件,让单元测试变得更加完善。
nose2 的社区没有 pytest 的活跃,要使用高级框架,推荐使用 pytest,因此下文不做过多详述。
1. 编写规则
nose2 的测试用例并不限制于类,也可以直接使用函数。
任何函数和类,只要名称匹配一定的条件(例如,以 test 开头或以 test 结尾等),都会被自动识别为测试用例;
为了兼容 unittest, 所有的基于 unitest 编写的测试用例,也会被 nose 自动识别为。
2. 简单示例
2.1 计算器代码
参考 unittest 的计算器代码部分。
2.2 计算器测试代码
import nose2
from src.demo.calculator import Calculator
def test_add():
c = Calculator()
result = c.add(3, 5)
assert result == 8
def test_sub():
c = Calculator()
result = c.sub(10, 5)
assert result == 5
def test_mul():
c = Calculator()
result = c.mul(5, 7)
assert result == 35
def test_div():
c = Calculator()
result = c.div(10, 5)
assert result == 2
if __name__ == '__main__':
nose2.main()
2.3 执行结果
....
----------------------------------------------------------------------
Ran 4 tests in 0.000s
OK
3. 参数化
import nose2
from nose2.tools import params
from src.demo.calculator import Calculator
test_data = [
{"nums": (3, 5), "total": 8},
{"nums": (1, 2), "total": 3},
{"nums": (2, 2), "total": 4}
]
@params(*test_data)
def test_add(data):
c = Calculator()
result = c.add(*data['nums'])
assert result == data['total']
if __name__ == '__main__':
nose2.main()
四、pytest
1. 编写规则
测试文件以 test_开头(以 test 结尾也可以)
测试类以 Test 开头,并且不能带有 init 方法
测试函数以 test_开头
断言使用基本的 assert 即可
可以通过下面的命令,查看 Pytest 收集到哪些测试用例:
$ py.test --collect-only