在 Python 中使用 mock

使用 mock 安全地测试你的代码。
1 位读者喜欢这篇文章。
Woman sitting in front of her laptop

kris krüg

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 和这个单元测试是会帮助你还是会阻碍你。

标签
Moshe sitting down, head slightly to the side. His t-shirt has Guardians of the Galaxy silhoutes against a background of sound visualization bars.
Moshe 自 1998 年以来一直参与 Linux 社区,帮助举办 Linux “安装派对”。他自 1999 年以来一直在编写 Python 程序,并为核心 Python 解释器做出了贡献。Moshe 在 DevOps/SRE 这些术语出现之前就一直是 DevOps/SRE,他非常关心软件可靠性、构建可重现性以及其他此类事情。

评论已关闭。

Creative Commons License本作品根据 Creative Commons Attribution-Share Alike 4.0 International License 获得许可。
© . All rights reserved.