5分钟内“不要嘲笑你不拥有的东西”

为实际软件编写测试时的一个常见问题是如何处理第三方依赖项。让我们研究一个古老但违反直觉的原则。

曾几何时,我在 Twitter 上开了一个关于“不要嘲笑你不拥有的东西”测试原则的愚蠢笑话:

只嘲笑你拥有的东西,因为嘲笑别人是粗鲁的。

-你真的, 推特

虽然它没有让我被解雇,但它导致我被骗做了一个 5 分钟长的演讲关于它。鉴于我对这个笑话 Tweet 的回答很混乱,我认为值得为后代写下谈话的内容。

原则

不要模拟你不拥有的东西意味着每当你使用模拟对象时,你应该用它们来替换你自己的对象,而不是第三方的.

如果你和我一样,那是没有意义的!我还应该嘲笑什么?我的代码是完全可测试的!

关键点:拥有一个对象和拥有使用它的API是有区别的。

请允许我使用一个简单的示例来演示模拟第三方对象的缺点以及该怎么做。

免责声明:我不喜欢原始意义上的模拟:一个测试对象,它模仿另一个(通常是复杂的)对象,记录**对其 API 的调用,并允许您对这些调用进行断言。

但是,这个原则仍然有用,因为它同样适用于任何其他类型的测试对象(如果您对mocks、fakes、stubs等之间的差异感到困惑,我推荐 Martin Fowler 的Mocks Aren't Stubs)。我将在本文中继续使用术语mock以与原理的名称保持一致,并且我将在我的示例中使用一个流行的 mocking 库来熟悉。

但是,我根本不在自己的代码中使用模拟。在 Python 中,我将假装用于简单的存根,将经过验证的假货用于更复杂的场景。在围棋中,我总是找到经过验证的假货。

这不是potayto potahto - 这是如何进行测试的根本区别,但它超出了本文的范围。

一个 Docker 存储库客户端

为了使我的示例简短,我将使用一个使用 HTTP 库的 Python 程序,但问题和解决方案对于任何面向对象的语言都是通用的。

如果您曾经运行过自己的Docker容器注册表,那么您很有可能已经围绕其Web API编写了脚本。例如,您可能想要打印出您的存储库列表,以及每个存储库中存在的标签列表:

$ list-docker-repos-with-tags
web-svc 1, 5, 7
worker-svc 8, 10, 11

这是我们示例的灵感来源。如果你不知道这意味着什么:别担心。您需要了解的是,我们正在编写一个程序,该程序向 Web API 发出 HTTP 请求并从响应中提取数据。

粗鲁的嘲弄

我将略过一点,从一个已经可以测试的函数开始。它需要一个 HTTP 客户端(在本例中,我使用出色的httpx包)并返回一个存储库名称字典,该字典指向版本标记列表。从 CLI 调用这个函数并打印出它的返回值对我们来说并不有趣,所以我把它省略了:

def get_repos_w_tags(client):
    rv = {}
    repos = client.get(
        "https://docker.example.com/v2/_catalog"
    ).json()["repositories"]
​
    for repo in repos:
        rv[repo] = client.get(
            f"https://docker.example.com/v2/{repo}/tags/list"
        ).json()["tags"]
​
    return rv

首先,我们从_catalog端点获取存储库名称列表,然后遍历它们并获取每个存储库的标签列表。

这比为这种情况编写的大量代码要好得多!许多人会对所有内容进行硬编码,如果需要对其进行测试,他们会耸耸肩,开始打猴子补丁。使用上面的代码,您可以“简单地”传入一个假client对象,该对象返回预定义的静态值以进行调用client.get()并查看它返回的字典。看,没有网络!

然而,它违反了本文所要讲述的原则。为什么?

因为不简单。让我们看一下最简单的测试,如果第一次client.get()调用返回一个空的repositories键列表会发生什么:

from unittest.mock import Mock
import httpx
​
def test_empty():
    client = Mock(
        spec_set=httpx.Client,
        get=Mock(
            return_value=Mock(
                spec_set=httpx.Response,
                json=lambda: {
                    "repositories": []
                },
            )
        ),
    )
​
    assert {} == get_repos_w_tags(client)

我们需要三层模拟来验证一个空repositories键会导致一个空字典。如果我不使用 alambda作为json函数,它甚至会是四层。

我使用spec_set参数(as should you)来防止unittest.mock.Mock在您访问任何属性时愉快地返回新的模拟,但是像这样的构造往往是脆弱的并且难以创建和调试。

这是一个业务逻辑测试,测试的目的是模仿可以随时更改的 HTTP 客户端的 API 所必需的样板文件。

这使得测试变得脆弱和不习惯。当我阅读业务逻辑测试时,我希望测试的意图一目了然。

您可以编写一个帮助程序来创建执行您想要的模拟客户端,从而为您节省单个测试中的样板文件。但是,您的测试越复杂,涉及的模拟越多越复杂,您就越无法确定您实际测试的是什么。最终,你最终进入了模拟地狱。

以我的经验,这是错误的道路,所以让我们尝试不同的方法。

礼貌的嘲讽

计算机科学中的所有问题都可以通过另一个层次的间接来解决。

— Butler Lampson, 软件工程基本定理

我们将遵循 Lampson 先生的建议,并在 HTTP 库周围添加一个非常薄的层,它成为您干净的代码和混乱的外部世界之间的门面。众所周知,像这样的层很难测试,所以它们应该尽可能简单地保持循环:在条件和循环上放轻松。否则,您只需将众所周知的测试踢下一层而一无所获。

在这种情况下,我们编写了一个DockerRegistryClient类,它提供了两个方法,它们的实现看起来应该很熟悉:get_repos()返回存储库名称列表和get_repo_tags()返回存储库标签列表。代码和之前一样:

class DockerRegistryClient:
    def __init__(self, client):
        self._client = client
​
    def get_repos(self):
        return self._client.get(
            "https://docker.example.com/v2/_catalog"
        ).json()["repositories"]
​
    def get_repo_tags(self, repo):
        return self._client.get(
             f"https://docker.example.com/v2/{repo}/tags/list"
        ).json()["tags"]

我希望这段代码没有意外,所以让我们将它应用到我们的业务代码中:

def get_repos_w_tags_drc(drc):
    rv = {}
    for repo in drc.get_repos():
        rv[repo] = drc.get_repo_tags(repo)
​
    return rv

第一个回报是业务逻辑更加地道!看到业务逻辑写得这么清楚,也说明它可以重写为字典理解!你永远不知道让代码更清晰、更惯用的东西会给你带来什么。

这一点怎么强调都不为过:为了通过遵循自古以来违反直觉的原则来简化我们的模拟,我们改进了我们的业务逻辑。

业务逻辑最终是您编写软件的原因。这是应用程序存在的原因。只要软件存在(总是比您想象的要长),拥有干净和惯用的业务逻辑就会带来好处。

接下来让我们重写测试:

def test_empty_drc():
    drc = Mock(
        spec_set=DockerRegistryClient,
        get_repos=lambda: []
    )
​
    assert {} == get_repos_w_tags_drc(drc)

只有一个Mock!一看就知道发生了什么!

一旦你想运行更复杂的测试并get_repos()返回一个非空列表并且你也需要模拟get_repo_tags(),这会容易得多,因为你所要做的就是添加另一行get_repo_tags=lambda repo: …. 不需要更多嵌套的模拟,也不需要为client.get()不同的调用返回不同的值。

如果您选择替换您的 HTTP 库,您的业务测试将不在乎,因为它们只与您的抽象接口。

推论:为了保持您的业务代码可测试和惯用,避免在其中直接使用第三方依赖项。

在线图片批量处理工具(imagestool.com),支持在线多功能图片转换!

什么时候打破规则?

一旦你完全理解了它的目的,每一条规则和原则都可以被打破。例如,如果一个对象已经有一个惯用的 API,那么它可能不值得包装在一个相同的外观中,因为它属于你。

对于像这篇博文中这样的简单程序,编写一个创建适当的假 HTTP 客户端的测试助手可能更容易。尽管更惯用的业务代码有好处!在某些时候,这成为对复杂性和性能的权衡。

有时,通过在测试中运行您自己的进程内 HTTP 服务器来伪造实际的 HTTP 响应也很容易——但我更喜欢在测试薄外层时隔离这些类型的测试。一旦您必须与不透明的 SOAP 服务器或 CLI 实用程序交互,它也会变得更加复杂。取舍,取舍。

当我打破这个原则时,最常见的情况是当我需要模拟有机地创建的并非微不足道的错误时:某些网络条件、超时、完整性错误……

最后,它不是规则,而是启发式。

延伸阅读

要了解有关该原理的更多信息,请查看That's Not Yours,它采用了更加以数据库为中心的方法以及TestDouble的 wiki中的此摘要。

与最近的许多事情一样,这篇文章的灵感来自于精彩的 Python 架构模式——这本书不断将我的直觉转化为前所未有的原则。去阅读它(如果你愿意,可以在网页上免费阅读)。Harry 还就这个主题写了一篇更广泛的论文,题为为外部 API 调用编写测试,提供了更多的想法和方法

你可能感兴趣的:(分享)