Python 上手容易,编程者可以很快投入到应用的开发之中。但这里不
能操之过急。在动手之前,各位要先问一问自己:理解接下来要做的
东西了吗?知道该怎么做了吗?
没错,敲代码前还有一个必经的步骤,那就是设计。
另外,敲完代码之后还少不了测试。如今,在极限编程等敏捷开发方
法的影响下,人们对测试的认识正在从“麻烦”逐渐向着“主导开发
的步骤”转变。
从某种意义上讲,设计是对测试的一种输入,因为我们只能在测试的
过程中检验自己明确
设计出来的东西。另外,易于测试的设计还能让源码维护省力不少。
为了让测试能够立竿见影,我们选择将功能分割到模块之中进行开
发。
本章会向各位介绍将功能分割到各个模块的设计方法、测试手法以及
如何根据测试结果改善设计。
8.1 模块分割设计
应用是由功能集合而成的。功能则要通过函数、对象等的相互作用来
实现。在 Python 中,函数、类等(以下统称为组件)整合在模块
里,模块又整合在程序包里。设计的第一步是设计功能,然后才轮到
实现功能的各个组件。
8.1.1 功能设计
在功能设计阶段,我们要敲定即将开发的应用包含哪些功能,明确各
功能的输入输出。
从应用的角度看,输入输出不仅包括用户能看到的这部分,还涵盖了
与外部相关系统的接口、向数据库保存的数据等。
首先我们要给功能写出一个方案。所谓方案,就是描述用户在使用功
能时会与系统进行何种交互的文本。我们要通过这一步明确用户向系
统输入的内容,以及系统该向用户显示的内容。
将方案所示的整个流程画成图,即为页面迁移图。这里要写下各个页
面的功能。作为一款 Web 应用,应当包含下面几项。
URL
HTTP 方法
安全(登录、权限等)
输入
用户输入
从数据库或文件读取
外部系统发来的内容
输出
页面输出
向数据库或文件写入
外部系统的调用
另外,要明确输入和输出的对应关系。连接输入与输出是功能的职
责。在功能测试中,我们要查看的也是这些输入和输出。
通过功能设计,我们确定了即将开发的应用应当怎样运作。做完了这
一步,接下来就是设计“如何实现这些功能”。简单的功能通常可以
一步到位,但在实际开发中,绝大部分功能要么复杂,要么必须与多
个功能协同工作。对付复杂的功能时切忌强求一步到位,最好将其视
为多个部分(组件)的组合。将复杂功能分割成组件的好处在于可以
重复使用同一组件来完成相同的工作,从而有效避免应用内的矛盾。
下面,我们将对构成 Web 应用的组件进行了解。随后再来学习如何将
功能分解成组件。
8.1.2 构成 Web 应用的组件
将功能分解成组件需要用到一个指标,我们将这个指标称为软件架
构。
Web 应用架构中最有名的当属 MVC(Model View Controller,模型-
视图-控制器)了。 =MVC 根据职责不同对 Web 应用的互动(应用与
用户关联的)部分进行了分割。M 是 Model,它负责功能的主要逻辑
与数据;V 是 View,负责将 Model 以用户能理解的形式显示出来;C
是 Controller,它的工作是根据用户的输入将 Model 和 View 联系
起来。
近来的 Web 应用框架已经基本上集成了 Controller 的功能。与此同
时,View 则更多地被分割成 HTML 模板和显示逻辑两部分。比如,
Django、Pyramid 等框架就很少需要明确写出 Controller,绝大部分
情况都是 Model、View、Template 结构。
Model 部分在 MVC 中很少被提及。不过,当应用不是单纯的 Web+DB
形式时,其必然会与外部系统存在某种协作。另外,虽然绝大部分
Model 会被永久保存,但不可否认有些 Model 只是为了构成功能而存
在的。鉴于这些因素,我们把 Model 分成 ApplicationModel 和
DomainModel 两类来考虑。DomainModel 是拥有持久(保存在文件或
数据库中)状态的模型,而 ApplicationModel 是用于构成功能的模
型,虽然它们也能具有状态,但这些状态通常不会被永久化。
现在来总结一下构成 Web 应用的组件。由于 Controller 可以完全交
给框架来处理,所以构成功能的组件应如图 8.1 所示。
图 8.1 Web 应用构架
View
它的工作就是接受请求然后作出响应。输入主要为请求的传值参
数,输出则是响应体。它会检查用户输入的数据,另外还会加载
模型以及调用ApplicationModel。最后,它会将结果对象转换成
可以显示给用户的HTML,生成响应体。
DomainModel
拥有持久状态的对象。大部分情况下,对象的状态保存在RDBMS
中。Python上能使用的O/R映射工具(Object-Relational
Mapper)以SQLAlchemy最为有名。另外,它还具有能改变自身状
态的业务逻辑(Business Logic)。
ApplicationModel
没有持久性,其状态是暂时的。状态大多只能维持Web应用的一次
请求,或者一定程度上完整的多个页面迁移期间。它们通过一般
的类、模块中的函数来实现。经View调用后,执行其对应的各个
DomainModel内定义的方法。它们大多不进行具体的处理,只负责
传递处理的流程。也正是因为这个,它们依存的模块通常较多。
ServiceGateway
表示外部服务。其作用是为了让Web API的简单封装、邮件发送、
启动其他处理等直接依存于外部系统的内容要能够不包含在Model
和View之中。
Utility
它们大多都很小,不具备状态,输入只有函数传值参数,输出只
有返回值。当然,它也可以拥有多种形式的输入,以对象状态维
持自身直到最终调用方法,但输入输出要符合前面所说的要求,
在该对象内完成所有处理。
一个功能对应一个 View。
ApplicationMode 和 DomainModel 被 View 调用。如果 DomainModel
只有一种,那么程序将通过 View 直接调用 DomainModel。当同时关
联着多个 DomainModel 时,程序则要通过 View 调用
ApplicationModel。
ServiceGateway 的作用就是一个简单的封装,它里面不能包含应用固
有的内容。ApplicationModel 则要使用 ServiceGateway 进行应用固
有的处理。
NOTE
SQLAlchemy
它是连结对象与 RDBMS 的 O/R 映射工具库。虽然 SQLAlchemy
并不是 Python 唯一的映射工具(其他还有 SQLObject、Storm
等),但相比之下它的功能最为丰富,文档也相对齐全。像
Pyramid、Flask 等非全栈式(不具备 O/R 映射工具)框架的说
明大多以使用 SQLAlchemy 为前提,这在如今的 Web+DB 应用开
发中已经逐渐成为了一条不成文的规定。
8.1.3 组件设计
接下来我们把功能分割到各个组件当中。
◉ 根据输入设计 View
首先对付输入。作为一款 Web 应用,输入的主要来源应该是用户输入
的数据。除此之外,已存在的数据和从外部系统获取的数据都属于输
入。
用户输入的数据由 View 接收。另外,View 还负责向用户传递数据。
交给模板的内容要由 View 进行输出。在 View 的内部会调用
ApplicationModel 或 DomainModel,这些 Model 也是输入之一。还
有,由于 View 没有状态可言,所以大多数情况以函数(以下称 View
函数)形式实现。
◉ 初步设计与 View 关联的 ApplicationModel
对 View 函数而言,函数传值参数是输入,返回值是输出。另外,它
只关联一种 Model。
这一阶段我们先不考虑 DomainModel 的相关问题,先给每个 View 函
数定义一个 ApplicationModel。View 函数和 ApplicationModel 建
议根据功能名来命名,比如“{ 功能名 }_view、{ 功能名
}Model”。除此之外,还要给 ApplicationModel 预先定义一个方
法,名称同样根据功能名来起。
◉ DomainModel 的设计
接下来定义 DomainModel(View 和 DomainModel 的定义可以并行进
行。实际上,在设计 View 之前就可以一定程度上对 DomainModel 进
行定义了)。由于 DomainModel 有状态,所以要用类来定义。
定义好 DomainModel 的类之后就要考虑方法了。要明确各个方法是如
何改变 DomainModel 的状态的。这些状态要定义成属性。另外,如果
要通过调用方法来改变其他相关 DomainModel 的状态,需要在目标
DomainModel 里定义方法,然后调用目标 DomainModel 里的方法。切
记不可以直接改变其他 DomainModel 的状态。
◉ 关联 ApplicationModel 和 DomainModel
接下来给 ApplicationModel 和 DomainModel 添加关联。在
ApplicationModel 的方法中定义需要调用的 DomainModel。然后检查
功能的输入输出是否满足要求。
◉ 整理 ApplicationModel
当 ApplicationModel 只关联着一个 DomainModel 时,应当删除该
ApplicationModel,让 View 直接调用 DomainModel。在
ApplicationModel 中,对象 Domain 模型一致地要整合成一个类。整
合好之后需要立刻给类命名。如果无法确定类名,就尽量不要整合。
◉ 从组件设计转入实现
到这里,我们已经将功能分割到了各个组件之中。接下来就是把它们
向 Python 的模块、程序包一步步整合了。
8.1.4 模块与程序包
现在我们根据目的把组件整合成模块或程序包。开发 Web 应用时,要
每一个功能制作一个程序包,功能内的各个组件(View、Model 等)
分别制成模块。在 Python 中,各个组件由类和函数来实现。
NOTE
我们来整理一下 Python 的模块与程序包。
模块
Python 的源码文件就是一个个模块。里面整合着多个类、函数、
变量等内容。虽然我们可以把处理写到模块内,但当我们开发的
应用由多个模块构成时,最好只让它作为一种函数和类的集合体
出现。
程序包
整合在一起的多个模块。它就是把程序包内包含的模块文件与
__init__.py 整合到了一个单独的目录下。
命名空间程序包
程序包的一种比较特殊的形式,用来向多个位置安装相同名称的
程序包。因为只有在分配库文件的时候才会用到它,所以一般应
用都与它无缘。
◉ 项目的文件结构
以 MVC 架构为基准时,项目的实际文件结构如下所示。
project/
+-- __init__.py
+-- views.py
+-- models.py
+-- services/
| +-- __init__.py
| +-- twitter.py
| +-- twitpic.py
+-- utils.py
+-- templates/
+-- tests/
+-- __init__.py
+-- test_models.py
+-- test_views.py
+-- test_services.py
+-- test_utils.py
○ project/__int__.py
启动应用所需的处理都写在这里,比如 WSGI 应用的入口点等。另
外,models 的导入、数据库连接的初始化、设置文件的读取等都在这
里进行。
○ project/views.py 以及 project/utils.py
分别实现 View 和 Utility。
○ project/models.py
实现 DomainModel 和 ApplicationModel。另外,连接 DB 所需的模
块等也要事先导入到这里。
○ project/services/
在存在多个 services 时,由程序包进行整合。
○ project/templates/
存放 HTML 模板的位置。
○ project/tests/
存放单元测试(Unit Test)的位置。
另外,当项目规模非常大的时候,会由多个 project 合在一起形成一
个网站。比如用户种类不同(终端用户与服务提供方的用户)或
DomainModel 完全分离时。
8.2 测试
完成设计之后,应该在动手实现前先把测试考虑好。因为如果没有一
个能验证成品是否符合设计初衷的方法,我们根本无法着手去实现。
反过来,只要把现成的测试摆在那里,那么我们只需以通过测试为目
的去编程即可。
实现阶段的测试包括单元测试和功能单元测试。我们应该让其自动
化,并且时常查看其结果。由于测试要重复执行多次,所以必须保证
其足够迅速,并且可以随时随地执行。要满足“随时随地执行”这一
点,就意味着这些测试不能依赖于环境。分离组件可以让不依赖于环
境的测试成为可能。
本节将介绍借助 Python 中的测试工具进行测试的手法,以及从测试
中提出环境依赖的技巧。
8.2.1 测试的种类
测试其实只是一个泛泛的概念,对于不同的对象或观点,其实施内容
都不一样。人们常说集成测试、单元测试,但究竟是集成了什么,又
是以什么为单元呢?这里我们以此思路为基础,对其进一步细分。
○ 单元测试
测试函数、方法等最小单元的测试。这个等级的测试能明确看到输入
和输出,所以测试内容往往就是函数或方法的设计方案本身。该部分
要利用 mock 或 dummy,把测试对象的处理单独拿出来执行,看结果
是否达到预期。
○ 组件集成测试
这是集成多个函数或方法的输入输出的测试,测试时需要将多个测试
对象组合在一起。由单个测试对象构成的流程已在单元测试中测试完
毕,所以不参与这一步测试。对象的前后处理与单元测试一样要使用
mock 或 dummy。
○ 功能单元测试
测试用户能看得到的功能。此时用户的输入项目以及数据库等外部系
统为输入的来源。输出则是向用户显示的结果、向数据库保存的内容
以及对外部系统的调用。系统内部不使用 mock 和 dummy,而是全部
使用正式的代码。不过,在对付某些异步调用之类的难以自动测试的
部分时,需要进行一定程度的置换。外部系统方面,要准备好虚拟的
SMTP 服务器或 Web API 服务器,用以执行应用内的通信。
○ 功能集成测试
集成各功能之间输入输出的测试。这里要尽可能不去直接查看数据库
内的数据,比如可以用引用类功能来显示更新类功能生成的数据。另
外在与外部系统的联动方面,要借助开发专用的 API 等模拟出正式运
行时的结构,然后再进行测试。这部分测试要依赖于数据库以及外部
服务等诸多环境,难以自动执行,所以属于偏手动的测试。
○ 系统测试
对需求的测试。测试成品是否最终满足了所有需求。在客户验收项目
时进行。
○ 非功能测试
对性能、安全等非功能方面进行的测试。借助压力测试软件进行正常
/ 高峰 / 极限情况的测试,通过 XSS、CSRF 以及注入式攻击等模拟
攻击来验证系统的安全性及可靠性。
本节,我们将就需开发者主要负责的单元测试、集成测试以及功能测
试进行学习。
8.2.2 编写单元测试
现在我们来编写单元测试。Python 的标准库里有为编写单元测试而准
备的 unittest 模块。另外,执行测试时建议使用 pytest。pytest
是一款能够自动搜索并执行测试的测试执行工具,并且会输出详细的
错误报告。
本节将为各位讲解如何用 unittest 和 pytest 生成并执行单元测
试。
◉ unittest 模块
它是基于 xunit(派生自 unit、sunit 等)的测试工具及程序库。其
中主要使用的是 unittest.TestCase 类。我们定义一个继承自它的
类,然后在其各个方法中描述测试用例(Test Case)。测试用例要写
在名称以 test 开头的方法中,具体如下。
测试用例写在名称包含 test 的方法中。
import unittest
class TestIt(unittest.TestCase):
def test_it(self):
""" 本方法是一个测试用例 """
def test_one(self):
""" 这是另一个测试用例 """
另外,TestCase 类里有用于设置环境的配置器(Fixture)和用于查
看测试结果的断言方法(Assert Method)。
配置器指执行测试所需的必要前提条件的搭建以及执行完毕后的善后
工作。unittest 在执行测试用例前后会分别调用各个类的 setUp 和
tearDown 方法,因此我们可以在这两个方法内描述执行测试所需的环
境设置(LIST 8.1)。Python 2.7 之后的 unittest 允许使用以模块
为单位的配置器。我们可以把整个模块共通的、不必每次测试都重置
的环境设置流程写在模块配置器里,这能够有效削减测试的执行时间
(LIST 8.2)。
LIST 8.1 各个类的配置器
class TestIt(unittest.TestCase):
def setUp(self):
""" 搭建测试环境 """
def tearDown(self):
""" 测试后的环境清理 """
setUp 在测试前被调用,tearDown 在测试后被调用。
LIST 8.2 各个模块的配置器
def setUpModule():
""" 搭建测试环境 """
def tearDownModule():
""" 测试后的环境清理 """
setUpModule 会在模块测试开始前被调用一次。tearDownModule 会在
整个模块的测试全部结束后被调用一次。
Python 在查看测试结果时要使用 assert 语句等断言。
a = 1
b = 2
c = a + b
assert c == 3, "%d != %d" % (c, 3)
assert 语言失败时会同时显示错误信息和测试结果(失败显示 F,出
错显示 E)。不过,由于 assert 语句本身只能进行 True、False 的
判断,而且信息很多为定式,所以 unittest 的 TestCase 类大多拥
有自己的断言方法。
最常用的断言方法当属 assertEqual。刚才那个例子改用
assertEqual 会变成下面这样。
def test_it(self):
a = 1
b = 2
c = a + b
self.assertEqual(c, 3)
◉ testfixtures 库
testfixtures 库(测试配置器)整合了多种典型配置器。里面包含生
成 / 删除临时目录、更改系统日期、添加 mock/dummy 等模块,这些
模块能帮助我们将单元测试与环境分离开来。
使用 testfixtures 库之前需要进行安装,安装步骤与通常的库相同
(LIST 8.3)。
LIST 8.3 安装
$ pip install testfixtures
testfixtures 的 compare 函数显示出的错误信息比 unittest 的
assertEqual 还要详细。
from testfixtures import compare
def test_add():
result = add(2, 3)
compare(result, 5)
使用 compare 函数时,只需将比较对象用作实参直接执行即可。
另外,该比较会递归地执行到 dict 及 list 内部,并生成结果报
告。
在比较下面这种复杂数据的时候,compare 函数能详细显示出 list
中的 dict 元素如何不同。
>>> compare([{'one': 1}, {'two': 2, 'text':'foo\nbar\nbaz'}],
... [{'one': 1}, {'two': 2, 'text':'foo\nbob\nbaz'}])
Traceback (most recent call last):
...
AssertionError: sequence not as expected:
same:
[{'one': 1}]
first:
[{'text': 'foo\nbar\nbaz', 'two': 2}]
second:
[{'text': 'foo\nbob\nbaz', 'two': 2}]
While comparing [1]: dict not as expected:
same:
['two']
values differ:
'text': 'foo\nbar\nbaz' != 'foo\nbob\nbaz'
While comparing [1]['text']:
@@ -1,3 +1,3 @@
foo
-bar
+bob
baz
多行的字符串会以 unified diff 格式显示不同。可见,它会递归地
根据数据类型不同选用最直观的显示方法。
另外,比较方法和结果输出可通过
testfixture.comparison.register 函数进行添加。各位想多次重复
使用 assert 语句时不妨一试。
使用 Comparison 类可以一次性查看对象的属性。
from testfixtures import compare, Comparison as C
def test_create_object():
result = create_object(name="dummy-object", value=4)
compare(result, C(SomeObject, name="dummy-object", value=4)
ShouldRaise 可以查看发生的例外。特别是它连交给例外的传值参数
都能查看到,这是 unittest 的 assertRaises 做不到的。
from testfixtures import ShouldRaises
def test_critical_error():
with ShouldRaise(CriticalError('very-critical')):
important_process(None)
testfixtures 提供的能应用于单元测试的实用程序还远不止这些,其
他还有控制台输出、日志输出等等。加之它可以像普通的模块一样对
待,所以能够在 unittest、nose、pytest 等 testrunner 上使用。
◉ 通过 pytest 执行测试
pytest 是第三方出品的测试工具。它描述测试比 unittest 要简单,
而且能输出详细的错误报告。pytest 能够自动发现并执行测试,其中
包括用 unittest 写的测试,所以 unittest 与 pytest 完全可以并
用。
pytest 可以用 pip 安装,具体如 LIST 8.4 所示。
LIST 8.4 安装
$ pip install pytest
pytest 执行时会在指定的目录(未指定状态下则默认当前目录)下寻
找测试。这个过程称为 Test Discovery。Python 程序包下的 tests
模块以及“test_**_test”等形式的名称都会被识别为测试模块。
pytest 发现测试模块后会执行该模块内的测试并显示结果。
◉ 实际编写一个测试并执行
建议不要把测试放得离测试对象太远。我们将测试与测试对象放在同
一个程序包内,以“test_{ 测试对象的模块名 }.py”命名该文件。
接下来用 unittest 写一个测试用例(LIST 8.5)。
LIST 8.5 测试对象:bankaccount.py
class NotEnoughFundsException(Exception):
""" Not Enough Funds """
class BankAccount(object):
def __init__(self):
self._balance = 0
def deposit(self, amount):
self.balance += amount
def withdraw(self, amount):
self.balance -= amount
def get_balance(self):
return self._balance
def set_balance(self, value):
if value < 0:
raise NotEnoughFundsException
self._balance = value
balance = property(get_balance, set_balance)
这是一个传统的 BankAccount 教程。状态只有 _balance,balance
属性为包装。其实际的逻辑是 withdraw 和 deposit。现在我们把这
些测试写出来。
LIST 8.6 测试类
import unittest
class TestBankAccount(unittest.TestCase):
这里创建一个继承了 unittest.TestCase 的测试类(LIST 8.6)。类
名没有特殊要求,但最好让人能明确分辨出测试对象。比如“Test+
测试对象类名”这种命名规则就很不错。
LIST 8.7 加载测试对象
class TestBankAccount(unittest.TestCase):
def _getTarget(self):
from bankaccount import BankAccount
return BankAccount
def _makeOne(self, *args, **kwargs):
return self._getTarget()(*args, **kwargs)
#...( 略)...
这是用来准备测试对象的实用方法。为防止模块的副作用对其他测试
产生影响,这里需要单独导入(LIST 8.8)。
LIST 8.8 测试方法
class TestBankAccount(unittest.TestCase):
#...( 中间省略)...
def test_construct(self):
target = self._makeOne()
self.assertEqual(target._balance, 0)
def test_deposit(self):
target = self._makeOne()
target.deposit(100)
self.assertEqual(target._balance, 100)
def test_withdraw(self):
target = self._makeOne()
target._balance = 100
target.withdraw(20)
self.assertEqual(target._balance, 80)
def test_get_balance(self):
target = self._makeOne()
target._balance = 500
self.assertEqual(target.get_balance(), 500)
def test_set_balance(self):
target = self._makeOne()
target.set_balance(500)
self.assertEqual(target._balance, 500)
def test_set_balance_not_enough_funds(self):
target = self._makeOne()
from bankaccount import NotEnoughFundsException
try:
target.set_balance(-1)
self.fail()
except NotEnoughFundsException:
pass
测试方法要每个方法分开描述。在 _makeOne 方法内生成测试对象的
实例,备齐测试的前提条件,然后执行测试。测试结果在 assert* 方
法内查看。会出现例外的测试要使用 fail 方法,或者用
assertRaises 也行。
执行 pytest 很简单,只要在描述测试的文件
(test_bankaccount.py)所在的目录下执行 py.test
即可。pytest 会自动找出并执行测试(LIST 8.9)。
LIST 8.9 执行测试
$ py.test
◉ 巧用 pytest 插件
pytest 的插件多种多样,比如收集执行数据并进行解析的插件、改变
测试结果显示模式的插件,等等。除 pytest 自身包含的插件外,我
们还可以选择使用第三方开发的插件,甚至自己编写插件。
○ pytest-cov
coverage 会在执行命令时收集该命令的信息。使用 pytest-cov 可以
查看测试中都执行了哪些代码。虽然一味盲从这个数值是很危险的,
但我们可以利用它来推断哪些部分未被测试。pytest-cov 可以通过
pip install pytest-cov 进行安装。安装完后用 --cov 选项指定要
获取覆盖率的程序包。
○ xunit
它和 JUnit 一样会将测试结果保存在特定格式的文件中。在与
Jenkins 等 CI 工具联动时会用到它。它是 pytest 标配的插件,可
以通过 --junit-xml 选项添加使用。
○ pdb
它会在测试发生错误时自动执行 pdb(Python 的调试器)。可以通过
--pdb 选项添加使用。
专栏 什么是覆盖率
覆盖率由百分比表示。比如测试代码执行过了程序的每一行,那
么覆盖率就是100%。这种时候,几乎不会出现新程序上线后突然
无法运行的尴尬情况。不过,这一过程只是让程序跑了一遍而
已,因此并不能检测出逻辑错误引起的Bug。换句话说,覆盖率不
关心代码内容究竟是什么。覆盖率是用来检查“测试代码不足、
测试存在疏漏”的一个指标,“测试内容是否妥当”并不归它
管。覆盖率按评测标准分为 3 个阶段(①最宽松,③最严格)。
① 指令覆盖率(简称C0):只要执行了所有命令即可。比如存在
if 语句,只要测试代码从 if 语句中通过即可达到100%。
② 分支覆盖率(C1):通过所有分支即可。如果代码中存在
if..elif..else 这样的分支,需要执行所有分支以及“不进入分
支的情况”才可以达到100%。
③ 条件覆盖率(C2):当存在多个分支条件时,测试代码需要执
行过所有情况才能达到 100%。
指令覆盖率、分支覆盖率、条件覆盖率三者之间,后者达到 100%
的难度都要高于前者。指令覆盖率比较容易达到 100%,所以我们
通常希望能保证这个 100%。但是能分给编写测试代码的时间毕竟
有限,如果为提升覆盖率编写大量测试,那么维护测试代码的成
本将大大提升(测试代码负债化)。所以我们应该现实一点,根
据眼前情况判断覆盖率应当达到多高。
另外,引入覆盖率后,大家会发现无意义的行以及意图不明确的
处理都很难被覆盖,这就顺便督促了我们在编程时应尽量避免出
现上述情况。覆盖率能让我们注意到一些平时注意不到的东西,
所以还没接触过它的朋友请务必一试。
◉ 使用 mock
mock 是将测试对象所依存的对象替换为虚构对象的库。该虚构对象的
调用允许事后查看。另外,还允许指定其在被调用时的返回值以及是
否发生例外等。
mock 可以通过 pip 安装,具体如 LIST 8.10 所示。
LIST 8.10 安装
$ pip install mock
○ 虚构对象
用 mock.Mock 生成虚构对象。虚构对象的添加方法也很简单。虚构对
象生成后,用任何方法都可以调用它。
>>> import mock
>>> m = mock.Mock()
>>> m.something("this-is-dummy-arg")
用 return_value 可以指定返回值。
>>> m.something.return_value = 10
>>> m.something("this-is-dummy-arg")
10
执行后可以查看到 mock 的方法的调用。
>>> m.something.called
True
>>> m.something.call_args
(('this-is-dummy-arg',), {})
此外,还能指定让其发生例外。
>>> m.something.side_effect = Exception('oops')
>>> m.something('this-is-dummy-arg')
Traceback (most recent call last):
...
Exception: oops
可以在测试内使用断言。
assert_called_with
assert_called_once_with
这些方法的作用是在测试对象的处理执行完毕后,查看其被调用的情
况以及被调用时的传值参数。
>>> m = mock.Mock()
>>> m.something('spam') # 第一次调用
<mock.Mock object at 0x00000000025AF7F0>
>>> m.something.assert_called_with('egg')
Traceback (most recent call last):
...
'Expected: %s\nCalled with: %s' % ((args, kwargs),
self.call_args)
AssertionError: Expected: (('egg',), {})
Called with: (('spam',), {})
>>> m.something.assert_called_with('spam')
>>> m.something('spam') # 第二次调用
<mock.Mock object at 0x00000000025AF7F0>
>>> m.something.assert_called_once_with('spam')
Traceback (most recent call last):
...
raise AssertionError(msg)
AssertionError: Expected to be called once. Called 2 times.
○ patch
生成虚构对象后,需要将其混入测试对象的代码之中。虚构对象用作
传值参数的时候最好对付,只要将其作为传值参数交出去即可,但处
理过程中要用到的类或函数就不能通过传值参数来传递了。这种时
候,mock 可以在 patch 的作用范围内将模块内的类或函数临时替换
为虚构对象(LIST 8.11)。
LIST 8.11 patch 装饰器的使用示例
@patch('myviews.SomeService')
def test_it(MockSomeService):
mock_obj = MockSomeService.return_value
mock_obj.something.return_value = 10
from myviews import MyView
result = MyView()
assert result = 10
通过 patch 的传值参数指定要替换的对象。这样一来,被替换进去的
Mock 对象就会作为测试的传值参数被传进去。这个替换仅在执行被
patch 装饰器装饰的函数的过程中有效,所以不会对其他测试造成影
响。
◉ 如何编写高效率的 Python 测试用例
光拿 unittest 模块来写测试用例并不能让测试变得高效。写完测试
用例后要多运行几遍。另外,在修改源码时如果已经有了内容明确的
相关测试用例,那么要迅速且准确地找出受影响的部分。另外,各个
测试用例要分离得足够开。如果修改一个测试用例导致其他测试用例
不能运行,这将会成为一种负债。
要想最大限度地巧用测试用例,需要注意以下几点。
尽可能简单
要让人从测试内容中一眼看出输入和输出。
尽可能快,多执行几次
单元测试多执行几遍。为实现这一点,要用pytest简化执行操
作,并且保证测试本身的执行不消耗过多时间。如果执行一次就
要花去10分钟,那么没人会愿意多执行几遍的。
分离各个测试
保证测试数据不被多个测试用例共享。对一个测试有用的数据对
其他测试不一定有用,如果测试中包含了这种没用的数据,会使
输入输出变得不明确。另外,如果遇到不得不变更测试数据的情
况,那么必须事先查清其影响范围。
不直接向模块内 import 测试对象
如果直接向测试模块内import测试对象,那么一旦这个测试对象
的import本身会带来问题,就会导致测试模块内的所有测试用例
全部无法评测。此外,某些测试用例会将 import失败判断为错
误。还有,如果模块包含会在import时执行的代码,那么还没等
测试开始,这些代码就先执行完毕了。
简化配置器
在“单元测试”部分我们已经提过了,不要试图用setUp方法做完
一切准备工作。setUp 不是用来存放相同的处理的地方。尤其不
能用setUp来生成依存于测试的数据配置器。
不搞通用的数据配置器
通用的数据配置器(Data Fixture)会在测试之间建立依存性。
在单元测试阶段,要保证只在测试用例中创建必要的输入数据。
不过分信任虚构对象
mock库可以轻松分离测试对象。但是不能过分依赖于mock。正因
为mock简单,我们才容易被它骗。另外,检查一下是不是写了
“在mock返回了mock之后返回一个mock”这种mock。这么复杂的
mock真的是模仿其他组件做出来的?这种mock会让输入输出变得
模糊,同时导致关联度上升。在制作mock上费脑筋是一个危险信
号,此时应该重新审视设计方案。
8.2.3 从单元测试中剔除环境依赖
要保证单元测试不依赖于环境,只执行测试对象本身。否则,每当其
所依赖的模块或外部系统的运作稍有变化就要对测试进行一次修改,
这样单元测试眨眼间就会成为一种负债。
◉ 请求 / 响应
View 的形式有许多种。接下来我们选 Python 框架中几种常见的模式
各写一个测试。
○ View 类
View 类是用类定义的 View(LIST 8.12)。它不是单纯的函数,所以
可以使用属性和方法,使得结构更加多变。View 的处理在类的方法中
进行定义,测试时用 dummy 替换这些方法,我们就能做到只运行测试
对象的方法了。
使用 View 类时,大部分框架会用构造函数来接收请求,通过方法来
进行 View 的处理。此时能很轻松地用 dummy 替换请求。另外,虽然
View 会调用服务以及读取模型,不过我们完全可以用 mock 来替换它
们。在 View 的输出中,HTTP 响应是方法的返回值,它能被视为响应
对象。对于这种状态,View 绝大多数时候都已经做完了 HTML 渲染。
鉴于 HTML 内容的易变性,测试时最好确认一下交给模板的传值参
数。
LIST 8.12 典型的 View 类
class MyView(object):
def __init__(self, request):
self.request = request
def index(self):
s = SomeService()
result = s.some_method(**self.request.params)
return render('index.html', dict(result=result))
为达到分离效果需要解决下面两点内容。
① 把 SomeService 替换为 mock
② 获取传给 render 的 dict 的内容
LIST 8.13 更便于测试的 View 类
class MyView(object):
someservice_cls = SomeService
def __init__(self, request):
self.request = request
def index(self):
s = self.someservice_cls()
result = s.some_method(**self.request.params)
self.render_context = dict(result=result)
return render('index.html', self.render_context)
把 SomeService 替换为 mock 的最简单方法就是把它放在类里。另
外,只要把 dict 的内容放在对象里,我们就能在测试中查看它了
(LIST 8.14)。
LIST 8.14 test
class DummyRequest(object):
def __init__(self, params):
self.params = params
class DummySomeService(object):
def somemethod(self, **kwargs):
return kwargs
class TestIt(unittest.TestCase):
def test_it(self):
request = DummyRequest(params={'a': 1})
target = MyView(request)
target.someservice_cls = DummySomeService
result = target.index()
self.assertEqual(target.render_context['result'], {'a':
1})
◉ 全局请求对象
某些框架会把请求对象当作一个线程本地化的全局对象提供给我们。
更改它们需要对框架进行调整,所以必须加以注意。另外,有些时候
框架根本不允许我们对请求对象进行修改。对于这类情况,框架一般
都会提供一个从框架传送虚拟请求的机制,我们要利用这一机制来控
制输入(LIST 8.15)。Flask 就属于这类框架。
LIST 8.15 test
with app.test_request_context:
result = myview()
然而麻烦来了,Flask 的 view 的返回值是响应体。而且 view 是个
函数,没地方存储传给模板的上下文。其实有一个地方可以用,那就
是请求对象(LIST 8.16)。
LIST 8.16 myviews.py
def index():
s = SomeService()
result = s.some_method(**self.request.params)
request.environ['render_context'] = dict(result=result)
return render('index.html', self.render_context)
把上下文添加到 request.environ 中,事后可以通过测试用例查看。
此外,在框架内部使用的 ApplicationModel 等也会变得难以替换。
到这里,我们自然希望 mock 等专用库伸出援手。不过别急,我们先
不用库,直接混入些 dummy 试试。
def test_it():
import myviews
SomeService_orig = myview.SomeService
try:
myviews.SomeService = DummySomeService
app = flask.Flask(__name__)
with app.test_request_context:
result = myview()
assert flask.request.environ['render_context'] ==
{'a': 1}
finally:
myviews.SomeService = SomeService_orig
显然,这作为测试而言太取巧了,我们甚至需要测试一下这个测试是
否能够正常运行。这种时候,使用 mock 库就简单得多。
@patch('myviews.SomeService')
def test_it(MockSomeService):
myviews.SomeService = DummySomeService
app = flask.Flask(__name__)
with app.test_request_context:
result = myview()
assert flask.request.environ['render_context'] == {'a': 1}
由于问题的根本在于测试对象会直接返回响应体,所以我们用装饰器
来避开它。
def render_view(name)
def dec(view_func):
def wraps()
data = view_func()
return render_template(name, data)
return wraps
return dec
但是,如果仅有上面这个处理,我们只能在测试对象加了装饰器的状
态下进行访问,所以需要再作一些调整,以保证能在加了装饰器的状
态下直接获取原来的函数。
def render_view(name)
def dec(view_func):
def wraps()
data = view_func()
return render_template(name, data)
wraps.inner = view_func
return wraps
return dec
这样一来,我们就能在测试中直接查看返回值了。
@patch('myviews.SomeService')
def test_it(MockSomeService):
myviews.SomeService = DummySomeService
app = flask.Flask(__name__)
with app.test_request_context:
result = myview.inner()
assert result == {'a': 1}
◉ 数据库
SQLAlchemy 支持 sqlite 的内存数据库。使用内存数据库时,可以在
不具备实际数据库的情况下测试伴随数据库连接的处理。另外,
SQLAlchemy 用 DBSession 对象进行访问数据库以及取出、更新
DomainModel 的操作。
我们继续按照本章中的设计,将 View 能直接访问的 Model 限制在一
个。涉及多个 DomainModel 的处理交给 ApplicationModel 来应付。
这样一来,View 就只会从 DBSession 加载一个模型了。它对
DBSession 的调用是输出,从 DBSession 获取的内容是输入。
输入大致分为两种情况,即存在 DomainModel 的情况和不存在
DomainModel 的情况。在测试对象执行前将 DomainModel 放到(或不
放到)会话(session)内,这样,我们就能轻松控制这两种情况了。
DomainModel 的调用方法要限制在一个。需要调用多个时交给
ApplicationModel 来处理。
由于存在实际访问数据库的行为,所以测试前后需要用配置器来设置
数据库。另外,我们不必每次测试都设置一遍数据库,因此要选用模
块配置器。
首先编写一个实用程序用作设置数据库的配置器(LIST 8.17)。
LIST 8.17 数据库配置器
def _setup_db():
from .models import DBSession, Base
from sqlalchemy import create_engine
engine = create_engine("sqlite:///")
DBSession.remove()
DBSession.configure(bind=engine)
Base.metadata.create_all(bind=engine)
return DBSession
def _teardown_db():
from .models import DBSession, Base
DBSession.rollback()
Base.metadata.drop_all(bind=DBSession.bind)
DBSession.remove()
在实际的测试套件中,通过模块配置器调用上述实用程序(LIST
8.18)。
LIST 8.18 测试
def setUpModule():
_setup_db()
def tearDownModule():
_teardown_db()
class TestMyView(unittest.TestCase):
def setUp(self):
from .models import DBSession
self.session = DBSession
def _setup_entry(self, **kwargs):
from .models import Entry
entry = Entry(**kwargs)
self.session.add(entry)
return entry
def test_it(self):
e = self._setup_entry(name=u"this-is-test")
result = self._callFUT(entry_id=e.id)
self.assertEqual(result['name'], e.name)
测试数据的生成不要在 setUp 中进行。保证在测试方法内只生成该测
试所需的数据。
一旦用 setUp 生成测试数据,这些测试数据就会被多个测试共享。被
共享的测试数据会给各个测试之间带来依存性。
多余数据会使输入输出变得不明确。而输入输出一旦不明确,会让人
很难搞清到底在测试什么。另外,当前提改变后,如果我们对测试数
据进行更改,其影响的测试可能导致有问题的数据残留在程序中。所
以,我们需要让单元测试只能给各个测试准备该测试所需的数据,并
要在保证没有多余数据的情况下进行测试。
◉ 系统时间
我们以判断系统时间是否为月末的实用程序为例来思考一下。系统时
间每次调用都会返回不同的值,所以做自动测试时要花些心思才行。
这里我们暂且把系统时间放在一边,先编写一个通过传值参数接收时
间并进行判断的实用程序,然后再写一个函数来给这个传值参数传递
系统时间(LIST 8.19、LIST 8.20)。
LIST 8.19 实现通过传值参数接收时间并进行判断的实用程序
def is_last_of_month(d):
return (d + timedelta(1)).day == 1
接下来进行测试。
def test_is_last_of_month():
d = date(2011, 11, 30)
assert is_last_of_month(d), "%s" % d
def test_is_last_of_month_not():
d = date(2011, 11, 29)
assert not is_last_of_month(d), "%s" % d
LIST 8.20 实现用系统时间进行判断的实用程序
def is_last_of_month_now():
return is_last_of_month(datetime.now())
把这个也测试一遍。获取时间部分使用 testfixtures 的 Replacer
和 test_date。
from datetime import datetime,date
def test_is_last_of_month_now():
with Replacer() as r:
r.repace('util.datetime', test_date(2011, 11, 30))
assert is_last_of_month_now()
这里不能替换 datetime.datetime,而是要替换掉测试对象 import
的东西(本例中测试对象使用的是 util.datetime)。在
ApplicationModel 或 DomainModel 中使用的时候,要用 mock 替换
掉 is_last_of_month_now。
实用程序的功能一定要压缩到上述这个程度。
8.2.4 用 WebTest 做功能测试
确认所有组件运转正常后,就该把它们结合起来,查看功能的运作情
况了。
对于 Web 应用而言,功能测试要查看从接受请求到作出响应的整个过
程是否正常运作。系统内部的所有组件都直接使用正式代码,与外部
系统联动的部分用 mock。mock 部分要尽可能小。另外,如果这一阶
段能拿到与相当于正式运营时的示例数据,那就更应该积极测试了。
本阶段通过内存数据库、模拟请求等来控制输入输出部分。内存数据
库用 SQLite 的内存数据库即可。由于 O/R 映射工具会帮我们吸收掉
RDBMS 的差异,所以用作功能测试绰绰有余。
至于模拟请求,用 WebTest 比较容易实现。
◉ WebTest
WebTest 是用于 Web 应用功能测试的库。它会对 WSGI 应用执行模拟
请求并获取结果。基本上所有 WSGI 应用的测试都可以用它。
WebTest 可以用 pip 进行安装(LIST 8.21)。
LIST 8.21 安装
$ pip install webtest
LIST 8.22 使用方法
def test_it():
from webtest import TestApp
import myproject.app
app = TestApp(myproject.app)
res = app.get('/')
assert "Hello" in res
如 LIST 8.22 所示,WebTest 中的 webtest.TestApp 可以通过构造
函数的传值参数接收 WSGI 应用类型的测试对象(比如
myproject.app)。TestApp 拥有 get、post 等方法。它可以利用这
些方法来执行 WSGI 应用,接收响应对象。在测试过程中,要测试响
应对象的内容以及执行测试之后的数据库的状态等。
NOTE
WSGI(Web Server Gateway Interface,Web 服务器网关接口)
是 PEP 3333 所倡导的机制。
WSGI 将 Web 服务器与 Web 应用之间的处理定义成了简洁统一的
接口,符合 WSGI 标准的服务器以及应用之间可以相互替换,对
应 WSGI 的应用可以在任意对应 WSGI 的服务器上运行。
比如在 gunicorn 上运行的 Web 应用可以直接放到 Apache 的
mod.wsgi 上运行。
关于在 gunicorn 上运行 WSGI 应用的方法,我们将在第 12 章
中了解。
1
https://www.python.org/dev/peps/pep-3333
◉ 配置器
让 WSGI 应用能通过 WebTest 调用模拟请求。另外,还要在数据库配
置器和数据库中生成必要的数据以及为外部服务准备 mock。
◉ 测试用例
一个请求对应一个测试用例。设置好配置器之后,通过 WebTest 发送
模拟请求。
◉ 断言
当断言的对象为 HTML 时,响应输出的内容很容易变化,所以要检查
由 HTML 输出的内容是否以字符串形式包含在其中。功能的输出多为
更新 DB,所以直接检查 DB 的数据即可。另外,外部系统的调用也属
于输出,这部分要通过 mock 的功能来查看。
◉ 与外部服务有关的测试
这里假设要使用外部的搜索服务。此时只将调用服务的部分替换为
mock。另外,负责生成数据的配置器要生成测试内所需的内容。
from mock import patch
from webtest import TestApp
def setUpModule():
_setup_db()
def tearDownModule():
_teardown_db()
def _init_data():
# 在这里生成数据
def _init_search_results():
# 在这里创建 mock 的外部服务结果
class TestWithMock(unittest.TestCase):
def _getTarget(self):
1
from app import myapp
app = TestApp(myapp)
return app
@patch('othersite.search')
def test_it(mock_search):
""" 测试 """
# 前提条件
mock_search.return_value = _init_search_results()
_init_data()
# 准备测试对象
app = self._getTarget()
# 执行测试对象
res = app.get('/?search_word=abcd')
# 确认结果
assert "20" in res
mock_account.deposit.assert_called_with(q="abcd")
这里我们假设测试对象 myapp 在内部通过 othersite.search 函数调
用了外部服务。测试时 othersite.search 被替换为了 mock。
◉ 与认证和会话相关的测试
大部分 WSGI 应用会将认证信息放在 environ['REMOTE_USER'] 里
(非此类框架的原理也是相同的,只需将存储位置替换为该框架的存
储位置即可)。WebTest 支持 Cookie,所以能正常执行基于 Cookie
的会话及认证的相关测试。
LIST 8.23 Cookie 的相关测试
from webob.dec import wsgify
@wsgify
def myapp(request):
c = int(request.cookies.get('count', "0"))
request.response.set_cookie('count', str(c + 1))
return "response %d" % c
def test_it():
from webtest import TestApp
app = TestApp(myapp)
res = app.get('/')
assert res.body == "response 0"
res = app.get('/')
assert res.body == "response 1"
如果不是与认证本身直接关联,那么可以在 extra_environ 的
REMOTE_USER 里直接进行设置。下面是直接将 REMOTE_USER 传递给
extra_environ 的例子。
LIST 8.24 认证相关的测试
@wsgify
def myapp(request):
if not request.remote_user:
return HTTPFound(location="/login")
return "OK"
def _makeOne():
from webtest import TestApp
return TestApp(myapp)
def _callAUT(url, params={}, method="GET", remote_user=None):
extra_environ = {'REMOTE_USER': remote_user}
if method == "GET":
return _makeOne().get(
url, params=params, extra_environ=extra_environ)
elif method == "POST":
return _makeOne().post(
url, params=params, extra_environ=extra_environ)
def test_with_login():
result = _callAUT('/', remote_user='dummy')
assert result.body == 'OK'
def test_without_login():
result = _callAUT('/')
assert result.location == "/login"
_callAUT 方法对测试对象的调用进行了包装。传递给这个包装后的方
法的 remote_user 最后将通过 WebTest 的 extra_environ 被传递给
测试对象(WSGI 应用)的 environ。
8.3 通过测试改良设计
前面我们谈了一系列仅执行测试对象的测试。不知各位在写测试时是
否觉得举步维艰,是否把 mock 写得非常复杂?为什么测试会变得复
杂呢?要知道,组件之间的结合度越高,给测试做准备就越花时间,
测试也就越复杂。光是替换 mock 都已经要绞尽脑汁,怎么可能轻松
添加新功能呢?因此我们要根据测试结果来改良设计。
便于测试的设计
什么样的设计便于测试呢?首先结合度要低。模块间的关联越少,测
试起来就越简单。另外就是内聚度要高。模块做的事越少测试越简
单。
◉ 面向对象原则
面向对象程序设计有几个原则。遵守这些原则能让我们的设计更加便
于测试。当然,有时也要勇于打破它们。不过,如果没有特殊原因,
应尽量遵循面向对象原则进行设计。
○ 单一职责原则
保证一个对象只具有一项职责,一项职责只由一个对象来负责。如果
只顾眼前方便而盲目追求对象的通用性,就很容易生成大得吓人的
类。所以我们应根据单一职责原则,将正确的方法放到正确的类中。
名如 HogeManager 的类往往都是未经过整理的类。给类起这种名字自
然无妨,但具体的处理最好别写在 HogeManager 中。具体的处理应该
在 Model 中实现,然后让 HogeManager 只负责调用即可。这样做有
助于提高内聚度。
○ 接口隔离原则
即不依赖于没有必要的接口。Python 的语法中没有接口的概念,但仍
然会遇到类似问题。比如一旦我们进行了类检查,那么传值参数将只
能接受该类或其子类。然而 Python 支持鸭子类型(Duck Typing),
非子类也可以替换使用,所以最好不要做类型检验,而是根据类内是
否存在某方法来判定传值参数,从而使模型结构更加灵活。
○ 开放封闭原则
对扩展开放,对修改封闭。一个模块的修改不带来额外的派生,同时
模块具有可扩展性。将某个类的对象替换为其子类的对象时,不需在
扩展的基础上再修改其他模块。
这类替换必须遵循特定条件,并不是说用类和继承随时都能实现。
○ 里氏替换原则
子类替换父类的过程对调用方透明。这要求我们在定义子类时,宽松
对待其能接受的内容,严格把关其返回的内容。
在下述情况下,父类不可以替换为子类。
子类会发生父类不允许发生的例外
子类不接受父类已有的参数
子类返回父类不返回的值
父类中实现的内容在子类中被重载,但并未实现相关处理
如果只是想加强代码的通用性,那么应该尽量重视复用,少用继承。
○ 复用优先于继承
继承是一种紧密的结合,因为我们无法将子类与父类彻底分离。复用
则不同,它也是对已有代码的一种再利用,但不会产生继承关系。另
外,复用允许我们在测试时用虚拟对象替换原对象。从对象角度看,
父类与子类属于同一个东西,从模块角度看则不然。
NOTE
面向对象程序设计的原则并不止这 5 个。本章仅是从活用测试结
果的角度挑选了几个加以说明。
8.4 推进测试自动化
测试要做到让任何人都能随时执行。因为这种任何人都能随时执行的
测试可以自动化。后面的章节中我们将介绍 CI 工具(Continuous
Integration Tool,持续集成工具),它能够在我们向版本库提交代
码时自动执行测试,并返回一个规整的测试结果报告。
执行测试和提交测试代码通常都是开发者的工作。此时各位不妨回想
一下昨天的情况。测试用例增加了吗?测试用例中通过测试的比例变
了吗?覆盖率怎么样?
自动化的 CI 工具会为我们持续保存这些测试结果。它能帮助我们更
有效地利用测试结果,并且可以使用已提交至版本库的正式代码进行
测试(毕竟谁都难免有忘记提交的时候),从而及早发现源码乃至开
发效率上的种种问题。
CI 工具的用法我们放到后面的章节再学习。本章剩余的部分,我们用
来给 CI 工具铺路,学习一下如何搭建让任何人都能随时执行测试的
环境。
8.4.1 用 tox 自动生成执行测试的环境
tox 能便捷地为我们准备好执行测试所需的环境。tox 会在多个
virtualenv 环境中搭建测试环境,然后在这些环境中执行测试并显示
结果。它能够把测试工具的选项及环境变量等内容统一起来,所以我
们只需执行 tox 命令即能轻松完成所需的测试。
如下所示,可以用 pip 安装 tox。
$ pip install tox
安装完成后,tox 命令就能用了。执行 tox 时需要用到 tox.ini
文件,我们要在这个文件中描述测试环境的设置。各个环境的设置由
所用 Python 的版本、环境变量、测试所需的库、执行测试的代码等
组成。
下面是 tox.ini 文件的一个例子。
[tox]
envlist = py26,py27,flake8
skipsdist = true
[testenv]
deps = pytest
webtest
testfixtures
mock
-rrequirements.txt
commands = py.test
[testenv:flake8]
basepython = python2.7
deps = flake8
commands = flake8
[tox] 节的 envlist 用于设置测试环境列表。这里我们用到了
py27、py26 和 flake8 这 3 个环境。py27 是个特殊的名字,它会找
出当前已安装的 python2.7 命令并生成 virtualenv。
另外,我们在 skipsdist 选项中进行了设置,保证即使没有
setup.py 也能执行测试。在没有 setup.py 的情况下,依赖库由
requirements.txt 等进行管理。关于依赖库的管理,我们将在 9.2
节再次进行学习。
[testenv] 节用来进行测试环境的设置。如果存在 [testenv:flake8]
这种指定了环境名的节,则优先采用该节的设置。环境未被特别指定
时,使用 [testenv] 节的通用设置。
在这个 tox.ini 文件所在的目录下执行 tox 之后,以下测试将会被
执行。
在 python2.6 生成的 virtualenv 内执行 py.test
在 python2.7 生成的 virtualenv 内执行 py.test
在 python2.7 生成的 virtualenv 内执行 flake8
另外,使用 -e 选项可以对指定环境进行测试。
$ tox -e py27
$ tox -e flake8
可见,tox 能将多种不同的测试执行方法统一成一个。另外,由于每
个测试都被分离在各自的 virtualenv 内,所以更改一个测试的设置
不会对其他测试造成影响。用 Python 2.6 和 2.7 两个版本进行测试
就是很好的例子。不仅如此,即便是 Web 应用框架等大程序库的不同
版本测试,tox 同样能发挥作用。
8.4.2 可重复使用的测试环境
要想让测试能够重复进行,必须能够随时准备出完全相同的环境。如
果上一次测试生成的文件或数据残留在环境中,很可能会对新一次测
试造成影响。所以测试结束后,千万记得要用 tearDown 方法清理测
试环境。另外,考虑到前一次测试可能由于出错等原因以中断的形式
残留在里面,最好在测试开始时和结束时都调用一遍清理方法。清理
的对象包括测试用数据库、目录、外部系统的过程等。
8.5 小结
本章讲解了便于测试的设计方法以及高效的测试手法。高效的测试具
备以下特点。
测试对象明确
测试要检查的内容明确
可任意次重复执行
进行这类测试必须剔除环境依赖。本章也一直是这样做的,让测试对
象不依赖于环境,仅执行测试对象。
测试同样能验证应用的正确性,有助于品质的提升。不仅如此,便于
测试的设计拥有低结合高内聚的特点,通过将各自独立的组件组合在
一起来实现功能。充分独立的组件所组成的结构有着很好的可维护性
和可扩展性。
兼顾到测试的设计可以为我们带来如此多的好处,何乐而不为呢?