- 引言
- 本书涉及的内容
- 第 1 部分 Python 开发入门
- 第 1 章 Python 入门
- 第 2 章 开发 Web 应用
- 第 3 章 Python 项目的结构与包的创建
- 第 4 章 面向团队开发的工具
- 第 5 章 项目管理与审查
- 第 6 章 用 Mercurial 管理源码
- 第 7 章 完备文档的基础
- 第 8 章 模块分割设计与单元测试
- 第 9 章 Python 封装及其运用
- 第 10 章 用 Jenkins 持续集成
- 第 11 章 环境搭建与部署的自动化
- 第 12 章 应用的性能改善
- 第 13 章 让测试为我们服务
- 第 14 章 轻松使用 Django
- 第 15 章 方便好用的 Python 模块
- 附录 A VirtualBox 的设置
- 附录 B OS(Ubuntu)的设置
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 33331 所倡导的机制。
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): 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。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论