代码覆盖率(code coverage)是一个非常有用的度量标准,它提供了有关项目代码的
测试的客观信息。它仅仅是在所有测试执行期间执行多少和哪些代码行的测量。它通常表示为百分比,100%覆盖意味着每个代码行都在测试期间执行。
最流行的代码覆盖工具被称为 simply coverage,并在 PyPI 上免费提供。使用非
常简单,只包括两个步骤。第一步是在 shell 中运行 coverage run 命令,并将脚本/程序
的路径作为参数运行所有测试:
$ coverage run --source . which py.test
-v
===================== test session starts ======================
platformdarwin – Python 3.5.1, pytest-2.8.7, py-1.4.31, pluggy-0.3.1 –
/Users/swistakm/.envs/book/bin/python3
cachedir: .cache
rootdir: /Users/swistakm/dev/book/chapter10/pytest, inifile:
plugins: capturelog-0.7, codecheckers-0.2, cov-2.2.1, timeout-1.0.0
collected 6 items
primes.py::pyflakes PASSED
primes.py::pep8 PASSED
test_primes.py::pyflakes PASSED
test_primes.py::pep8 PASSED
test_primes.py::test_is_prime_true PASSED
test_primes.py::test_is_prime_false PASSED
========= 6 passed, 1 pytest-warnings in 0.10 seconds ==========
coverage run 命令还接受指定可运行模块名称的-m 参数,而不是可能更适合某些
测试框架的程序路径,如下所示:
$ coverage run -m unittest
$ coverage run -m nose
$ coverage run -m pytest
下一步是从.coverage 文件中的生成一个可读的代码覆盖率报告。coverage 包支
持一些输出格式,最简单的只是在终端中打印 ASCII 表格,如下所示:
$ coverage report
Name StmtsMiss Cover
primes.py 7 0 100%
test_primes.py 16 0 100%
TOTAL 23 0 100%
还可以生成 HTML 格式的覆盖率报告,你可以在 Web 浏览器中浏览:
$ coverage html
此 HTML 报告默认输出到你的工作目录下的 htmlcov/文件夹中。coverage html
命令输出的真正优势是,你可以浏览你项目中带有注释的源码,其中未被测试覆盖的代码
会高亮显示
你应该记住,虽然你应该始终努力确保 100%的测试覆盖率,但它绝不能保证代码被完
美测试,并且没有代码可以中断的地方。这意味着只有在执行期间才能达到每行代码,但
不一定每个可能的条件都被测试。在实践中,确保完全代码覆盖可能相对容易,但是确保
达到代码的每个分支是非常困难的。这对于具有 if 语句的包含多个组合的函数以及像
list/dict/set 生成式这样的特定语言结构的的测试尤其如此。你应该总是关心好的测
试覆盖率,但你不应该把它的测量作为最好的测试套件的最终答案。
仿真与模拟
写单元测试的前提是要隔离被测试的代码单元。测试通常将一些数据传入函数或方法,
并验证其返回值且/或其执行的副作用,这主要是为了确保测试。
● 涉及应用程序的原子部分,可以是函数、方法、类或接口。
● 提供确定的,可重现的结果。
有时,程序组件之间的适当隔离并不明显。例如,发送电子邮件的代码,它可能会调
用 Python 的 smtplib 模块,这将通过网络连接与 SMTP 服务器工作。如果我们想要我们的
测试是可重现的,并且只是测试电子邮件是否具有所需的内容,那么这可能无法做到。理
想情况下,单元测试应该在没有外部依赖性和副作用的计算机上运行。
由于 Python 的动态特性,可以使用猴子补丁(monkey patching)在测试固件中修改运行时
代码(即在运行时动态修改软件而不触及源代码),用以仿真(fake)第三方代码或库的行为。
创建一个仿真
通过发现测试代码与外部部件一起使用所需的最小交互集,可以创建测试中的仿真行
为。然后,手动返回输出或使用先前已记录的真实数据池。
这是通过启动一个空类或函数并将其用作为一个替换来完成的。然后启动测试,并且迭代
更新仿真,直到它正确运行。这可能是由于 Python 类型系统的特性。只要这个对象的行为像
期望的类型,那它就会被认为与给定的类型兼容,并且不需要通过子类化它的祖先。这种在
Python 中输入的方法称为鸭子类型——如果某个行为像鸭子一样,就可以像鸭子一样对待它。
让我们举以下一个例子,mailer 模块中调用发送电子邮件的 send 函数:
import smtplib
import email.message
def send(
sender, to,
subject=‘None’,
body=‘None’,
server=‘localhost’
):
“”“发送一条信息”“”
message = email.message.Message()
message[‘To’] = to
message[‘From’] = sender
message[‘Subject’] = subject
message.set_payload(body)
server = smtplib.SMTP(server)
try:
return server.sendmail(sender, to, message.as_string())
finally:
server.quit()
相应的测试为:
from mailer import send
def test_send():
res = send(
‘john.doe@example.com’,
‘john.doe@example.com’,
‘topic’,
‘body’
)
assert res == {}
只要本地主机上有 SMTP 服务器,此测试将通过并运行。如果没有,它会失败,像下面
这样:
$ py.test --tb=short
========================= test session starts =========================
platform darwin – Python 3.5.1, pytest-2.8.7, py-1.4.31, pluggy-0.3.1
rootdir: /Users/swistakm/dev/book/chapter10/mailer, inifile:
plugins: capturelog-0.7, codecheckers-0.2, cov-2.2.1, timeout-1.0.0
collected 5 items
mailer.py …
test_mailer.py …F
============================== FAILURES ===============================
______________________________ test_send ______________________________
test_mailer.py:10: in test_send
‘body’
mailer.py:19: in send
server = smtplib.SMTP(server)
…/smtplib.py:251: in init
(code, msg) = self.connect(host, port)
…/smtplib.py:335: in connect
self.sock = self._get_socket(host, port, self.timeout)
…/smtplib.py:306: in _get_socket
self.source_address)
…/socket.py:711: in create_connection
raise err
…/socket.py:702: in create_connection
sock.connect(sa)
E ConnectionRefusedError: [Errno 61] Connection refused
======== 1 failed, 4 passed, 1 pytest-warnings in 0.17 seconds ========
可以添加一个补丁来仿真 SMTP 类:
import smtplib
import pytest
from mailer import send
class FakeSMTP(object):
pass
@pytest.yield_fixture()
def patch_smtplib():
设置步骤: smtplib 的猴子补丁
old_smtp = smtplib.SMTP
smtplib.SMTP = FakeSMTP
yield
卸载步骤: 将 smtplib 恢复到它先前的状态
smtplib.SMTP = old_smtp
def test_send(patch_smtplib):
res = send(
‘john.doe@example.com’,
‘john.doe@example.com’,
‘topic’,
‘body’
)
assert res == {}
在上面的代码中,我们使用了一个新的 pytest.yield_fixture()装饰器。它允许
我们使用生成器语法在单个固件函数中提供设置和卸载过程。现在我们的测试套件可以使
用 smtplib 的修补版本再次运行,如下所示:
$ py.test --tb=short -v
======================== test session starts ========================
platform darwin – Python 3.5.1, pytest-2.8.7, py-1.4.31, pluggy-0.3.1 –
/Users/swistakm/.envs/book/bin/python3
cachedir: .cache
rootdir: /Users/swistakm/dev/book/chapter10/mailer, inifile:
plugins: capturelog-0.7, codecheckers-0.2, cov-2.2.1, timeout-1.0.0
collected 5 items
mailer.py::pyflakes PASSED
mailer.py::pep8 PASSED
test_mailer.py::pyflakes PASSED
test_mailer.py::pep8 PASSED
test_mailer.py::test_send FAILED
============================= FAILURES ==============================
_____________________________ test_send _____________________________
test_mailer.py:29: in test_send
‘body’
mailer.py:19: in send
server = smtplib.SMTP(server)
E TypeError: object() takes no parameters
======= 1 failed, 4 passed, 1 pytest-warnings in 0.09 seconds =======
从上面的文字记录中可以看到,我们的 FakeSMTP 类的实现并不完整。我们需要更新
其接口以匹配原始的 SMTP 类。根据鸭式类型原则,我们只需要提供测试的 send()函数
所需的接口,如下所示:
class FakeSMTP(object):
def init(self, *args, **kw):
这个例子中的参数并不重要
pass
def quit(self):
pass
def sendmail(self, *args, **kw):
return {}
当然,仿真类可以随着新的测试的演变来提供更复杂的行为。但它应该尽可能短小和
简单。相同的原则可以用于更复杂的输出,通过记录它们作为仿真 API 返回。这通常用于
第三方服务器,例如 LDAP 或 SQL。
重要的是要知道,当猴子补丁用于任何内置或第三方模块时应该特别注意。如果处理
不当,这种方法可能会留下不必要的副作用,这将在测试之间传播。幸运的是,许多测试
框架和工具提供了适当的实用程序,使任何代码单元的补丁更加安全和容易。在我们的示
例中,我们手动执行了一切,并提供了一个自定义的 patch_smtplib()固件函数,具有
单独的设置和卸载步骤。py.test 中的典型解决方案要容易得多。这个框架配有一个内置的猴子补丁固件,它应该可以满足我们的大部分补丁需求,如下所示:
import smtplib
from mailer import send
class FakeSMTP(object):
def init(self, *args, **kw):
这个例子中的参数并不重要
pass
def quit(self):
pass
def sendmail(self, *args, **kw):
return {}
def test_send(monkeypatch):
monkeypatch.setattr(smtplib, ‘SMTP’, FakeSMTP)
res = send(
‘john.doe@example.com’,
‘john.doe@example.com’,
‘topic’,
‘body’
)
assert res == {}
你应该知道仿真确实也有局限性。如果你决定仿真一个外部依赖,你可能会引入 bug
或不必要的行为,而真实的服务器不会有,反之亦然。
使用模拟
模拟对象(mock objects)是可用于隔离所测试代码的通用仿真对象。它们自动化对象
的输入和输出的构建过程。在静态类型语言中也大量地使用模拟对象,其中猴子补丁更困
难,但是它们在 Python 中仍然有用,可以缩短代码以模仿外部 API。
Python 中有很多模拟库,但最常见的是 unittest.mock,标准库中也提供了该库。
创建之初,它是一个第三方包,并不是 Python 分发版的一部分,但很快就作为临时包加入
标准库中(参考 https://docs.python.org/dev/glossary.html#term-provisional-api)。对于 3.3 以
前的 Python 版本,你需要从 PyPI 安装:
pip install Mock
在我们的示例中,使用 unittest.mock 修补 SMTP 比从头开始创建更简单,如下所示:
import smtplib
from unittest.mock import MagicMock
from mailer import send
def test_send(monkeypatch):
smtp_mock = MagicMock()
smtp_mock.sendmail.return_value = {}
monkeypatch.setattr(
smtplib, ‘SMTP’, MagicMock(return_value=smtp_mock)
)
res = send(
‘john.doe@example.com’,
‘john.doe@example.com’,
‘topic’,
‘body’
)
assert res == {}
模拟对象或方法的 return_value 参数允许你定义调用返回的值。当使用模拟对象
时,每次代码调用某个属性时,它都会为该属性即时创建一个新的模拟对象。因此,没有
抛出异常。这是我们之前写的退出方法的情况(例如),不需要再定义。
在前面的例子中,我们实际上创建了两个模拟。
● 第一个模拟 SMTP 类对象而不是其实例。这允许你轻松创建新对象,而不考虑所
期望的__init__()方法。如果被视为可调用,Mocks 默认返回新的 Mock()对
象。这就是为什么我们需要提供另一个模拟作为其 return_value 关键字参数来
控制实例接口。
● 第二个模拟的是修补过的 smtplib.SMTP()调用中返回的实际实例。在这个模拟
中,我们控制 sendmail()方法的行为。
在我们的例子中,我们使用了 py.test 框架中的 monkey-patching 实用程序,但
unittest.mock 提供了自己的修补实用程序。在某些情况下(例如修补类对象),使用它
们而不是特定于框架的工具可能更简单并且更快。这里是使用由 unittest.mock 模块提
供的 patch()上下文管理器的猴子补丁的示例如下:
from unittest.mock import patch
from mailer import send
def test_send():
with patch(‘smtplib.SMTP’) as mock:
instance = mock.return_value
instance.sendmail.return_value = {}
res = send(
‘john.doe@example.com’,
‘john.doe@example.com’,
‘topic’,
‘body’
)
assert res == {}