4 月 1 日是关于虚假故事和假装的日子。这使得今天成为讨论 mock 的完美日子。
有时,使用真实对象是困难的、不明智的或复杂的。例如,requests.Session
连接到真实的网站。在单元测试中使用它会带来...很多...问题。
Python 中的基本 mock
“Mock” 是单元测试的概念。它们生成作为真实对象替代品的对象。
from unittest import mock
有一个完整的家庭手工业会解释说,“mock”、“fake”和“stub”都有细微的差别。在本文中,我互换使用这些术语。
regular = mock.MagicMock()
def do_something(o):
return o.something(5)
do_something(regular)
此代码产生
<MagicMock name='mock.something()' id='140228824621520'>
Mock 拥有所有方法。这些方法通常返回另一个 Mock。这可以通过将其分配给 return_value
来更改。
例如,假设你想调用以下函数
def do_something(o):
return o.something() + 1
它需要具有 .something()
方法的东西。幸运的是,mock 对象拥有它
obj = mock.MagicMock(name="an object")
obj.something.return_value = 2
print(do_something(obj))
答案
3
也可以覆盖“魔术方法”
a = mock.MagicMock()
a.__str__.return_value = "an a"
print(str(a))
答案
an a
Spec
通过使用 spec 确保 mock 没有“额外”的方法或属性。例如,这里有一些应该失败的代码
import pathlib
def bad_pathlib_usage(path):
## TYPO: missing underscore
path.writetext("hello")
dummy_path = mock.MagicMock(spec=pathlib.Path)
try:
bad_pathlib_usage(dummy_path)
except Exception as exc:
print("Failed!", repr(exc))
结果
Failed! AttributeError("Mock object has no attribute 'writetext'")
Mock 副作用
有时,拥有一个每次都返回相同内容的 MagicMock
并不完全是你所需要的一切。例如,sys.stdin.readline()
通常返回不同的值,而不是在整个测试中都返回相同的值。
属性 side_effect
允许比使用 return_value
更详细地控制 magic mock 返回的内容。
可迭代对象
可以分配给 side_effect
的东西之一是可迭代对象,例如序列或生成器。
这是一个强大的功能。它允许使用少量代码控制每次调用的返回值。
different_things = mock.MagicMock()
different_things.side_effect = [1, 2, 3]
print(different_things())
print(different_things())
print(different_things())
输出
1
2
3
一个更实际的例子是模拟文件输入时。在这种情况下,我希望能够控制 readline
每次返回的内容,以假装它是文件输入
def parse_three_lines(fpin):
line = fpin.readline()
name, value = line.split()
modifier = fpin.readline().strip()
extra = fpin.readline().strip()
return {name: f"{value}/{modifier}+{extra}"}
from io import TextIOBase
filelike = mock.MagicMock(spec=TextIOBase)
filelike.readline.side_effect = [
"thing important\n",
"a-little\n",
"to-some-people\n"
]
value = parse_three_lines(filelike)
print(value)
结果
{'thing': 'important/a-little+to-some-people'}
异常
另一种可能的事情是将异常分配给 side_effect
属性。这会导致调用引发你分配的异常。使用此功能可以模拟环境中的边缘条件,通常正是那些
- 你关心的
- 难以真实地模拟
一个常见的例子是网络问题。根据墨菲定律,它们总是在凌晨 4 点发生,导致寻呼机响起,而永远不会在你早上 10 点坐在办公桌前时发生。以下内容基于我编写的用于测试网络服务的真实代码。
在这个简化的例子中,代码返回响应行的长度,如果达到超时,则返回负数。这个数字根据协议协商中达到的时间点而有所不同。这允许代码区分“连接超时”和“响应超时”,例如。
针对真实服务器测试这段代码很困难。服务器努力避免中断!你可以 fork 服务器的 C 代码并添加一些混乱,或者你可以只使用 side_effect
和 mock
import socket
def careful_reader(sock):
sock.settimeout(5)
try:
sock.connect(("some.host", 8451))
except socket.timeout:
return -1
try:
sock.sendall(b"DO THING\n")
except socket.timeout:
return -2
fpin = sock.makefile()
try:
line = fpin.readline()
except socket.timeout:
return -3
return len(line.strip())
from io import TextIOBase
from unittest import mock
sock = mock.MagicMock(spec=socket.socket)
sock.connect.side_effect = socket.timeout("too long")
print(careful_reader(sock))
结果是失败,但在这种情况下,这意味着测试成功
-1
通过仔细的副作用,你可以获得每个返回值。例如
sock = mock.MagicMock(spec=socket.socket)
sock.sendall.side_effect = socket.timeout("too long")
print(careful_reader(sock))
结果
-2
可调用对象
前面的例子是简化的。真实的网络服务测试代码必须验证它获得的结果是否正确,以验证服务器是否正常工作。这意味着进行合成请求并查找正确的结果。mock 对象必须模拟这一点。它必须对输入执行一些计算。
尝试在不执行任何计算的情况下测试此类代码是困难的。测试往往过于不敏感或过于“不稳定”。
- 不敏感的测试是指在存在 bug 的情况下不会失败的测试。
- 不稳定的测试是指即使代码正确,有时也会失败的测试。
在这里,我的代码是不正确的。不敏感的测试不会捕获它,而不稳定的测试即使在修复后也会失败!
import socket
import random
def yolo_reader(sock):
sock.settimeout(5)
sock.connect(("some.host", 8451))
fpin = sock.makefile()
order = [0, 1]
random.shuffle(order)
while order:
if order.pop() == 0:
sock.sendall(b"GET KEY\n")
key = fpin.readline().strip()
else:
sock.sendall(b"GET VALUE\n")
value = fpin.readline().strip()
return {value: key} ## Woops bug, should be {key: value}
以下内容将过于“不敏感”,无法检测到 bug
sock = mock.MagicMock(spec=socket.socket)
sock.makefile.return_value.readline.return_value = "interesting\n"
assert yolo_reader(sock) == {"interesting": "interesting"}
以下内容将过于“不稳定”,即使 bug 不存在,有时也会检测到 bug
for i in range(10):
sock = mock.MagicMock(spec=socket.socket)
sock.makefile.return_value.readline.side_effect = ["key\n", "value\n"]
if yolo_reader(sock) != {"key": "value"}:
print(i, end=" ")
例如
3 6 7 9
从 mock 对象获取结果的最终选项是将可调用对象分配给 side_effect
。这将调用 side_effect
以简单地调用它。为什么不直接将可调用对象分配给属性?请耐心等待,我将在下一部分中介绍这一点!
在这个例子中,我的可调用对象(只是一个函数)将 return_value
分配给另一个对象的属性。这并不罕见。我正在模拟环境,在真实环境中,拨弄一件事通常会影响其他事情。
sock = mock.MagicMock(spec=socket.socket)
def sendall(data):
cmd, name = data.decode("ascii").split()
if name == "KEY":
sock.makefile.return_value.readline.return_value = "key\n"
elif name == "VALUE":
sock.makefile.return_value.readline.return_value = "value\n"
else:
raise ValueError("got bad command", name)
sock.sendall.side_effect = sendall
print(yolo_reader(sock), dict(key="value"))
结果
{'value': 'key'} {'key': 'value'}
Mock 调用参数:代码的 X 射线
在编写单元测试时,你“远离”代码,但试图窥探其内部,看看它的行为方式。Mock 对象是你偷偷摸摸的间谍。在它进入生产代码后,它忠实地记录一切。这就是你如何找到你的代码做了什么以及它是否正确的方法。
调用计数
最简单的事情是确保代码被调用的次数符合预期。.call_count
属性正是用于计数的。
def get_values(names, client):
ret_value = []
cache = {}
for name in names:
# name = name.lower()
if name not in cache:
value = client.get(f"https://httpbin.org/anything/grab?name={name}").json()['args']['name']
cache[name] = value
ret_value.append(cache[name])
return ret_value
client = mock.MagicMock()
client.get.return_value.json.return_value = dict(args=dict(name="something"))
result = get_values(['one', 'One'], client)
print(result)
print("call count", client.get.call_count)
结果
['something', 'something']
call count 2
检查 .call_count >= 1
而不是检查 .called
的一个好处是,它更能抵抗愚蠢的错别字。
def call_function(func):
print("I'm going to call the function, really!")
if False:
func()
print("I just called the function")
func = mock.MagicMock()
call_function(func)
print(func.callled) # TYPO -- Extra "l"
I'm going to call the function, really!
I just called the function
<MagicMock name='mock.callled' id='140228249343504'>
勤奋地使用 spec
可以防止这种情况。但是,spec
不是递归的。即使原始 mock 对象具有 spec,也很少有测试会确保它拥有的每个属性也具有 spec。但是,使用 .call_count
而不是 .called
是一个简单的技巧,可以完全消除犯此错误的机会。
调用参数
在下一个示例中,我确保代码使用正确的参数调用该方法。在自动化数据中心操作时,正确处理事情非常重要。正如他们所说,“人非圣贤孰能无过,但要摧毁整个数据中心需要一个带有 bug 的机器人。”
我们想确保我们基于 Paramiko 的自动化正确获取文件大小,即使文件名中包含空格。
def get_remote_file_size(client, fname):
client.connect('ssh.example.com')
stdin, stdout, stderr = client.exec_command(f"ls -l {fname}")
stdin.close()
results = stdout.read()
errors = stderr.read()
stdout.close()
stderr.close()
if errors != '':
raise ValueError("problem with command", errors)
return int(results.split()[4])
fname = "a file"
client = mock.MagicMock()
client.exec_command.return_value = [mock.MagicMock(name=str(i)) for i in range(3)]
client.exec_command.return_value[1].read.return_value = f"""\
-rw-rw-r-- 1 user user 123 Jul 18 20:25 {fname}
"""
client.exec_command.return_value[2].read.return_value = ""
result = get_remote_file_size(client, fname)
assert result == 123
[args], kwargs = client.exec_command.call_args
import shlex
print(shlex.split(args))
结果
['ls', '-l', 'a', 'file']
糟糕!那不是正确的命令。幸好你检查了参数。
深入了解 mock
Mock 具有强大的功能。就像任何强大的工具一样,不当使用它会很快陷入困境。但是,通过正确使用 .return_value
、.side_effect
和各种 .call*
属性,可以编写最好的单元测试。
好的单元测试是能够
- 在存在不正确的代码时失败
- 在存在正确的代码时通过
“质量”不是二元的。它存在于一个光谱中。单元测试的坏程度由以下因素决定
- 它放过了多少错误。这是一个“漏报”或“假阴性”。如果你是统计学家,那就是“II 类错误”。
- 它使多少正确的代码更改失败。这是一个“误报”或“假阳性”。如果你是统计学家,那就是“I 类错误”。
当使用 mock 时,花时间思考这两个指标,以评估这个 mock 和这个单元测试是会帮助你还是会阻碍你。
评论已关闭。