Anthony Shaw
Python 中的测试是一个庞大的主题,可能涉及很多复杂性,但其实并不一定很难。你只需几个简单的步骤就可以为自己的应用程序编写基础测试,然后在此基础上逐步深入。
本教程适用于那些已经用 Python 编写了出色应用,但尚未编写任何测试的人。
在本教程中,你将学习如何创建基本测试、执行测试,并在用户发现问题之前就找出 bug!你还将了解可用于编写和执行测试的工具、检查应用程序性能,甚至查找安全问题的方法。
测试你的代码
测试代码的方式多种多样。本教程将从最基础的步骤开始,逐步引导你掌握更高级的方法。
自动化测试 vs. 手动测试
好消息是,你可能已经在不知不觉中创建过测试了。还记得你第一次运行应用程序并使用它的时候吗?你是否检查了各项功能并尝试使用它们?这被称为探索性测试(exploratory testing),是一种手动测试形式。
探索性测试是一种没有计划的测试方式。在探索性测试中,你只是随意地使用应用程序。
要拥有一套完整的手动测试,你只需列出应用程序的所有功能、它可以接受的不同输入类型以及预期的结果。之后,每次修改代码时,你都需要逐一检查列表中的每一项。
听起来不太有趣,对吧?
这就是自动化测试发挥作用的地方。自动化测试是指通过脚本而非人工来执行你的测试计划(即你想测试的应用程序部分、测试顺序以及预期响应)。Python 自带了一整套工具和库,可帮助你为应用程序创建自动化测试。本教程将带你探索这些工具和库。
单元测试 vs. 集成测试
测试领域术语繁多。既然你已经了解了自动化测试与手动测试的区别,现在让我们再深入一层。
想象一下,你如何测试一辆汽车的车灯?你会打开车灯(称为测试步骤),然后走到车外或请朋友确认车灯是否亮起(称为测试断言)。这种对多个组件进行的测试称为集成测试(integration testing)。
想想看,一个简单任务要得到正确结果,需要多少个组件协同工作。这些组件就像你应用程序中的各个部分——你编写的类、函数和模块。
集成测试的一大挑战在于:当测试结果不正确时,很难诊断出系统中哪个部分出了问题。如果车灯没亮,可能是灯泡坏了?电池没电了?还是交流发电机故障?又或是车载电脑出问题了?
如果你有一辆现代智能汽车,它会自动告诉你灯泡是否烧坏。这是通过一种**单元测试(unit test)**实现的。
单元测试是一种更小粒度的测试,用于验证单个组件是否按预期工作。单元测试能帮助你隔离应用程序中的故障点,并更快地修复问题。
你刚刚接触了两种测试类型:
- 集成测试:检查应用程序中的多个组件是否能协同工作。
- 单元测试:检查应用程序中的某个小型组件。
你可以在 Python 中编写这两种测试。例如,为内置函数 sum() 编写单元测试时,你可以将 sum() 的输出与已知结果进行比较。
比如,你可以这样验证 sum([1, 2, 3]) 是否等于 6:
>>> assert sum([1, 2, 3]) == 6, "Should be 6"
由于值是正确的,REPL(交互式解释器)不会输出任何内容。
如果 sum() 的结果不正确,就会抛出 AssertionError 并显示消息 "Should be 6"。你可以尝试用错误的值再次执行断言语句,看看 AssertionError:
>>> assert sum([1, 1, 1]) == 6, "Should be 6"
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AssertionError: Should be 6
在 REPL 中,你会看到因 sum() 结果与 6 不匹配而引发的 AssertionError。
与其在 REPL 中测试,不如将这段代码放入一个名为 test_sum.py 的新 Python 文件中并再次执行:
def test_sum():
assert sum([1, 2, 3]) == 6, "Should be 6"
if __name__ == "__main__":
test_sum()
print("Everything passed")
现在你已经编写了一个测试用例、一条断言和一个入口点(命令行)。你可以在命令行执行它:
$ python test_sum.py
Everything passed
你会看到成功的结果:“Everything passed”。
在 Python 中,sum() 接受任何可迭代对象作为第一个参数。你刚才用列表进行了测试,现在也用元组测试一下。创建一个名为 test_sum_2.py 的新文件,内容如下:
def test_sum():
assert sum([1, 2, 3]) == 6, "Should be 6"
def test_sum_tuple():
assert sum((1, 2, 2)) == 6, "Should be 6"
if __name__ == "__main__":
test_sum()
test_sum_tuple()
print("Everything passed")
当你执行 test_sum_2.py 时,脚本会报错,因为 (1, 2, 2) 的 sum() 结果是 5,不是 6。脚本会输出错误信息、代码行号和 traceback:
$ python test_sum_2.py
Traceback (most recent call last):
File "test_sum_2.py", line 9, in <module>
test_sum_tuple()
File "test_sum_2.py", line 5, in test_sum_tuple
assert sum((1, 2, 2)) == 6, "Should be 6"
AssertionError: Should be 6
这里你可以看到,代码中的错误会在控制台显示错误信息,并提供错误位置和预期结果等信息。
这种方式编写测试对于简单检查来说是可以接受的,但如果多个测试失败怎么办?这时就需要**测试运行器(test runner)**了。测试运行器是一种专门用于运行测试、检查输出并提供调试和诊断工具的应用程序。
选择 Python 测试运行器
Python 有多种测试运行器可供选择。Python 标准库自带的测试运行器叫作 unittest。本教程将使用 unittest 测试用例和 unittest 测试运行器。unittest 的原则很容易迁移到其他框架。三个最流行的测试运行器是:
unittestnose或nose2pytest
根据你的需求和经验水平选择最适合的测试运行器非常重要。
使用 unittest 运行测试
unittest 自 Python 2.1 起就内置于标准库中。你可能会在商业 Python 应用和开源项目中看到它。
unittest 既包含测试框架,也包含测试运行器。使用 unittest 编写和执行测试有一些重要要求:
- 你需要将测试放入类的方法中
- 你需要使用
unittest.TestCase类中的一系列特殊断言方法,而不是内置的assert语句
要将前面的例子转换为 unittest 测试用例,你需要:
- 从标准库导入
unittest - 创建一个名为
TestSum的类,继承自TestCase类 - 将测试函数转换为方法,添加
self作为第一个参数 - 将断言改为使用
TestCase类的self.assertEqual()方法 - 将命令行入口点改为调用
unittest.main()
按照这些步骤,创建一个名为 test_sum_unittest.py 的新文件,内容如下:
import unittest
class TestSum(unittest.TestCase):
def test_sum(self):
self.assertEqual(sum([1, 2, 3]), 6, "Should be 6")
def test_sum_tuple(self):
self.assertEqual(sum((1, 2, 2)), 6, "Should be 6")
if __name__ == "__main__":
unittest.main()
注意:上面代码中
if __name__ == "__main_":应该是if __name__ == "__main__":(两个下划线),原文存在笔误。
在命令行执行它,你会看到一个成功(用 . 表示)和一个失败(用 F 表示):
$ python test_sum_unittest.py
.F
======================================================================
FAIL: test_sum_tuple (__main__.TestSum)
----------------------------------------------------------------------
Traceback (most recent call last):
File "test_sum_unittest.py", line 9, in test_sum_tuple
self.assertEqual(sum((1, 2, 2)), 6, "Should be 6")
AssertionError: Should be 6
----------------------------------------------------------------------
Ran 2 tests in 0.001s
FAILED (failures=1)
你刚刚使用 unittest 测试运行器执行了两个测试。
注意:如果你编写的测试用例需要在 Python 2 和 3 中都能执行,请小心处理。在 Python 2.7 及以下版本中,
unittest被称为unittest2。如果直接从unittest导入,在 Python 2 和 3 中会得到不同版本,功能也有所不同。
使用 nose 运行测试
随着时间推移,当你为应用程序编写数百甚至数千个测试时,可能会发现 unittest 的输出越来越难以理解和使用。
nose 与使用 unittest 框架编写的任何测试都兼容,可以作为 unittest 测试运行器的直接替代品。nose 作为一个开源应用,开发进度已经落后,因此创建了一个名为 nose2 的分支。如果你从头开始,建议使用 nose2 而不是 nose。
要开始使用 nose2,请从 PyPI 安装 nose2 并在命令行执行:
$ pip install nose2
$ python -m nose2
nose2 会尝试在当前目录中发现所有名为 test*.py 的测试脚本,以及继承自 unittest.TestCase 的测试用例:
$ python -m nose2
.F
======================================================================
FAIL: test_sum_tuple (__main__.TestSum)
----------------------------------------------------------------------
Traceback (most recent call last):
File "test_sum_unittest.py", line 9, in test_sum_tuple
self.assertEqual(sum((1, 2, 2)), 6, "Should be 6")
AssertionError: Should be 6
----------------------------------------------------------------------
Ran 2 tests in 0.001s
FAILED (failures=1)
你刚刚使用 nose2 测试运行器执行了在 test_sum_unittest.py 中创建的测试。nose2 提供了许多命令行标志,用于过滤要执行的测试。
使用 pytest 运行测试
pytest 支持执行 unittest 测试用例。使用 pytest 的真正优势在于编写 pytest 测试用例。pytest 测试用例是 Python 文件中以 test_ 开头的一系列函数。
pytest 还有一些其他优秀特性:
- 支持使用内置的
assert语句,而不是特殊的self.assert*()方法 - 支持测试用例过滤
- 能够从上次失败的测试重新运行
- 拥有数百个插件的生态系统,可扩展功能
为 pytest 编写 TestSum 测试用例示例如下:
def test_sum():
assert sum([1, 2, 3]) == 6, "Should be 6"
def test_sum_tuple():
assert sum((1, 2, 2)) == 6, "Should be 6"
你已经去掉了 TestCase、所有类的使用以及命令行入口点。
编写你的第一个 Python 测试
让我们整合一下目前所学的内容,不再测试内置的 sum() 函数,而是测试一个简单实现的相同需求。
创建一个新的项目文件夹,在其中创建一个名为 my_sum 的文件夹。在 my_sum 内创建一个空文件 __init__.py。创建 __init__.py 文件意味着 my_sum 文件夹可以从父目录作为模块导入。
你的项目文件夹结构应该如下:
project/
│
└── my_sum/
└── __init__.py
打开 my_sum/__init__.py,创建一个名为 sum() 的新函数,它接受一个可迭代对象(列表、元组或集合)并将值相加:
def sum(arg):
total = 0
for val in arg:
total += val
return total
这段代码创建了一个名为 total 的变量,遍历 arg 中的所有值并将它们加到 total 中。遍历完成后返回结果。
在哪里编写测试
要开始编写测试,你可以简单地创建一个名为 test.py 的文件,其中包含你的第一个测试用例。由于该文件需要能够导入你的应用程序来进行测试,你应该将 test.py 放在包文件夹的上一级,因此你的目录树看起来像这样:
project/
│
├── my_sum/
│ └── __init__.py
|
└── test.py
你会发现,随着添加越来越多的测试,单个文件会变得杂乱且难以维护,因此可以创建一个名为 tests/ 的文件夹,并将测试拆分到多个文件中。按照惯例,确保每个文件都以 test_ 开头,这样所有测试运行器都会假定该 Python 文件包含要执行的测试。一些大型项目会根据用途或使用方式将测试进一步拆分到更多子目录中。
注意:如果你的应用程序是单个脚本怎么办?
你可以使用内置的
__import__()函数导入脚本的任何属性,如类、函数和变量。例如,不用from my_sum import sum,你可以这样写:target = __import__("my_sum.py") sum = target.sum使用
__import__()的好处是不需要将项目文件夹变成包,还可以指定文件名。如果文件名与标准库包冲突(例如math.py会与math模块冲突),这种方法也很有用。
如何构建一个简单的 Python 测试
在开始编写测试之前,你需要先做几个决定:
- 你要测试什么?
- 你是在编写单元测试还是集成测试?
然后,测试的结构应大致遵循以下工作流程:
- 创建输入
- 执行被测试的代码,捕获输出
- 将输出与预期结果进行比较
对于这个应用程序,你要测试 sum()。你可以检查 sum() 的许多行为,例如:
- 能否对整数列表求和?
- 能否对元组或集合求和?
- 能否对浮点数列表求和?
- 当提供错误值(如单个整数或字符串)时会发生什么?
- 当其中一个值为负数时会发生什么?
最简单的测试是对整数列表求和。创建一个名为 test.py 的文件,包含以下 Python 代码:
import unittest
from my_sum import sum
class TestSum(unittest.TestCase):
def test_list_int(self):
"""
Test that it can sum a list of integers
"""
data = [1, 2, 3]
result = sum(data)
self.assertEqual(result, 6)
if __name__ == "__main__":
unittest.main()
注意:同样,这里应该是
if __name__ == "__main__":(两个下划线)。
这段代码:
- 从你创建的
my_sum包中导入sum() - 定义一个名为
TestSum的新测试用例类,继承自unittest.TestCase - 定义一个测试方法
.test_list_int()来测试整数列表。该方法将:- 声明一个包含数字
[1, 2, 3]的变量data - 将
my_sum.sum(data)的结果赋给变量result - 使用
unittest.TestCase类的.assertEqual()方法断言result的值等于 6
- 声明一个包含数字
- 定义一个命令行入口点,运行
unittest测试运行器.main()
如何在 Python 中编写断言
编写测试的最后一步是将输出与已知响应进行验证。这被称为断言(assertion)。编写断言时有一些通用的最佳实践:
- 确保测试是可重复的,并多次运行测试以确保每次结果相同
- 尽量断言与输入数据相关的结果,例如在
sum()示例中检查结果是否确实是值的总和
unittest 提供了许多方法来断言变量的值、类型和存在性。以下是最常用的一些方法:
| 方法 | 等价于 |
|---|---|
.assertEqual(a, b) |
a == b |
.assertTrue(x) |
bool(x) is True |
.assertFalse(x) |
bool(x) is False |
.assertIs(a, b) |
a is b |
.assertIsNone(x) |
x is None |
.assertIn(a, b) |
a in b |
.assertIsInstance(a, b) |
isinstance(a, b) |
.assertIs()、.assertIsNone()、.assertIn() 和 .assertIsInstance() 都有对应的反向方法,如 .assertIsNot() 等。
副作用(Side Effects)
编写测试时,通常不仅仅是查看函数的返回值那么简单。通常,执行一段代码会改变环境中的其他东西,比如类的属性、文件系统上的文件或数据库中的值。这些被称为副作用,是测试的重要组成部分。在将副作用包含在断言列表中之前,请先确定是否要测试该副作用。
如果你发现要测试的代码单元有很多副作用,可能违反了单一职责原则(Single Responsibility Principle)。违反单一职责原则意味着这段代码做了太多事情,最好进行重构。遵循单一职责原则是设计易于编写可重复、简单单元测试的代码的好方法,最终也能构建出可靠的应用程序。
执行你的第一个 Python 测试
现在你已经创建了第一个测试,想要执行它。当然,你知道它会通过,但在创建更复杂的测试之前,你应该先确认能够成功执行测试。
执行 Python 测试运行器
执行测试代码、检查断言并在控制台给出测试结果的 Python 应用程序称为测试运行器。
在 test.py 的底部,你添加了这段小代码:
if __name__ == "__main__":
unittest.main()
这是一个命令行入口点。这意味着如果你单独执行脚本(在命令行运行 python test.py),它会调用 unittest.main()。这会通过发现此文件中所有继承自 unittest.TestCase 的类来执行测试运行器。
这是执行 unittest 测试运行器的多种方式之一。当你的单个测试文件名为 test.py 时,调用 python test.py 是很好的入门方式。
另一种方式是使用 unittest 命令行。试试这个:
$ python -m unittest test
这将通过命令行执行相同的测试模块(名为 test)。
你可以提供额外选项来更改输出。其中之一是 -v(详细模式)。接下来试试:
$ python -m unittest -v test
test_list_int (test.TestSum) ... ok
----------------------------------------------------------------------
Ran 1 tests in 0.000s
这执行了 test.py 中的一个测试,并将结果显示在控制台。详细模式首先列出了执行的测试名称以及每个测试的结果。
除了提供包含测试的模块名称,你还可以请求自动发现:
$ python -m unittest discover
这将在当前目录中搜索所有名为 test*.py 的文件并尝试测试它们。
一旦你有多个测试文件,只要遵循 test*.py 命名模式,就可以使用 -s 标志和目录名称来提供目录名称:
$ python -m unittest discover -s tests
unittest 将在一个测试计划中运行所有测试并给出结果。
最后,如果你的源代码不在根目录而是在子目录中(例如在名为 src/ 的文件夹中),你可以告诉 unittest 在哪里执行测试,以便正确导入模块,使用 -t 标志:
$ python -m unittest discover -s tests -t src
unittest 将切换到 src/ 目录,扫描 tests 目录内的所有 test*.py 文件并执行它们。
理解测试输出
这是一个非常简单的例子,所有测试都通过了,现在你将尝试一个失败的测试并解释输出。
sum() 应该能够接受其他数值类型的列表,比如分数。
在 test.py 文件顶部,添加一个导入语句,从标准库的 fractions 模块导入 Fraction 类型:
from fractions import Fraction
现在添加一个期望错误值的测试,这里期望 1/4、1/4 和 2/5 的和为 1:
import unittest
from fractions import Fraction
from my_sum import sum
class TestSum(unittest.TestCase):
def test_list_int(self):
"""
Test that it can sum a list of integers
"""
data = [1, 2, 3]
result = sum(data)
self.assertEqual(result, 6)
def test_list_fraction(self):
"""
Test that it can sum a list of fractions
"""
data = [Fraction(1, 4), Fraction(1, 4), Fraction(2, 5)]
result = sum(data)
self.assertEqual(result, 1)
if __name__ == "__main__":
unittest.main()
注意:同样,这里应该是
if __name__ == "__main__":(两个下划线)。
如果再次使用 python -m unittest test 执行测试,你应该看到以下输出:
$ python -m unittest test
F.
======================================================================
FAIL: test_list_fraction (test.TestSum)
----------------------------------------------------------------------
Traceback (most recent call last):
File "test.py", line 21, in test_list_fraction
self.assertEqual(result, 1)
AssertionError: Fraction(9, 10) != 1
----------------------------------------------------------------------
Ran 2 tests in 0.001s
FAILED (failures=1)
在输出中,你会看到以下信息:
- 第一行显示所有测试的执行结果,一个失败(F)和一个通过(.)
- FAIL 条目显示了失败测试的一些详细信息:
- 测试方法名称(
test_list_fraction) - 测试模块(
test)和测试用例(TestSum) - 指向失败行的 traceback
- 断言的详细信息,包括预期结果(1)和实际结果(
Fraction(9, 10))
- 测试方法名称(
记住,你可以通过在 python -m unittest 命令中添加 -v 标志来向测试输出添加额外信息。
为 Django 和 Flask 等 Web 框架编写测试
如果你使用 Django 或 Flask 等流行框架为 Web 应用程序编写测试,编写和运行测试的方式会有一些重要差异。
为什么它们与其他应用程序不同
想想你要在 Web 应用程序中测试的所有代码。路由、视图和模型都需要大量导入和对所用框架的了解。
这类似于教程开头的汽车测试:你需要先启动汽车的电脑,然后才能运行像检查车灯这样的简单测试。
Django 和 Flask 都通过提供基于 unittest 的测试框架使这变得简单。你可以继续以你学到的方式编写测试,但执行方式略有不同。
如何使用 Django 测试运行器
Django 的 startapp 模板会在你的应用程序目录中创建一个 tests.py 文件。如果你还没有这个文件,可以创建它,内容如下:
from django.test import TestCase
class MyTestCase(TestCase):
# Your test methods
与之前示例的主要区别在于,你需要继承 django.test.TestCase 而不是 unittest.TestCase。这些类具有相同的 API,但 Django 的 TestCase 类会设置测试所需的所有状态。
要执行测试套件,不要在命令行使用 unittest,而是使用 manage.py test:
$ python manage.py test
如果你想要多个测试文件,可以用名为 tests 的文件夹替换 tests.py,在其中插入一个空的 __init__.py 文件,并创建你的 test_*.py 文件。Django 会自动发现并执行这些测试。
如何在 Flask 中使用 unittest
Flask 要求导入应用程序并将其设置为测试模式。你可以实例化一个测试客户端,并使用该测试客户端向应用程序中的任何路由发出请求。
所有测试客户端实例化都在测试用例的 setUp 方法中完成。在下面的示例中,my_app 是应用程序的名称。如果你不知道 setUp 是做什么的,不用担心,你将在“更高级的测试场景”部分学到。
测试文件中的代码应该如下所示:
import my_app
import unittest
class MyTestCase(unittest.TestCase):
def setUp(self):
my_app.app.testing = True
self.app = my_app.app.test_client()
def test_home(self):
result = self.app.get("/")
# Make your assertions
然后你可以使用 python -m unittest discover 命令执行测试用例。
更高级的 Python 测试场景
在开始为应用程序创建测试之前,请记住每个测试的三个基本步骤:
- 创建输入
- 执行代码,捕获输出
- 将输出与预期结果进行比较
创建静态输入值(如字符串或数字)并不总是那么容易。有时,你的应用程序需要类的实例或上下文。那时你该怎么办?
你创建的输入数据被称为夹具(fixture)。创建夹具并重复使用它们是常见的做法。
如果你运行相同的测试但每次都传递不同的值并期望相同的结果,这被称为参数化(parameterization)。
处理预期的失败
早些时候,当你列出要测试 sum() 的场景时,出现了一个问题:当你提供错误值(如单个整数或字符串)时会发生什么?
在这种情况下,你期望 sum() 抛出错误。当它抛出错误时,会导致测试失败。
有一种特殊的方式来处理预期的错误。你可以使用 .assertRaises() 作为上下文管理器,然后在 with 块内执行测试步骤:
import unittest
from fractions import Fraction
from my_sum import sum
class TestSum(unittest.TestCase):
def test_list_int(self):
"""
Test that it can sum a list of integers
"""
data = [1, 2, 3]
result = sum(data)
self.assertEqual(result, 6)
def test_list_fraction(self):
"""
Test that it can sum a list of fractions
"""
data = [Fraction(1, 4), Fraction(1, 4), Fraction(2, 5)]
result = sum(data)
self.assertEqual(result, 1)
def test_bad_type(self):
data = "banana"
with self.assertRaises(TypeError):
result = sum(data)
if __name__ == "__main__":
unittest.main()
注意:同样,这里应该是
if __name__ == "__main__":(两个下划线)。
这个测试用例只有在 sum(data) 抛出 TypeError 时才会通过。你可以将 TypeError 替换为你选择的任何异常类型。
隔离应用程序中的行为
在教程前面,你学习了什么是副作用。副作用使单元测试变得更困难,因为每次运行测试时可能会得到不同的结果,甚至更糟的是,一个测试可能会影响应用程序的状态并导致另一个测试失败。
有一些简单的技术可以用来测试具有许多副作用的应用程序部分:
- 重构代码以遵循单一职责原则
- 模拟(mocking)任何方法或函数调用以消除副作用
- 对应用程序的这部分使用集成测试而不是单元测试
在 Python 中编写集成测试
到目前为止,你主要学习的是单元测试。单元测试是构建可预测和稳定代码的好方法。但归根结底,你的应用程序需要在启动时正常工作!
集成测试是测试应用程序的多个组件,以检查它们是否能协同工作。集成测试可能需要像应用程序的消费者或用户一样:
- 调用 HTTP REST API
- 调用 Python API
- 调用 Web 服务
- 运行命令行
这些类型的集成测试可以像单元测试一样编写,遵循输入、执行和断言的模式。最主要的区别是集成测试一次检查更多的组件,因此比单元测试有更多的副作用。此外,集成测试需要更多的夹具,比如数据库、网络套接字或配置文件。
这就是为什么将单元测试和集成测试分开是很好的做法。集成测试所需的夹具(如测试数据库)和测试用例本身通常比单元测试花费更长的时间执行。因此,你可能只想在推送到生产环境之前运行集成测试,而不是在每次提交时都运行。
一个简单的分离单元测试和集成测试的方法就是将它们放在不同的文件夹中:
project/
│
├── my_app/
│ └── __init__.py
│
└── tests/
|
├── unit/
| ├── __init__.py
| └── test_sum.py
|
└── integration/
├── __init__.py
└── test_integration.py
有许多方法可以只执行选定的一组测试。可以将指定源目录的标志 -s 添加到 unittest discover 中,后跟包含测试的路径:
$ python -m unittest discover -s tests/integration
unittest 将给出 tests/integration 目录中所有测试的结果。
测试数据驱动的应用程序
许多集成测试需要后端数据(如数据库)以特定值存在。例如,你可能想要一个测试来检查当数据库中有超过 100 个客户时,应用程序是否正确显示,或者即使产品名称以日文显示,订单页面也能正常工作。
这些类型的集成测试将依赖不同的测试夹具来确保它们是可重复和可预测的。
一个很好的技术是在集成测试文件夹内创建一个名为 fixtures 的文件夹来存储测试数据,以表明它包含测试数据。然后,在测试中,你可以加载数据并针对该测试数据运行测试。
如果数据由 JSON 文件组成,结构如下所示:
project/
│
├── my_app/
│ └── __init__.py
│
└── tests/
|
└── unit/
| ├── __init__.py
| └── test_sum.py
|
└── integration/
|
├── fixtures/
| ├── test_basic.json
| └── test_complex.json
|
├── __init__.py
└── test_integration.py
在测试用例中,你可以使用 .setUp() 方法从已知路径的夹具文件加载测试数据,并针对该测试数据执行多个测试。记住,单个 Python 文件中可以有多个测试用例,unittest 发现机制会执行两者。你可以为每组测试数据设置一个测试用例:
import unittest
class TestBasic(unittest.TestCase):
def setUp(self):
# Load test data
self.app = App(database="fixtures/test_basic.json")
def test_customer_count(self):
self.assertEqual(len(self.app.customers), 100)
def test_existence_of_customer(self):
customer = self.app.get_customer(id=10)
self.assertEqual(customer.name, "Org XYZ")
self.assertEqual(customer.address, "10 Red Road, Reading")
class TestComplexData(unittest.TestCase):
def setUp(self):
# Load test data
self.app = App(database="fixtures/test_complex.json")
def test_customer_count(self):
self.assertEqual(len(self.app.customers), 10000)
def test_existence_of_customer(self):
customer = self.app.get_customer(id=9999)
self.assertEqual(customer.name, u"バナナ")
self.assertEqual(customer.address, "10 Red Road, Akihabara, Tokyo")
if __name__ == "__main__":
unittest.main()
注意:同样,这里应该是
if __name__ == "__main__":(两个下划线)。
如果你的应用程序依赖远程位置的数据(如远程 API),你需要确保测试是可重复的。因为 API 离线或连接问题导致测试失败可能会拖慢开发进度。在这种情况下,最佳做法是将远程夹具本地存储,以便可以调用并发送给应用程序。
requests 库有一个配套包叫 responses,它提供了创建响应夹具并将其保存在测试文件夹中的方法。
在多个环境中进行 Python 测试
到目前为止,你一直在使用具有特定依赖集的虚拟环境中针对单个 Python 版本进行测试。你可能希望检查你的应用程序是否能在多个 Python 版本或多个包版本上工作。Tox 是一个自动化在多个环境中测试的应用程序。
安装 Tox
Tox 在 PyPI 上作为包提供,可以通过 pip 安装:
$ pip install tox
现在你已经安装了 Tox,需要进行配置。
为你的依赖配置 Tox
Tox 通过项目目录中的配置文件进行配置。Tox 配置文件包含以下内容:
- 用于执行测试的命令
- 执行前需要的任何额外包
- 要测试的目标 Python 版本
与其学习 Tox 配置语法,不如运行快速启动应用程序来获得一个良好的开端:
$ tox-quickstart
Tox 配置工具会询问你这些问题,并在 tox.ini 中创建一个类似以下的文件:
[tox]
envlist = py27, py36
[testenv]
deps =
commands =
python -m unittest discover
在运行 Tox 之前,它要求你的应用程序文件夹中有一个 setup.py 文件,其中包含安装包的步骤。
或者,如果你的项目不打算发布到 PyPI,可以通过在 tox.ini 文件的 [tox] 标题下添加以下行来跳过此要求:
[tox]
envlist = py27, py36
skipsdist=True
如果你没有创建 setup.py,并且你的应用程序有一些来自 PyPI 的依赖项,你需要在 [testenv] 部分下的多行中指定这些依赖项。例如,Django 需要以下内容:
[testenv]
deps = django
完成此阶段后,你就可以运行测试了。
你现在可以执行 Tox,它将创建两个虚拟环境:一个用于 Python 2.7,另一个用于 Python 3.6。Tox 目录名为 .tox/。在 .tox/ 目录中,Tox 将针对每个虚拟环境执行 python -m unittest discover。
你可以通过在命令行调用 Tox 来运行此过程:
$ tox
Tox 将输出你的测试在每个环境中的结果。第一次运行时,Tox 需要一点时间来创建虚拟环境,但一旦创建完成,第二次执行就会快得多。
执行 Tox
Tox 的输出非常直观。它为每个版本创建环境,安装你的依赖项,然后运行测试命令。
有一些额外的命令行选项值得记住:
仅运行单个环境,如 Python 3.6:
$ tox -e py36
重新创建虚拟环境(当你的依赖项发生变化或 site-packages/ 损坏时):
$ tox -r
以较少的详细输出运行 Tox:
$ tox -q
以更详细的输出运行 Tox:
$ tox -v
自动化测试执行
到目前为止,你一直通过运行命令手动执行测试。有一些工具可以在你进行更改并提交到 Git 等源代码控制仓库时自动执行测试。自动化测试工具通常被称为 CI/CD 工具,代表“持续集成/持续部署”。它们可以运行你的测试、编译和发布任何应用程序,甚至将它们部署到生产环境。
Travis CI 是众多可用的 CI(持续集成)服务之一。
Travis CI 与 Python 配合得很好,既然你已经创建了所有这些测试,就可以在云端自动执行它们!Travis CI 对 GitHub 和 GitLab 上的任何开源项目都是免费的,对私有项目则收费。
要开始使用,请登录网站并使用你的 GitHub 或 GitLab 凭据进行身份验证。然后创建一个名为 .travis.yml 的文件,内容如下:
language: python
python:
- "2.7"
- "3.7"
install:
- pip install -r requirements.txt
script:
- python -m unittest discover
此配置指示 Travis CI:
- 针对 Python 2.7 和 3.7 进行测试(你可以将这些版本替换为你选择的任何版本)
- 安装你在
requirements.txt中列出的所有包(如果没有依赖项,可以删除此部分) - 运行
python -m unittest discover来运行测试
一旦你提交并推送此文件,Travis CI 将在每次推送到远程 Git 仓库时运行这些命令。你可以在他们的网站上查看结果。
后续步骤
现在你已经学会了如何创建测试、执行测试、将它们包含在项目中,甚至自动执行它们,随着测试库的增长,还有一些高级技术可能会派上用场。
在应用程序中引入 Linter
Tox 和 Travis CI 都有测试命令的配置。你在本教程中一直使用的测试命令是 python -m unittest discover。
你可以在所有这些工具中提供一个或多个命令,此选项的存在是为了让你能够添加更多提高应用程序质量的工具。
其中一种应用程序称为 linter(代码检查工具)。Linter 会查看你的代码并对其进行评论。它可能会给你关于犯错的提示、修正尾随空格,甚至预测你可能引入的 bug。
使用 flake8 进行被动 Linting
一个流行的 linter 是 flake8,它会根据 PEP 8 规范对你的代码风格进行评论。
你可以使用 pip 安装 flake8:
$ pip install flake8
然后你可以对单个文件、文件夹或模式运行 flake8:
$ flake8 test.py
test.py:6:1: E302 expected 2 blank lines, found 1
test.py:23:1: E305 expected 2 blank lines after class or function definition, found 1
test.py:24:20: W292 no newline at end of file
你会看到 flake8 找到的代码错误和警告列表。
flake8 可以在命令行或项目中的配置文件中进行配置。如果你想忽略某些规则(如上面显示的 E305),可以在配置中设置它们。flake8 会检查项目文件夹中的 .flake8 文件或 setup.cfg 文件。如果你决定使用 Tox,可以将 flake8 配置部分放在 tox.ini 中。
此示例忽略了 .git 和 __pycache__ 目录以及 E305 规则。此外,它将最大行长设置为 90 而不是 80 个字符。你可能会发现,默认的 79 个字符行长限制对测试来说非常严格,因为测试包含长方法名、带有测试值的字符串字面量和其他可能较长的数据片段。通常将测试的行长设置为最多 120 个字符:
[flake8]
ignore = E305
exclude = .git,__pycache__
max-line-length = 90
或者,你可以在命令行提供这些选项:
$ flake8 --ignore E305 --exclude .git,__pycache__ --max-line-length=90
你现在可以将 flake8 添加到 CI 配置中。对于 Travis CI,这看起来如下:
matrix:
include:
- python: "2.7"
script: "flake8"
Travis 将读取 .flake8 中的配置,如果出现任何 linting 错误,构建将失败。务必在 requirements.txt 文件中添加 flake8 依赖项。
使用代码格式化工具进行主动 Linting
flake8 是一个被动的 linter:它推荐更改,但你需要自己去修改代码。更主动的方法是使用代码格式化工具。代码格式化工具会自动更改你的代码以满足一系列风格和布局实践。
black 是一个非常严格的格式化工具。它没有任何配置选项,并且有非常特定的风格。这使得它成为测试管道中很好的即插即用工具。
注意:black 需要 Python 3.6+。
你可以通过 pip 安装 black:
$ pip install black
然后在命令行运行 black,提供要格式化的文件或目录:
$ black test.py
保持测试代码整洁
编写测试时,你可能会发现自己比在常规应用程序中更多地复制粘贴代码。测试有时会非常重复,但这绝不是让你的代码混乱和难以维护的理由。
随着时间推移,你的测试代码中会积累大量技术债务,如果你的应用程序发生重大变化需要修改测试,由于测试结构的问题,这可能会比必要的更加繁琐。
编写测试时尽量遵循 DRY 原则:Don't Repeat Yourself(不要重复自己)。
测试夹具和函数是生成更易于维护的测试代码的好方法。此外,可读性很重要。考虑在测试代码上部署像 flake8 这样的 linting 工具:
$ flake8 --max-line-length=120 tests/
测试代码变更之间的性能退化
Python 中有许多基准测试代码的方法。标准库提供了 timeit 模块,可以多次计时函数并给出分布。此示例将执行 test() 100 次并 print() 输出:
def test():
# ... your code
if __name__ == "__main__":
import timeit
print(timeit.timeit("test()", setup="from __main__ import test", number=100))
另一个选项,如果你决定使用 pytest 作为测试运行器,是 pytest-benchmark 插件。这提供了一个名为 benchmark 的 pytest fixture。你可以将任何可调用对象传递给 benchmark(),它会将可调用对象的计时记录到 pytest 的结果中。
你可以使用 pip 从 PyPI 安装 pytest-benchmark:
$ pip install pytest-benchmark
然后,你可以添加一个使用该 fixture 并传递要执行的可调用对象的测试:
def test_my_function(benchmark):
result = benchmark(test)
执行 pytest 现在会给你基准测试结果:
测试应用程序中的安全漏洞
你还需要对应用程序运行的另一种测试是检查常见安全错误或漏洞。
你可以使用 pip 从 PyPI 安装 bandit:
$ pip install bandit
然后,你可以使用 -r 标志传递应用程序模块的名称,它会给你一个摘要:
$ bandit -r my_sum
[main] INFO profile include tests: None
[main] INFO profile exclude tests: None
[main] INFO cli include tests: None
[main] INFO cli exclude tests: None
[main] INFO running on Python 3.5.2
Run started:2018-10-08 00:35:02.669550
Test results:
No issues identified.
Code scanned:
Total lines of code: 5
Total lines skipped (#nosec): 0
Run metrics:
Total issues (by severity):
Undefined: 0.0
Low: 0.0
Medium: 0.0
High: 0.0
Total issues (by confidence):
Undefined: 0.0
Low: 0.0
Medium: 0.0
High: 0.0
Files skipped (0):
与 flake8 一样,bandit 标记的规则是可配置的,如果有任何你想忽略的规则,可以在 setup.cfg 文件中添加以下部分:
[bandit]
exclude: /test
tests: B101,B102,B301
结论
Python 通过内置命令和库使测试变得触手可及,帮助你验证应用程序是否按设计工作。在 Python 中开始测试不必复杂:你可以使用 unittest 编写小型、可维护的方法来验证你的代码。
随着你对测试的深入了解和应用程序的增长,你可以考虑切换到其他测试框架(如 pytest),并开始利用更高级的功能。
感谢阅读。希望你在 Python 的未来中无 bug!