Python 单元测试和重构详解

本章探讨的思想是本书全球背景下的基本支柱,因为它们对我们的最终目标非常重要:编写更好、更可维护的软件。

单元测试(以及任何形式的自动测试)对于软件的可维护性至关重要,因此任何质量项目都不能缺少它。正是由于这个原因,本章专门讨论了自动化测试的各个方面,作为一种关键策略,它可以安全地修改代码,并在增量更好的版本中对代码进行迭代。

在本章之后,我们将对以下内容有更多的了解:

在前面的章节中,我们已经看到了 Python 特有的特性,以及如何利用它们来实现更易于维护的代码。我们还探讨了如何利用 Python 的特性将软件工程的一般设计原则应用于 Python。在这里,我们还将回顾软件工程的一个重要概念,例如自动测试,但使用了一些工具,其中一些工具在标准库中可用(例如unittest 模块),另一些工具是外部包(例如pytest。我们从探索软件设计与单元测试的关系开始这段旅程。

在本节中,我们将首先从概念的角度来看单元测试。我们将回顾上一章中讨论的一些软件工程原理,以了解这与干净代码的关系。

之后,我们将更详细地讨论如何将这些概念付诸实践(在代码级别),以及我们可以使用哪些框架和工具。

首先,我们快速定义单元测试是关于什么的。单元测试是负责验证代码其他部分的代码。通常,任何人都会倾向于说单元测试验证应用程序的“核心”,但这样的定义将单元测试视为次要的,这不是本书中所考虑的方式。单元测试是软件的核心和关键组件,应该与业务逻辑一样考虑它们。

单元测试是一段代码,它使用业务逻辑导入部分代码,并执行其逻辑,以保证某些条件的想法断言多个场景。单元测试必须具备一些特性,例如:

  • 隔离:单元测试应该完全独立于任何其他外部代理,并且它们必须只关注业务逻辑。因此,它们不连接到数据库,不执行 HTTP 请求,等等。隔离还意味着测试之间是独立的:它们必须能够以任何顺序运行,而不依赖于以前的任何状态。
  • 性能:单元测试必须快速运行。它们打算多次重复运行。
  • 可重复性:单元测试应该能够以确定性的方式客观地评估软件的状态。这意味着测试产生的结果应该是可重复的。单元测试评估代码的状态:如果测试失败,它必须继续失败,直到代码修复为止。如果测试通过,并且代码中没有任何更改,那么它应该继续通过。测试不应该是片状的或随机的。
  • 自验证:单元测试的执行决定其结果。不需要额外的步骤来解释单元测试(更不用说手动干预)。

更具体地说,在 Python 中,这意味着我们将有新的*.py文件,我们将在其中放置单元测试,它们将被一些工具调用。这些文件将有import语句,从我们的业务逻辑(我们打算测试的内容)中获取我们需要的内容,在这个文件中,我们自己编程测试。之后,一个工具将收集我们的单元测试并运行它们,并给出结果。

最后一部分是自我验证的实际含义。当该工具调用我们的文件时,将启动一个 Python 进程,我们的测试将在其上运行。如果测试失败,进程将退出,并显示错误代码(在 Unix 环境中,这可以是除0之外的任何数字)。标准是工具运行测试,并为每个成功的测试打印一个点(.);如果测试失败(测试条件不满足),则为F,如果出现异常,则为E

关于其他形式的自动化测试的说明

单元测试旨在验证非常小的代码单元,例如,函数或方法。我们希望我们的单元测试达到非常详细的粒度级别,测试尽可能多的代码。为了测试更大的东西,比如类,我们不希望只使用单元测试,而是使用一个测试套件,它是单元测试的集合。他们中的每一个人都将测试更具体的东西,比如该类的一个方法。

单元测试不是唯一可用的自动测试机制,我们不应该期望它们捕获所有可能的错误。还有验收集成测试,都超出了本书的范围。

在集成测试中,我们希望一次测试多个组件。在本例中,我们希望验证它们是否按照预期工作。在这种情况下,产生副作用是可以接受的(更可取的是),并且忘记隔离,这意味着我们希望发出 HTTP 请求,连接到数据库,等等。虽然我们希望我们的集成测试能够像生产代码那样实际运行,但是我们仍然希望避免一些依赖关系。例如,如果您的服务通过 Internet 连接到另一个外部依赖项,那么该部分确实会被省略。

假设您的应用程序使用数据库并连接到其他一些内部服务。应用程序将为不同的环境提供不同的配置文件,当然,在生产环境中,您将为实际服务设置配置。但是,对于集成测试,您需要使用专门为这些测试构建的 Docker 容器来模拟数据库,这将在特定的配置文件中进行配置。至于依赖项,只要有可能,您就会希望使用 Docker 服务来模拟它们。

模拟作为单元测试的部分将在本章后面介绍。当涉及到模仿依赖项以执行组件测试时,这将在第 10 章清洁架构中介绍,当我们在软件架构上下文中提到组件时。

验收测试是一种自动化的测试形式,它试图从用户的角度验证系统,通常执行用例。

与单元测试相比,最后两种形式的测试失去了另一个很好的特性:速度。正如您可以想象的那样,它们将需要更多的时间来运行,因此运行频率将降低。

在一个良好的开发环境中,程序员将拥有整个测试套件,并将一直重复地运行单元测试,同时对代码进行更改、迭代、重构等等。一旦更改准备就绪,并且 pull 请求打开,continuous integration service 将运行该分支的构建,只要存在集成或验收测试,单元测试都将运行。不用说,在合并之前,构建的状态应该是成功的(绿色),但重要的部分是不同类型的测试之间的区别:我们希望一直运行单元测试,而这些测试花费的时间更长,频率更低。

出于这个原因,我们希望有很多小单元测试和一些自动化测试,策略性地设计以尽可能多地覆盖单元测试无法到达的地方(例如,数据库的使用)。

最后,向智者说一句话。记住这本书鼓励实用主义。除了给出这些定义,以及本节开头关于单元测试的观点外,读者还必须记住,根据您的标准和上下文的最佳解决方案应该占主导地位。没有人比你更了解你的系统,这意味着如果出于某种原因,你必须编写一个单元测试,需要启动一个 Docker 容器来测试数据库,那就去做吧。正如我们在整本书中反复记住的那样,实用性战胜了

单元测试与敏捷软件开发

在现代软件开发中,我们希望不断地、尽快地交付价值。这些目标背后的基本原理是,我们越早得到反馈,影响就越小,改变就越容易。这些都不是新的想法;其中一些类似于几十年前的原则,而另一些(如尽快从利益相关者那里获得反馈并重复使用的想法)可以在大教堂和集市(缩写为CatB等文章中找到。

因此,我们希望能够有效地响应更改,为此,我们编写的软件必须更改。正如我在前几章中提到的,我们希望我们的软件具有适应性、灵活性和可扩展性。

如果没有正式的证据证明代码在修改后仍能正确运行,那么单靠代码(不管它的编写和设计有多好)无法保证它足够灵活,可以进行更改。

假设我们按照 SOLID 原则设计一个软件,其中一部分实际上有一组符合开放/封闭原则的组件,这意味着我们可以轻松地扩展它们,而不会影响太多现有代码。进一步假设代码的编写方式有利于重构,因此我们可以根据需要对其进行更改。当我们进行这些更改时,我们没有引入任何 bug,这是怎么说的?我们如何知道现有功能得到了保留(并且没有退化)?您是否有足够的信心将其发布给您的用户?他们会相信新版本的效果和预期的一样吗?

所有这些问题的答案都是,除非我们有正式的证据,否则我们无法确定。而单元测试只是:程序按照规范工作的正式证明。

因此,单元(或自动)测试可以作为一个安全网,让我们有信心处理代码。有了这些工具,我们可以高效地处理代码,因此这最终决定了团队处理软件产品的速度(或能力)。测试越好,我们就越有可能快速交付价值,而不被不时出现的 bug 所阻止。

单元测试与软件设计

当涉及到主代码和单元测试之间的关系时,这是硬币的另一面。除了上一节探讨的实用原因外,好的软件是可测试的软件。

可测试性(决定软件测试有多容易的质量属性)不仅很好,而且是干净代码的驱动程序。

单元测试不仅仅是对主代码库的补充,而是对代码编写方式有直接影响和实际影响的东西。这有很多层次,从一开始,当我们意识到,当我们想要为代码的某些部分添加单元测试时,我们必须改变它(产生一个更好的版本),当整个代码(设计)由它将要成为的方式驱动时,我们必须改变它的最终表达(在本章末尾探索)通过测试驱动设计进行测试。

从一个简单的例子开始,我将向您展示一个小用例,在这个小用例中,测试(以及测试代码的需要)可以改进代码的编写方式。

在下面的示例中,我们将模拟一个流程,该流程要求向外部系统发送关于在每个特定任务中获得的结果的度量(与往常一样,只要我们关注代码,细节就不会有任何区别)。我们有一个表示域问题任务的Process对象,它使用metrics客户端(一个外部依赖项,因此我们无法控制)将实际指标发送给外部实体(例如,这可能是向syslogstatsd发送数据):

class MetricsClient:
    """3rd-party metrics client"""
    def send(self, metric_name, metric_value):
        if not isinstance(metric_name, str):
            raise TypeError("expected type str for metric_name")
        if not isinstance(metric_value, str):
            raise TypeError("expected type str for metric_value")
        logger.info("sending %s = %s", metric_name, metric_value)
class Process:
    def __init__(self):
        self.client = MetricsClient() # A 3rd-party metrics client
    def process_iterations(self, n_iterations):
        for i in range(n_iterations):
            result = self.run_process()
            self.client.send(f"iteration.{i}", str(result)) 

在第三方客户端的模拟版本中,我们要求提供的参数必须是字符串类型。因此,如果run_process方法的result不是一个字符串,我们可能会认为它会失败,事实上它会:

Traceback (most recent call last):
...
    raise TypeError("expected type str for metric_value")
TypeError: expected type str for metric_value 

请记住,这种验证是我们无法控制的,我们无法更改代码,因此我们必须在继续之前为方法提供正确类型的参数。但是由于这是我们检测到的一个 bug,我们首先要编写一个单元测试来确保它不会再次发生。我们这样做是为了证明我们已经解决了这个问题,并在将来防止这个错误,不管代码更改了多少次。

可以通过模拟Process对象的客户机来测试代码(我们将在模拟对象部分中了解如何进行测试,当我们探索单元测试工具时),但这样做会运行比需要更多的代码(注意我们要测试的部分是如何嵌套在代码中的)。此外,该方法相对较小也很好,因为如果不是,测试将不得不运行更多我们可能还需要模拟的不需要的部分。这是另一个与可测试性相关的良好设计(小的、内聚的函数或方法)的例子。

最后,我们决定不费吹灰之力,只测试我们需要测试的部分,因此我们没有直接在main方法上与client交互,而是委托给wrapper方法,新类如下所示:

class WrappedClient:
    def __init__(self):
        self.client = MetricsClient()
    def send(self, metric_name, metric_value):
        return self.client.send(str(metric_name), str(metric_value))
class Process:
    def __init__(self):
        self.client = WrappedClient()
    ... # rest of the code remains unchanged 

在本例中,我们选择为度量创建我们自己的client版本,也就是说,围绕我们以前拥有的第三方库创建一个包装器。为此,我们放置了一个类(具有相同的接口),该类将相应地进行类型转换。

这种使用组合的方式类似于适配器设计模式(我们将在下一章中探讨设计模式,因此,现在,它只是一条信息性的消息),并且由于这是我们领域中的一个新对象,因此它可以有其各自的单元测试。拥有这个对象将使测试变得更简单,但更重要的是,现在我们看到了它,我们意识到这可能是最初编写代码的方式。试图为我们的代码编写一个单元测试使我们意识到我们完全缺少了一个重要的抽象!

既然我们已经按原样分离了方法,那么让我们为它编写实际的单元测试。本章的测试工具和库部分将更详细地探讨与本例中使用的unittest模块相关的细节,但目前,阅读代码将给我们关于如何测试它的第一印象,这将使前面的概念不那么抽象:

import unittest
from unittest.mock import Mock
class TestWrappedClient(unittest.TestCase):
    def test_send_converts_types(self):
        wrapped_client = WrappedClient()
        wrapped_client.client = Mock()
        wrapped_client.send("value", 1)
        wrapped_client.client.send.assert_called_with("value", "1") 

Mockunittest.mock模块中可用的一种类型,它是询问各种事情的方便对象。例如,在本例中,我们使用它来代替第三方库(模拟到系统的边界,如下一节中所述),以检查它是否按预期调用(同样,我们没有测试库本身,只是测试它是否正确调用)。注意我们如何运行一个类似于Process对象中的调用,但我们希望参数转换为字符串。

这是一个单元测试如何在代码设计方面帮助我们的例子:通过尝试测试代码,我们找到了更好的版本。我们可以更进一步地说,这个测试还不够好,因为单元测试在第二行中覆盖了包装器客户机的内部合作者。为了解决这个问题,我们可以说实际的客户机必须由参数提供(使用依赖项注入),而不是在其初始化方法中创建它。单元测试再一次让我们想到了更好的实现。

上一个示例的推论应该是,一段代码的可测试性也说明了它的质量。换句话说,如果代码很难测试,或者测试很复杂,那么它可能需要改进。

"There are no tricks to writing tests; there are only tricks to writing testable code"

–米什科·赫维

定义要测试的内容的边界

测试需要努力。如果我们在决定测试什么时不小心,我们将永远不会结束测试,因此浪费了大量的精力而没有取得多少成果。

我们应该将测试范围扩展到代码的边界。如果不这样做,我们还必须测试代码中的依赖项(外部/第三方库或模块),然后测试它们各自的依赖项,等等,这是一个永无止境的过程。测试依赖性不是我们的责任,所以我们可以假设这些项目有自己的测试。只要测试对外部依赖项的正确调用是否使用了正确的参数就足够了(这甚至可能是修补的一种可接受的用法),但我们不应该投入更多的精力。

这是好的软件设计带来回报的另一个例子。如果我们在设计中非常小心,并且明确定义了系统的边界(即,我们设计的是接口,而不是将发生变化的具体实现,从而反转对外部组件的依赖性以减少时间耦合),那么在编写单元测试时,模拟这些接口将更容易。

在良好的单元测试中,我们希望修补系统的边界,并关注要执行的核心功能。我们不测试外部库(例如,通过pip安装的第三方工具),而是检查它们是否被正确调用。当我们在本章后面探讨mock对象时,我们将回顾用于执行这些类型断言的技术和工具。

我们可以使用许多工具来编写单元测试,所有这些工具都有其优点和缺点,并且服务于不同的目的。我将介绍 Python 中用于单元测试的两个最常见的库。它们涵盖了大多数(如果不是全部的话)用例,并且非常流行,因此知道如何使用它们很方便。

除了测试框架和测试运行库之外,经常会发现配置代码覆盖率的项目,它们将代码覆盖率用作质量指标。由于覆盖率(当用作度量时)具有误导性,在了解了如何创建单元测试之后,我们将讨论为什么不能掉以轻心。

下一节首先介绍我们将在本章中用于单元测试的主要库。

单元测试的框架和库

在本节中,我们将讨论编写和运行单元测试的两个框架。第一个unittest在 Python 标准库中可用,而第二个pytest必须通过pip外部安装:

当涉及到我们的代码的测试场景时,unittest本身很可能就足够了,因为它有很多助手。然而,对于我们有多个依赖项、与外部系统的连接以及可能需要修补对象、定义夹具和参数化测试用例的更复杂的系统,那么pytest看起来是一个更完整的选项。

我们将以一个小程序为例,向您展示如何使用这两个选项对其进行测试,这最终将帮助我们更好地了解两者的比较情况。

演示测试工具的示例是版本控制工具的简化版本,它支持合并请求中的代码检查。我们将从以下标准开始:

  • 如果至少有一个人不同意更改,则合并请求为rejected
  • 如果没有人不同意,并且合并请求至少对其他两个开发人员有利,那么它就是approved
  • 在任何其他情况下,其状态为pending

下面是代码可能的样子:

from enum import Enum
class MergeRequestStatus(Enum):
    APPROVED = "approved"
    REJECTED = "rejected"
    PENDING = "pending"
class MergeRequest:
    def __init__(self):
        self._context = {
            "upvotes": set(),
            "downvotes": set(),
        }
    @property
    def status(self):
        if self._context["downvotes"]:
            return MergeRequestStatus.REJECTED
        elif len(self._context["upvotes"]) >= 2:
            return MergeRequestStatus.APPROVED
        return MergeRequestStatus.PENDING
    def upvote(self, by_user):
        self._context["downvotes"].discard(by_user)
        self._context["upvotes"].add(by_user)
    def downvote(self, by_user):
        self._context["upvotes"].discard(by_user)
        self._context["downvotes"].add(by_user) 

以这段代码为基础,让我们看看如何使用本章介绍的两个库对其进行单元测试。这样做的目的不仅在于了解如何使用每个库,还在于找出一些差异。

单元测试

unittest模块是一个很好的开始编写单元测试的选项,因为它提供了一个丰富的 API 来编写各种测试条件,并且因为它在标准库中可用,所以它非常通用和方便。

unittest模块基于 JUnit(来自 Java)的概念,而 JUnit 反过来也基于 Smalltalk 的单元测试的原始思想(也许这就是这个模块上方法命名约定背后的原因),因此它本质上是面向对象的。由于这个原因,测试是通过类编写的,在类中检查是通过方法验证的,在类中按场景对测试进行分组是很常见的。

为了开始编写单元测试,我们必须创建一个继承自unittest.TestCase的测试类,并定义我们想要强调其方法的条件。这些方法应该从test_*开始,并且可以在内部使用继承自unittest.TestCase的任何方法来检查必须为真的条件。

我们可能需要为我们的案例验证的一些条件示例如下:

class TestMergeRequestStatus(unittest.TestCase):
    def test_simple_rejected(self):
        merge_request = MergeRequest()
        merge_request.downvote("maintainer")
        self.assertEqual(merge_request.status, MergeRequestStatus.REJECTED)
    def test_just_created_is_pending(self):
        self.assertEqual(MergeRequest().status, MergeRequestStatus.PENDING)
    def test_pending_awaiting_review(self):
        merge_request = MergeRequest()
        merge_request.upvote("core-dev")
        self.assertEqual(merge_request.status, MergeRequestStatus.PENDING)
    def test_approved(self):
        merge_request = MergeRequest()
        merge_request.upvote("dev1")
        merge_request.upvote("dev2")
        self.assertEqual(merge_request.status, MergeRequestStatus.APPROVED) 

用于单元测试的 API 提供了许多有用的比较方法,最常见的方法是assertEqual(<actual>, <expected>[, message]),它可以用于将操作结果与我们期望的值进行比较,也可以选择使用在出现错误时显示的消息。

我使用顺序(<actual>, <expected>来命名参数,因为在我的经验中,这是我发现的大多数情况下的顺序。尽管我认为这是 Python 中使用的最常见的形式(作为约定),但没有关于这方面的建议或指导方针。事实上,一些项目(如 gRPC)使用反向形式(<expected>,``<actual>),这实际上是其他语言(例如 Java 和 Kotlin)的惯例。关键是要保持一致并尊重项目中已经使用的表单。

另一种有用的测试方法允许我们检查是否引发了某个异常(assertRaises

当发生异常时,我们在代码中引发异常,以防止在错误的假设下进行进一步处理,并在执行调用时通知调用方调用有问题。这是逻辑中需要测试的部分,这就是这个方法的目的。

想象一下,我们现在进一步扩展了我们的逻辑,允许用户关闭他们的合并请求,一旦发生这种情况,我们就不想再进行投票(一旦合并请求已经关闭,评估合并请求就没有意义了)。为了防止这种情况发生,我们扩展了代码,并在有人试图对关闭的合并请求进行投票的不幸事件上引发了一个异常。

在增加了两个新状态(OPENCLOSED以及一个新的close()方式后,我们修改了之前的投票方式,首先处理这个检查:

class MergeRequest:
    def __init__(self):
        self._context = {
            "upvotes": set(),
            "downvotes": set(),
        }
        self._status = MergeRequestStatus.OPEN
    def close(self):
        self._status = MergeRequestStatus.CLOSED
    ...
    def _cannot_vote_if_closed(self):
        if self._status == MergeRequestStatus.CLOSED:
            raise MergeRequestException(
                "can't vote on a closed merge request"
            )
    def upvote(self, by_user):
        self._cannot_vote_if_closed()
        self._context["downvotes"].discard(by_user)
        self._context["upvotes"].add(by_user)
    def downvote(self, by_user):
        self._cannot_vote_if_closed()
        self._context["upvotes"].discard(by_user)
        self._context["downvotes"].add(by_user) 

现在,我们要检查这个验证是否确实有效。为此,我们将使用asssertRaisesassertRaisesRegex方法:

 def test_cannot_upvote_on_closed_merge_request(self):
        self.merge_request.close()
        self.assertRaises(
            MergeRequestException, self.merge_request.upvote, "dev1"
        )
    def test_cannot_downvote_on_closed_merge_request(self):
        self.merge_request.close()
        self.assertRaisesRegex(
            MergeRequestException,
            "can't vote on a closed merge request",
            self.merge_request.downvote,
            "dev1",
        ) 

前者希望在调用第二个参数中的 callable 时引发提供的异常,函数的其余部分使用参数(*args**kwargs),如果不是这样,它将失败,表示预期引发的异常不是。后者也执行相同的操作,但它还检查引发的异常是否包含与作为参数提供的正则表达式匹配的消息。即使引发异常,但使用不同的消息(与正则表达式不匹配),测试也将失败。

尝试检查错误消息,因为作为额外的检查,异常不仅会更准确,并且确保触发的是我们想要的异常,它还会检查是否有其他相同类型的异常意外到达。

请注意这些方法如何也可以用作上下文管理器。在其第一种形式(在前面的示例中使用的形式)中,该方法接受异常,然后是可调用的,最后是要在该可调用中使用的参数列表)。但我们也可以将异常作为方法的参数传递,将其用作上下文管理器,并在该上下文管理器的块内以以下格式计算代码:

with self.assertRaises(MyException):
   test_logic() 

第二种形式通常更有用(有时是唯一的选择);例如,如果我们需要测试的逻辑不能表示为单个可调用的。

在某些情况下,您会注意到我们需要使用不同的数据运行相同的测试用例。我们可以构建单个测试并使用不同的值来执行其条件,而不是重复并生成重复的测试。这就是称为参数化测试,我们将在下一节开始探讨这些测试。稍后,我们将使用pytest重新访问参数化测试。

参数化测试

现在,我们想测试对合并请求的阈值接受是如何工作的,只需提供context外观的数据样本,而不需要整个MergeRequest对象。我们想测试status属性的一部分,它位于检查它是否关闭的行之后,但是独立地。

实现这一点的最佳方法是将该组件分离到另一个类中,使用组合,然后使用自己的测试套件测试这个新的抽象:

class AcceptanceThreshold:
    def __init__(self, merge_request_context: dict) -> None:
        self._context = merge_request_context
    def status(self):
        if self._context["downvotes"]:
            return MergeRequestStatus.REJECTED
        elif len(self._context["upvotes"]) >= 2:
            return MergeRequestStatus.APPROVED
        return MergeRequestStatus.PENDING
class MergeRequest:
    ...
    @property
    def status(self):
        if self._status == MergeRequestStatus.CLOSED:
            return self._status
        return AcceptanceThreshold(self._context).status() 

通过这些更改,我们可以再次运行测试并验证它们是否通过,这意味着这个小重构没有破坏当前功能(单元测试确保回归)。有了这一点,我们可以继续我们的目标,编写特定于新类的测试:

class TestAcceptanceThreshold(unittest.TestCase):
    def setUp(self):
        self.fixture_data = (
            (
                {"downvotes": set(), "upvotes": set()},
                MergeRequestStatus.PENDING
            ),
            (
                {"downvotes": set(), "upvotes": {"dev1"}},
                MergeRequestStatus.PENDING,
            ),
            (
                {"downvotes": "dev1", "upvotes": set()},
                MergeRequestStatus.REJECTED,
            ),
            (
                {"downvotes": set(), "upvotes": {"dev1", "dev2"}},
                MergeRequestStatus.APPROVED,
            ),
        )
    def test_status_resolution(self):
        for context, expected in self.fixture_data:
            with self.subTest(context=context):
                status = AcceptanceThreshold(context).status()
                self.assertEqual(status, expected) 

在这里,在setUp()方法中,我们定义了在整个测试中使用的数据夹具。在这种情况下,实际上并不需要它,因为我们可以直接将它放在方法上,但是如果我们希望在执行任何测试之前运行一些代码,那么这里就是编写它的地方,因为在每个测试运行之前,这个方法都会被调用一次。

在这种特殊情况下,我们可以将这个元组定义为类属性,因为它是一个常量(静态)值。如果我们需要运行一些代码,执行一些计算(例如构建对象或使用工厂),那么setUp()方法是我们唯一的选择。

通过编写新版本的代码,正在测试的代码下的参数更清晰、更紧凑。

为了模拟我们正在运行所有参数,测试将迭代所有数据,并使用每个实例练习代码。这里一个有趣的助手是使用subTest,在本例中,我们使用它来标记被调用的测试条件。如果其中一次迭代失败,unittest将报告它,并报告传递给subTest的变量的相应值(在本例中,它被命名为context,但任何一系列关键字参数都将工作相同)。例如,发生的一个错误可能如下所示:

FAIL: (context={'downvotes': set(), 'upvotes': {'dev1', 'dev2'}})
----------------------------------------------------------------------
Traceback (most recent call last):
  File "" test_status_resolution
    self.assertEqual(status, expected)
AssertionError: <MergeRequestStatus.APPROVED: 'approved'> != <MergeRequestStatus.REJECTED: 'rejected'> 

如果选择参数化测试,请尝试为每个参数实例的上下文提供尽可能多的信息,以使调试更容易。

参数化测试背后的想法是在不同的数据集上运行相同的测试条件。其思想是,首先确定要测试的数据的等价类,然后选择每个类的值代表(本章后面将对此进行详细介绍)。然后,您想知道测试失败的等价类是哪一个,而subTest上下文管理器提供的上下文在这种情况下很有用。

皮特斯特

Pytest 是一个很棒的测试框架,可以通过pip install pytest安装。关于unittest的一个不同之处是,尽管仍然可以将测试场景分类并创建测试的面向对象模型,但这实际上不是强制性的,而且可以通过使用assert检查我们希望在简单函数中验证的条件,用较少的样板编写单元测试陈述

默认情况下,与assert语句进行比较就足够pytest识别单元测试并相应地报告其结果。更高级的使用,如前一节所述,也是可能的,但它们需要使用软件包中的特定功能。

一个很好的特性是,pytests命令将运行它可以发现的所有测试,即使它们是用unittest编写的。这种兼容性使得从unittest逐渐过渡到pytest更加容易。

使用 pytest 的基本测试用例

我们在上一节中测试的条件可以用pytest在简单函数中重写。

下面是一些简单断言的示例:

def test_simple_rejected():
    merge_request = MergeRequest()
    merge_request.downvote("maintainer")
    assert merge_request.status == MergeRequestStatus.REJECTED
def test_just_created_is_pending():
    assert MergeRequest().status == MergeRequestStatus.PENDING
def test_pending_awaiting_review():
    merge_request = MergeRequest()
    merge_request.upvote("core-dev")
    assert merge_request.status == MergeRequestStatus.PENDING 

布尔等式比较只需要一个简单的assert语句,而其他类型的检查,如异常检查,则需要使用一些函数:

def test_invalid_types():
    merge_request = MergeRequest()
    pytest.raises(TypeError, merge_request.upvote, {"invalid-object"})
def test_cannot_vote_on_closed_merge_request():
    merge_request = MergeRequest()
    merge_request.close()
    pytest.raises(MergeRequestException, merge_request.upvote, "dev1")
    with pytest.raises(
        MergeRequestException,
        match="can't vote on a closed merge request",
    ):
        merge_request.downvote("dev1") 

在本例中,pytest.raises相当于unittest.TestCase.assertRaises,它也接受将其作为方法和上下文管理器调用。如果我们想要检查异常的消息,而不是使用不同的方法(如assertRaisesRegex,则必须使用相同的函数,但作为上下文管理器,并通过向match参数提供我们想要识别的表达式。

pytest还将把原始异常包装成一个定制异常,如果我们想检查更多的条件,可以预期(通过检查它的一些属性,例如.value),但该函数的使用覆盖了绝大多数情况。

参数化测试

使用pytest运行参数化的测试更好,这不仅是因为它提供了更干净的 API,还因为测试及其参数的每次组合都会生成一个新的测试用例(一个新函数)。

为了解决这个问题,我们必须在测试中使用pytest.mark.parametrize装饰器。decorator 的第一个参数是一个字符串,表示要传递给test函数的参数的名称,第二个参数必须与这些参数的相应值匹配。

请注意,测试函数体是如何缩减为一行的(在删除内部for循环及其嵌套的上下文管理器之后),并且每个测试用例的数据都与函数体正确隔离,从而更易于扩展和维护:

@pytest.mark.parametrize("context,expected_status", (
    (
        {"downvotes": set(), "upvotes": set()},
        MergeRequestStatus.PENDING
    ),
    (
        {"downvotes": set(), "upvotes": {"dev1"}},
        MergeRequestStatus.PENDING,
    ),
    (
        {"downvotes": "dev1", "upvotes": set()},
        MergeRequestStatus.REJECTED,
    ),
    (
        {"downvotes": set(), "upvotes": {"dev1", "dev2"}},
        MergeRequestStatus.APPROVED,
    ),
),)
def test_acceptance_threshold_status_resolution(context, expected_status):
    assert AcceptanceThreshold(context).status() == expected_status 

使用@pytest.mark.parametrize消除重复,使测试主体尽可能内聚,并使代码必须明确支持的参数(测试输入或场景)。

当使用参数化时,一个重要的建议是每个参数(每次迭代)应该只对应于一个测试场景。这意味着您不应该将不同的测试条件混合到同一个参数中。如果需要测试不同参数的组合,则使用不同的参数化。叠加此装饰器将创建与装饰器中所有值的笛卡尔乘积一样多的测试条件。

例如,配置如下的测试:

@pytest.mark.parametrize("x", (1, 2))
@pytest.mark.parametrize("y", ("a", "b"))
def my_test(x, y):
   … 

将针对值(x=1, y=a)(x=1, y=b)(x=2, y=a)(x=2, y=b)运行。

这是一种更好的方法,因为每个测试都更小,每个参数化都更具体(内聚)。它将允许您以一种更简单的方式通过所有可能的组合来强调代码。

当您拥有需要测试的数据,或者知道如何轻松构建数据时,数据参数工作得很好,但在某些情况下,您需要为测试构建特定的对象,或者您发现自己在重复编写或构建相同的对象。为了帮助实现这一点,我们可以使用夹具,我们将在下一节中看到。

固定设施

pytest的一大优点是它如何帮助创建可重用的特性,以便我们可以为测试提供数据或对象,从而更有效地进行测试,而无需重复。

例如,我们可能希望在特定状态下创建一个MergeRequest对象,并在多个测试中使用该对象。我们通过创建一个函数并应用@pytest.fixture装饰器将对象定义为一个固定装置。想要使用该夹具的测试必须有一个与定义的函数同名的参数,pytest将确保提供该参数:

@pytest.fixture
def rejected_mr():
    merge_request = MergeRequest()
    merge_request.downvote("dev1")
    merge_request.upvote("dev2")
    merge_request.upvote("dev3")
    merge_request.downvote("dev4")
    return merge_request
def test_simple_rejected(rejected_mr):
    assert rejected_mr.status == MergeRequestStatus.REJECTED
def test_rejected_with_approvals(rejected_mr):
    rejected_mr.upvote("dev2")
    rejected_mr.upvote("dev3")
    assert rejected_mr.status == MergeRequestStatus.REJECTED
def test_rejected_to_pending(rejected_mr):
    rejected_mr.upvote("dev1")
    assert rejected_mr.status == MergeRequestStatus.PENDING
def test_rejected_to_approved(rejected_mr):
    rejected_mr.upvote("dev1")
    rejected_mr.upvote("dev2")
    assert rejected_mr.status == MergeRequestStatus.APPROVED 

请记住,测试也会影响主代码,因此干净代码的原则也适用于它们。在这种情况下,我们在前面章节中探讨的原则不要重复自己干燥原则再次出现,我们可以借助pytest夹具来实现。

除了创建多个对象或公开将在整个测试套件中使用的数据外,还可以使用它们设置一些条件,例如,全局修补一些我们不希望调用的函数,或者当我们希望使用修补对象时。

代码覆盖率

测试运行程序支持覆盖插件(将通过pip安装),该插件提供关于代码中哪些行在测试运行时执行的有用信息。这些信息非常有用,这样我们就可以知道测试需要覆盖代码的哪些部分,并确定需要进行的改进(在生产代码和测试中)。我的意思是,检测未被发现的生产代码行将迫使我们为该部分代码编写测试(因为请记住,没有测试的代码应该被视为已被破坏)。在试图覆盖代码的过程中,可能会发生以下几种情况:

  • 我们可能会意识到我们完全错过了一个测试场景。
  • 我们将尝试提出更多的单元测试或覆盖更多代码行的单元测试。
  • 我们将尽量简化生产代码,消除冗余,使其更加紧凑,这意味着更容易覆盖。
  • 我们甚至可能意识到,我们试图覆盖的代码行是不可访问的(可能是逻辑中有错误),可以安全地删除。

请记住,即使这些都是积极的方面,覆盖率也不应该是一个目标,而应该是一个衡量标准。这意味着试图实现高覆盖率,仅仅达到 100%,将不会产生成效。我们应该将代码覆盖率作为一个单元来理解,以确定代码中需要测试的明显部分,并了解如何改进。然而,我们可以设置一个最低阈值,比如说 80%(一个普遍接受的值),作为所需覆盖的最低水平,以了解项目有合理的测试次数。

此外,认为高度的代码覆盖率是健康代码库的标志也是危险的:请记住,大多数覆盖率工具将报告已执行代码的生产线。调用一行并不意味着它已经过正确的测试(只是它已经运行)。单个语句可能封装多个逻辑条件,每个逻辑条件都需要单独测试。

不要被高度的代码覆盖率所误导,不断思考测试代码的方法,包括已经覆盖的代码行。

中使用最广泛的库之一是coveragehttps://pypi.org/project/coverage/ )。我们将在下一节探讨如何设置此工具。

设置休息覆盖率

pytest的情况下,我们可以安装的pytest-cov包。一旦安装,当测试运行时,我们必须告诉pytest转轮pytest-cov也将运行,以及应涵盖哪些包(或多个包)(以及其他参数和配置)。

该软件包支持多种配置,包括不同类型的输出格式,并且很容易将其与任何 CI 工具集成,但在所有这些功能中,强烈建议设置标志,告诉我们哪些行尚未被测试覆盖,因为这将帮助我们诊断代码,并允许我们开始编写更多的测试。

要向您展示此操作的示例,请使用以下命令:

PYTHONPATH=src pytest \
    --cov-report term-missing \
    --cov=coverage_1 \
    tests/test_coverage_1.py 

这将产生与以下类似的输出:

test_coverage_1.py ................ [100%]
----------- coverage: platform linux, python 3.6.5-final-0 -----------
Name         Stmts Miss Cover Missing
---------------------------------------------
coverage_1.py 39      1  97%    44 

这里,它告诉我们有一行没有单元测试,所以我们可以看看如何为它编写单元测试。这是一个常见的场景,我们意识到为了覆盖那些缺失的行,我们需要通过创建更小的方法来重构代码。因此,我们的代码看起来会更好,就像我们在本章开头看到的示例一样。

问题在于相反的情况,我们能相信高覆盖率吗?这是否意味着我们的代码是正确的?不幸的是,对于干净的代码来说,具有良好的测试覆盖率是一个必要但不充分的条件。没有对部分代码进行测试显然是件坏事。有测试实际上是非常好的,但我们只能对确实存在的测试说这一点。然而,我们不知道我们缺少什么测试,即使代码覆盖率很高,我们也可能缺少很多条件。

这些是测试覆盖的一些注意事项,我们将在下一节中提到。

测试覆盖的注意事项

Python 是解释的,在非常高的层次上,覆盖率工具利用它来识别在测试运行时被解释(运行)的行。然后,它将在最后报告这一点。一句话被解释的事实并不意味着它被正确地测试过,这就是为什么我们在阅读最终报道时要小心,并相信它所说的。

这实际上适用于任何语言。一条线被执行的事实并不意味着它被所有可能的组合所强调。所有分支都使用提供的数据成功运行,这一事实仅意味着代码支持该组合,但它没有告诉我们任何其他可能导致程序崩溃的参数组合(模糊测试)。

使用覆盖率作为一种工具来发现代码中的盲点,而不是作为度量或目标。

要用一个简单的例子来说明这一点,请考虑下面的代码:

def my_function(number: int):
    return "even" if number % 2 == 0 else "odd" 

现在,让我们为它编写以下测试:

@pytest.mark.parametrize("number,expected", [(2, "even")])
def test_my_function(number, expected):
    assert my_function(number) == expected 

如果我们以覆盖率运行测试,报告将为我们提供华而不实的 100%覆盖率。不用说,我们错过了对执行的单个语句的一半条件的测试。更令人不安的是,由于语句的else子句没有运行,我们不知道我们的代码可能以何种方式中断(为了使这个例子更加夸张,假设有一个不正确的语句,例如1/0而不是字符串"odd",或者有一个函数调用)。

可以说,我们可能会更进一步,认为这只是一条“快乐之路”,因为我们为函数提供了良好的值。但是不正确的类型呢?职能部门应该如何防范这种情况?

正如您所看到的,即使是一个简单的、看起来很无辜的陈述也可能引发我们需要准备的许多问题和测试条件。

检查代码的覆盖率是个好主意,甚至在 CI 构建中配置代码覆盖率阈值,但我们必须记住,这只是我们的另一个工具。就像我们之前研究过的工具(linter、代码检查器、格式化程序等),它只有在有更多工具和为干净的代码库准备的良好环境的情况下才有用。

另一个帮助我们进行测试的工具是使用模拟对象。我们将在下一节中探讨这些问题。

模拟对象

在某些情况下,我们的代码并不是测试上下文中出现的唯一内容。毕竟,我们设计和构建的系统必须做一些真实的事情,这通常意味着连接到外部服务(数据库、存储服务、外部 API、云服务等等)。因为它们需要有这些副作用,它们是不可避免的。当我们将代码抽象出来,面向接口编程,并将代码与外部因素隔离以最小化副作用时,它们将出现在我们的测试中,我们需要一种有效的方法来处理这些问题。

Mock对象是保护单元测试免受不良副作用影响的最佳策略之一(如本章前面所述)。我们的代码可能需要执行 HTTP 请求或发送通知电子邮件,但我们肯定不希望在单元测试中发生这种情况。单元测试应该以代码的逻辑为目标,并快速运行,因为我们希望经常运行它们,这意味着我们无法承受延迟。因此,真正的单元测试不使用任何实际的服务,它们不连接任何数据库,它们不发出 HTTP 请求,基本上,它们只执行生产代码的逻辑。

我们需要做这些事情的测试,但它们不是单元。集成测试应该从更广阔的角度测试功能,几乎模仿用户的行为。但它们并不快。因为它们连接到外部系统和服务,所以运行时间更长,成本更高。一般来说,我们希望有大量快速运行的单元测试,以便始终运行它们,并减少集成测试的运行频率(例如,在任何新的合并请求上)。

虽然模拟对象很有用,但滥用它们的范围介于代码气味和反模式之间。这是我们在下一节中讨论的第一个问题,然后讨论使用 mock 的细节。

关于修补和模拟的合理警告

我之前说过,单元测试可以帮助我们编写更好的代码,因为当我们开始考虑如何测试代码时,我们就会意识到如何改进代码以使其可测试。通常,随着代码变得更易于测试,它会变得更干净(更内聚、更细粒度、划分成更小的组件,等等)。

另一个有趣的收获是,测试将帮助我们注意到我们认为代码正确的部分中的代码气味。我们的代码有代码气味的一个主要警告是,我们是否发现自己只是为了覆盖一个简单的测试用例而试图修补(或模拟)许多不同的东西。

unittest模块提供了一个工具,用于在unittest.mock.patch处修补我们的对象。

修补意味着原始代码(由一个字符串表示其在导入时的位置)将被其原始代码以外的其他代码替换。如果没有提供替换对象,默认的是一个标准的模拟对象,它只接受所有方法调用或询问的属性。

补丁函数在运行时替换代码,缺点是我们正在失去与最初存在的原始代码的联系,这使得我们的测试变得有点肤浅。由于在运行时修改解释器中的对象所带来的开销,它还带来了性能方面的考虑,并且如果我们重构代码并四处移动(因为修补函数中声明的字符串将不再有效),将来可能需要对其进行更改。

在我们的测试中使用猴子补丁或模拟可能是可以接受的,而且它本身并不代表一个问题。另一方面,猴子补丁中的滥用确实是一个危险信号,告诉我们必须改进代码中的某些内容。

例如,在测试一个函数时遇到困难可能会让我们认为该函数可能太大,应该分解成更小的部分,同样,尝试测试一段需要入侵性很强的 monkey 补丁的代码应该告诉我们,可能代码过于依赖硬依赖性,应该使用依赖注入。

使用模拟对象

在单元测试术语中,有几种类型的对象属于名为测试双精度的类别。双重测试是一种对象类型,由于各种原因,它将取代我们测试套件中的真实对象(也许我们不需要实际的生产代码,但只要一个虚拟对象就可以工作,或者我们不能使用它,因为它需要访问服务,或者它有我们在单元测试中不想要的副作用,等等)。

有不同类型的双重测试,例如虚拟对象、存根、间谍或模拟。

mock 是最通用的对象类型,因为它们非常灵活和通用,所以它们适用于所有情况,而无需对其余对象进行详细介绍。正是由于这个原因,标准库还包含了此类对象,这在大多数 Python 程序中都很常见。这就是我们在这里要用到的:unittest.mock.Mock

mock是一种对象,它是根据规范(通常类似于生产类的对象)和一些配置响应(即,我们可以告诉 mock 在某些调用时应该返回什么,以及它的行为应该是什么)创建的。然后,Mock对象将作为其内部状态的一部分,记录调用它的方式(使用什么参数、调用多少次等等),我们可以在稍后阶段使用这些信息来验证应用程序的行为。

在 Python 的例子中,标准库中提供的Mock对象提供了一个很好的 API 来进行各种行为断言,例如检查调用 mock 的次数、使用什么参数等等。

模拟的类型

标准的库在unittest.mock模块中提供MockMagicMock对象。前者是一个 test-double,可以配置为返回任何值,并跟踪对其进行的调用。后者也有同样的功能,但它也支持神奇的方法。这意味着,如果我们编写了使用神奇方法的惯用代码(我们正在测试的部分代码将依赖于此),那么很可能我们将不得不使用一个MagicMock实例,而不仅仅是一个Mock

当我们的代码需要调用 magic 方法时,尝试使用Mock将导致错误。有关此示例,请参见以下代码:

class GitBranch:
    def __init__(self, commits: List[Dict]):
        self._commits = {c["id"]: c for c in commits}
    def __getitem__(self, commit_id):
        return self._commits[commit_id]
    def __len__(self):
        return len(self._commits)
def author_by_id(commit_id, branch):
    return branch[commit_id]["author"] 

我们想测试这个函数;但是,另一个测试需要调用author_by_id函数。出于某种原因,因为我们没有测试该函数,所以提供给该函数(并返回)的任何值都是好的:

def test_find_commit():
    branch = GitBranch([{"id": "123", "author": "dev1"}])
    assert author_by_id("123", branch) == "dev1"
def test_find_any():
    author = author_by_id("123", Mock()) is not None
    # ... rest of the tests.. 

如预期,此将不起作用:

def author_by_id(commit_id, branch):
    > return branch[commit_id]["author"]
    E TypeError: 'Mock' object is not subscriptable 

改用MagicMock会起作用。我们甚至可以配置这种类型的 mock 的 magic 方法,以返回控制测试执行所需的内容:

def test_find_any():
    mbranch = MagicMock()
    mbranch.__getitem__.return_value = {"author": "test"}
    assert author_by_id("123", mbranch) == "test" 
双重测试用例

为了查看 mock 的可能用途,我们需要向我们的应用程序添加一个新组件,该组件将负责通知buildstatus的合并请求。当build完成时,将使用合并请求的 ID 和buildstatus调用此对象,并通过向特定的固定端点发送 HTTPPOST请求,将此信息更新合并请求的status

# mock_2.py
from datetime import datetime
import requests
from constants import STATUS_ENDPOINT
class BuildStatus:
    """The CI status of a pull request."""
    @staticmethod
    def build_date() -> str:
        return datetime.utcnow().isoformat()
    @classmethod
    def notify(cls, merge_request_id, status):
        build_status = {
            "id": merge_request_id,
            "status": status,
            "built_at": cls.build_date(),
        }
        response = requests.post(STATUS_ENDPOINT, json=build_status)
        response.raise_for_status()
        return response 

这个类有很多副作用,但其中一个是很难克服的重要外部依赖。如果我们试图在不修改任何内容的情况下在其上编写测试,那么一旦它尝试执行 HTTP 连接,就会出现连接错误。

作为测试目标,我们只想确保信息正确组合,并且使用适当的参数调用库请求。因为这是一个外部依赖,所以我们不想测试requests模块;只要检查它的调用是否正确就足够了。

当试图比较发送到库中的数据时,我们将面临的另一个问题是类正在计算当前时间戳,这在单元测试中是不可能预测的。直接修补datetime是不可能的,因为模块是用 C 编写的。一些外部库可以做到这一点(例如freezegun),但它们会带来性能损失,在本例中,这将是过分的。因此,我们选择将我们想要的功能包装在一个静态方法中,这样我们就能够修补它。

现在我们已经确定了代码中需要替换的点,让我们编写单元测试:

# test_mock_2.py
from unittest import mock
from constants import STATUS_ENDPOINT
from mock_2 import BuildStatus
@mock.patch("mock_2.requests")
def test_build_notification_sent(mock_requests):
    build_date = "2018-01-01T00:00:01"
    with mock.patch(
        "mock_2.BuildStatus.build_date", 
        return_value=build_date
    ):
        BuildStatus.notify(123, "OK")
    expected_payload = {
        "id": 123, 
        "status": "OK", 
        "built_at": build_date
    }
    mock_requests.post.assert_called_with(
        STATUS_ENDPOINT, json=expected_payload
    ) 

首先,我们使用mock.patch作为装饰器来替换requests模块。此函数的结果将创建一个mock对象,该对象将作为参数传递给测试(本例中名为mock_requests。然后,我们再次使用这个函数,但这次是作为上下文管理器来更改计算build日期的类的方法的返回值,将该值替换为我们控制的值,我们将在断言中使用该值。

一旦我们完成了所有这些,我们就可以用一些参数调用 class 方法,然后我们可以使用mock对象来检查它是如何被调用的。在本例中,我们使用该方法来查看requests.post是否确实按照我们希望的方式使用参数进行了调用。

这是 mock 的一个很好的特性,它们不仅在所有外部组件周围设置了一些边界(在本例中是为了防止实际发送一些通知或发出 HTTP 请求),而且还提供了一个有用的 API 来验证调用及其参数。

虽然在本例中,我们可以通过设置相应的mock对象来测试代码,但我们必须按照主要功能的代码行总数的比例进行大量修补也是事实。关于被测试的纯生产性代码与我们必须模拟的代码的多少部分的比率没有任何规则,但当然,通过使用常识,我们可以看到,如果我们必须在相同的部分中修补相当多的东西,那么有些东西没有被清晰地抽象出来,而且看起来像是一种代码气味。

外部依赖项的修补可以与 fixture 结合使用,以应用一些全局配置。例如,防止所有单元测试执行 HTTP 调用通常是一个好主意,因此在单元测试的子目录中,我们可以在配置文件pytesttests/unit/conftest.py中添加一个 fixture):

@pytest.fixture(autouse=True)
def no_requests():
    with patch("requests.post"):
        yield 

此功能在所有单元测试中都会自动调用(因为autouse=True),调用时会在requests模块中补丁post功能。这只是一个想法,您可以调整您的项目,以增加一些额外的安全性,并确保您的单元测试没有副作用。

在下一节中,我们将探讨如何重构代码以克服此问题。

重构意味着在不修改外部行为的情况下,通过重新安排代码的内部表示来改变代码的结构。

一个例子是,如果您确定了一个具有大量职责和非常长的方法的类,然后决定使用较小的方法对其进行更改,创建新的内部协作者,并将职责分配到新的较小对象中。在这样做时,要小心不要更改该类的原始接口,保持其所有公共方法不变,并且不要更改任何签名。对于该类的外部观察者来说,可能看起来什么都没有发生(但我们知道不是这样)。

重构是软件维护中的一项关键活动,但如果没有单元测试,这是无法完成的(至少是不正确的)。这是因为,随着每次更改的进行,我们需要知道我们的代码仍然是正确的。在某种意义上,您可以将我们的单元测试视为代码的“外部观察者”,以确保契约不会破裂。

时不时地,我们需要支持一个新功能或以非预期的方式使用我们的软件。适应这些需求的唯一方法是首先重构代码,使其更通用或更灵活。

通常,在重构代码时,我们希望改进其结构并使其更好,有时更通用、更可读或更灵活。面临的挑战是实现这些目标,同时保留在进行修改之前完全相同的功能。必须支持与以前相同的功能,但代码版本不同,这一限制意味着我们需要对修改后的代码运行回归测试。运行回归测试的唯一经济有效的方法是如果这些测试是自动的。最具成本效益的自动测试版本是单元测试。

进化我们的代码

在上一个例子中,我们能够将代码的副作用从代码中分离出来,通过修补那些依赖于我们在单元测试中无法控制的东西的代码部分,使其可测试。这是一个很好的方法,因为毕竟,mock.patch函数对于这类任务来说很方便,它替换了我们告诉它的对象,给了我们一个Mock对象。

这样做的缺点是,我们必须以字符串形式提供要模拟的对象(包括模块)的路径。这有点脆弱,因为如果我们重构代码(比如重命名文件或将其移动到其他位置),那么所有带有补丁的位置都必须更新,否则测试将中断。

在本例中,notify()方法直接依赖于实现细节(requests模块)这一事实是一个设计问题;也就是说,它在单元测试中付出了代价,同时也带来了上述隐含的脆弱性。

我们仍然需要用 double(mock)替换这些方法,但是如果我们重构代码,我们可以用更好的方式来完成。让我们将这些方法分成更小的方法,最重要的是注入依赖项,而不是将其固定。代码现在应用了依赖项反转原则,它希望使用支持接口(在本例中为隐式接口)的东西,例如requests模块提供的接口:

from datetime import datetime
from constants import STATUS_ENDPOINT
class BuildStatus:
    endpoint = STATUS_ENDPOINT
    def __init__(self, transport):
        self.transport = transport
    @staticmethod
    def build_date() -> str:
        return datetime.utcnow().isoformat()
    def compose_payload(self, merge_request_id, status) -> dict:
        return {
            "id": merge_request_id,
            "status": status,
            "built_at": self.build_date(),
        }
    def deliver(self, payload):
        response = self.transport.post(self.endpoint, json=payload)
        response.raise_for_status()
        return response
    def notify(self, merge_request_id, status):
        return self.deliver(self.compose_payload(merge_request_id, status)) 

我们分离了方法(注意 notify 现在是如何组合+交付的),使compose_payload()成为一个新方法(这样我们可以替换,而无需修补类),并要求注入transport依赖项。既然transport是一个依赖项,那么将该对象更改为我们想要的任何双精度对象就容易多了。

甚至可以暴露此对象的固定装置,并根据需要更换双倍:

@pytest.fixture
def build_status():
    bstatus = BuildStatus(Mock())
    bstatus.build_date = Mock(return_value="2018-01-01T00:00:01")
    return bstatus
def test_build_notification_sent(build_status):
    build_status.notify(1234, "OK")
    expected_payload = {
        "id": 1234,
        "status": "OK",
        "built_at": build_status.build_date(),
    }
    build_status.transport.post.assert_called_with(
        build_status.endpoint, json=expected_payload
    ) 

正如第一章中提到的,拥有干净的代码的目标是拥有可维护的代码,我们可以重构这些代码,使其能够发展和扩展到更多的需求。为此,测试是一个很大的帮助。但是,由于测试非常重要,我们还需要重构它们,以便它们能够随着代码的发展保持相关性和有用性。这是下一节讨论的主题。

生产代码并不是唯一进化的代码

我们一直说单元测试和产品代码一样重要。如果我们对生产代码足够小心,以创建尽可能最好的抽象,为什么我们不对单元测试也这样做呢?

如果单元测试的代码与主代码一样重要,那么明智的做法是在设计时考虑可扩展性,并使其尽可能可维护。毕竟,这是必须由工程师(而不是其原始作者)维护的代码,因此它必须是可读的。

我们之所以如此关注代码的灵活性,是因为我们知道需求会随着时间的推移而变化和演变,最终,随着域业务规则的变化,我们的代码也必须改变以支持这些新的需求。由于生产代码已更改以支持新的需求,因此测试代码也必须更改以支持较新版本的生产代码。

在我们使用的第一个示例中,我们为合并请求对象创建了一系列测试,尝试不同的组合并检查合并请求的状态。这是一个很好的第一种方法,但我们可以做得更好。

一旦我们更好地理解了问题,我们就可以开始创建更好的抽象。有了这个,我们想到的第一个想法是,我们可以创建一个更高级别的抽象来检查特定的条件。例如,如果我们有一个对象是专门针对MergeRequest类的测试套件,我们知道它的功能将限于该类的行为(因为它应该符合 SRP),因此我们可以在此测试类上创建特定的测试方法。这些仅对此类有意义,但这将有助于减少大量样板代码。

我们可以创建一个方法来封装它,并在所有测试中重用它,而不是重复遵循完全相同的结构的断言:

class TestMergeRequestStatus(unittest.TestCase):
    def setUp(self):
        self.merge_request = MergeRequest()
    def assert_rejected(self):
        self.assertEqual(
            self.merge_request.status, MergeRequestStatus.REJECTED
        )
    def assert_pending(self):
        self.assertEqual(
            self.merge_request.status, MergeRequestStatus.PENDING
        )
    def assert_approved(self):
        self.assertEqual(
            self.merge_request.status, MergeRequestStatus.APPROVED
        )
    def test_simple_rejected(self):
        self.merge_request.downvote("maintainer")
        self.assert_rejected()
    def test_just_created_is_pending(self):
        self.assert_pending() 

如果我们检查合并请求状态的方式发生了变化(或者说我们想添加额外的检查),那么只有一个地方(即assert_approved()方法)需要修改。更重要的是,通过创建这些更高级别的抽象,最初仅仅作为单元测试的代码开始演变为最终可能成为具有自己的 API 或域语言的测试框架,从而使测试更具声明性。

有了到目前为止我们已经回顾过的概念,我们知道如何测试我们的代码,从如何测试的角度考虑我们的设计,并配置项目中的工具来运行自动化测试,这将使我们对我们编写的软件的质量有一定程度的信心。

如果我们对代码的信心是由写在代码上的单元测试决定的,那么我们怎么知道这些测试已经足够了呢?我们如何才能确保我们已经完成了足够多的测试场景,并且没有错过一些测试?谁说这些测试是正确的?意思是,谁测试这些测试?

问题的第一部分,关于在我们编写的测试方面的彻底性,通过基于属性的测试超越我们的测试努力来回答。

问题的第二部分可能从不同的角度有多种答案,但我们将简要提及突变测试,作为确定我们的测试确实正确的一种手段。从这个意义上说,我们认为单元测试检查我们的主要生产代码,这也可以作为单元测试的控制。

基于属性的测试

基于属性的测试包括为测试用例生成数据,以发现会导致代码失败的场景,这在我们之前的单元测试中没有涉及。

这方面的主要库是hypothesis,它与我们的单元测试一起配置,将帮助我们找到导致代码失败的问题数据。

我们可以想象这个库所做的是为我们的代码找到反例。我们编写了我们的生产代码(以及它的单元测试!),并且我们声称它是正确的。现在,通过这个库,我们为代码定义了一个必须保持的hypothesis,如果在某些情况下我们的断言不保持,那么hypothesis将提供一组导致错误的数据。

单元测试最好的一点是,它们让我们更加认真地思考生产代码。hypothesis最好的一点是,它让我们更加认真地思考单元测试。

突变试验

我们知道测试是正式的验证方法,我们必须确保我们的代码是正确的。如何确保测试是正确的?您可能会认为,生产代码在某种程度上是正确的。我们可以将主代码看作是测试的制衡。

编写单元测试的要点是,我们保护自己不受 bug 的影响,并测试我们不希望在生产中发生的故障场景。考试通过是好的,但如果考试因为错误的原因通过了就糟糕了。也就是说,如果有人在代码中引入 bug,我们可以使用单元测试作为自动回归工具,稍后,我们希望至少有一个测试能够捕获它并失败。如果这没有发生,要么是缺少测试,要么是我们的测试没有进行正确的检查。

这就是突变测试背后的想法。使用突变测试工具,代码将被修改为新版本(称为突变),即是原始代码的变体,但其某些逻辑发生了改变(例如,运算符被交换,条件被颠倒)。

一个好的测试套件应该捕获这些突变体并杀死它们,在这种情况下,这意味着我们可以依赖这些测试。如果一些突变体在实验中幸存下来,这通常是个坏兆头。当然,这并不是完全精确的,所以我们可能希望忽略一些中间状态。

为了快速向您展示这是如何工作的,并让您了解这一点,我们将使用不同版本的代码,根据批准和拒绝的数量计算合并请求的状态。这一次,我们更改了一个简单版本的代码,该版本基于这些数字返回结果。我们已将带有状态常量的枚举移到一个单独的模块中,因此它现在看起来更紧凑:

# File mutation_testing_1.py
from mrstatus import MergeRequestStatus as Status
def evaluate_merge_request(upvote_count, downvotes_count):
    if downvotes_count > 0:
        return Status.REJECTED
    if upvote_count >= 2:
        return Status.APPROVED
    return Status.PENDING 

现在我们将添加一个简单的单元测试,检查其中一个条件及其预期的result

# file: test_mutation_testing_1.py
class TestMergeRequestEvaluation(unittest.TestCase):
    def test_approved(self):
        result = evaluate_merge_request(3, 0)
        self.assertEqual(result, Status.APPROVED) 

现在,我们将用pip install mutpy安装mutpy,这是一个 Python 的突变测试工具,并告诉它使用这些测试来运行这个模块的突变测试。以下代码针对不同的情况运行,通过更改CASE环境变量来区分这些情况:

$ PYTHONPATH=src mut.py \
    --target src/mutation_testing_${CASE}.py \
    --unit-test tests/test_mutation_testing_${CASE}.py \
    --operator AOD `# delete arithmetic operator`\
    --operator AOR `# replace arithmetic operator` \
    --operator COD `# delete conditional operator` \
    --operator COI `# insert conditional operator` \
    --operator CRP `# replace constant` \
    --operator ROR `# replace relational operator` \
    --show-mutants 

如果对案例 2 运行上一个命令(也可以作为make mutation CASE=2运行),结果将类似于以下内容:

[*] Mutation score [0.04649 s]: 100.0%
   - all: 4
   - killed: 4 (100.0%)
   - survived: 0 (0.0%)
   - incompetent: 0 (0.0%)
   - timeout: 0 (0.0%) 

这是个好兆头。让我们用一个具体的例子来分析发生了什么。输出上的一行显示以下内容:

 - [# 1] ROR mutation_testing_1:11 : 
------------------------------------------------------
  7: from mrstatus import MergeRequestStatus as Status
  8: 
  9: 
 10: def evaluate_merge_request(upvote_count, downvotes_count):
~11:     if downvotes_count < 0:
 12:         return Status.REJECTED
 13:     if upvote_count >= 2:
 14:         return Status.APPROVED
 15:     return Status.PENDING
------------------------------------------------------
[0.00401 s] killed by test_approved (test_mutation_testing_1.TestMergeRequestEvaluation) 

请注意,该突变体由原始版本组成,在第11行(>代表<行)中更改了运算符,结果告诉我们该突变体被测试杀死。这意味着,对于这个版本的代码(假设有人错误地进行了此更改),那么函数的结果应该是APPROVED,并且由于测试预期它是REJECTED,它失败了,这是一个好迹象(测试捕获了引入的错误)。

突变测试是保证单元测试质量的好方法,但它需要一些努力和仔细的分析。通过在复杂环境中使用此工具,我们必须花一些时间分析每个场景。同样,运行这些测试的成本很高,因为它需要多次运行不同版本的代码,这可能会占用太多的资源,并且可能需要更长的时间才能完成。然而,手动进行这些检查的成本更高,需要付出更多的努力。根本不做这些检查可能会有更大的风险,因为我们会危及测试的质量。

测试中的常见主题

我想简单地谈谈一些主题,在考虑如何测试我们的代码时,这些主题通常需要牢记在心,因为它们是反复出现的,而且很有帮助。

这些是您在尝试对代码进行测试时通常需要考虑的要点,因为它们会导致无情的测试。当您编写单元测试时,您的心态必须是破坏代码:您希望确保找到错误,以便能够修复它们,并且它们不会滑入生产环境(这将更糟糕)。

边界或极限值

边界值通常是代码中的一个很大的麻烦源,因此这可能是一个很好的起点。查看代码并检查围绕某些值设置的条件。然后,添加测试以确保包含这些值。

例如,在这样的代码行中:

if remaining_days > 0: ... 

为 zero 添加显式测试,因为这似乎是代码中的一个特例。

更一般地说,在检查值范围的情况下,检查间隔的两端。如果代码处理数据结构(如列表或堆栈),请检查是否有空列表或完整堆栈,并确保始终正确设置索引,即使是索引限制上的值。

等价类

等价类是集合上的一个分区,因此该分区中的所有元素对于某个函数都是等价的。因为这个分区内的所有元素都是等价的,所以我们只需要其中一个元素作为代表来测试这个条件。

为了给出一个简单的示例,让我们回顾一下本节中用于演示代码覆盖率的前面代码:

def my_function(number: int):
    return "even" if number % 2 == 0 else "odd" 

这里,函数有一个if语句,并根据该条件返回不同的数据。

如果我们想通过规定输入测试的值集S是整数集来简化此函数的测试,我们可以认为它可以分为两部分:偶数和奇数。

因为这段代码对偶数做了一些处理,而对奇数做了一些处理,所以我们可以说这是我们的测试条件。也就是说,我们只需要每个子集的一个元素来测试整个条件,仅此而已。换句话说,使用 2 进行测试与使用 4 进行测试是相同的(在这两种情况下使用相同的逻辑),因此我们不需要两者,而只需要其中一个(任何一个)。1 和 3(或任何其他奇数)也是如此。

我们可以将这些有代表性的元素分成不同的参数,并使用@pytest.mark.parametrize装饰器运行相同的测试。重要的是确保我们覆盖了所有情况,并且我们没有重复元素(也就是说,我们没有用同一分区的元素添加两个不同的参数化,因为这不会增加任何值)。

通过等价类进行测试有两个好处:一方面,我们通过不重复不会给测试场景添加任何内容的新值来有效地进行测试;另一方面,如果我们耗尽所有类,那么我们就可以很好地覆盖要测试的场景。

边缘案例

最后,尝试为您能想到的所有边缘情况添加特定测试。这在很大程度上取决于业务逻辑和您正在编写的代码的特性,并且与围绕边界值进行测试的思想有一些重叠。

例如,如果部分代码涉及日期,请确保测试闰年、2 月 29 日和 2 月 29 日以及新年前后。

到目前为止,我们假设我们正在编写代码之后的测试。这是一个典型的例子。毕竟,大多数时候,您会发现自己在一个已经存在的代码库上工作,而不是从头开始。

还有一种选择,就是在编写代码之前编写测试。这可能是因为您正在启动一个新项目或功能,并且您希望在编写实际的生产代码之前看到它的样子。或者可能是因为代码库中存在缺陷,在开始修复之前,您首先要编写一个测试来复制它。这被称为测试驱动设计TDD,将在下一节中讨论。

测试驱动开发简介

有整本书只专注于 TDD,因此在本书中尝试全面涵盖这一主题是不现实的。然而,这是一个非常重要的话题,必须提及。

TDD 背后的思想是,测试应该在生产代码之前编写,生产代码的编写只是为了响应由于缺少功能实现而失败的测试。

我们希望先编写测试,然后编写代码的原因有很多。从实用的角度来看,我们将非常准确地涵盖我们的生产代码。由于所有的生产代码都是为了响应单元测试而编写的,因此不太可能缺少功能测试(当然,这并不意味着有 100%的覆盖率,但至少所有的主要功能、方法或组件都有各自的测试,即使它们没有完全覆盖)。

工作流程很简单,在较高的层次上,包括三个步骤:

  1. 编写一个单元测试,描述代码的行为。这可以是仍然不存在的新功能,也可以是被破坏的当前代码,在这种情况下,测试描述了所需的场景。第一次运行此测试必须失败。
  2. 对代码进行最小的更改以使测试通过。现在测试应该通过了。
  3. 改进(重构)代码并再次运行测试,确保它仍然有效。

这个循环被推广为著名的红绿重构,这意味着在一开始,测试失败(红色),然后我们让它们通过(绿色),然后我们继续重构代码并迭代它。

单元测试是一个非常有趣和深入的话题,但更重要的是,它是干净代码的关键部分。最终,单元测试决定了代码的质量。当代码易于测试、清晰且设计正确时,单元测试通常充当代码的镜子,这将反映在单元测试中。

单元测试的代码与生产代码一样重要。适用于生产代码的所有原则也适用于单元测试。这意味着它们应该以同样的努力和周到的方式进行设计和维护。如果我们不关心我们的单元测试,它们将开始出现问题,并变得有缺陷(或有问题),因此毫无用处。如果发生这种情况,并且它们很难维护,它们就会成为一种负担,这会使事情变得更糟,因为人们往往会忽视它们或完全禁用它们。这是最糟糕的情况,因为一旦发生这种情况,整个生产代码就处于危险之中。盲目前进(没有单元测试)会导致灾难。

幸运的是,Python 提供了许多用于单元测试的工具,包括标准库中的工具和通过pip提供的工具。它们有很大的帮助,从长远来看,花时间配置它们是值得的。

我们已经看到了单元测试是如何作为程序的正式规范工作的,以及一个软件按照规范工作的证明,我们还了解到,当涉及到发现新的测试场景时,总是有改进的余地,我们总是可以创建更多的测试。从这个意义上说,用不同的方法(如基于属性的测试或变异测试)扩展我们的单元测试是一项很好的投资。

在下一章中,我们将学习设计模式及其在 Python 中的适用性。

以下是您可以参考的信息列表:

教程来源于Github,感谢apachecn大佬的无私奉献,致敬!

技术教程推荐

技术领导力实战笔记 -〔TGO鲲鹏会〕

MySQL实战45讲 -〔林晓斌〕

设计模式之美 -〔王争〕

人人都能学会的编程入门课 -〔胡光〕

体验设计案例课 -〔炒炒〕

业务开发算法50讲 -〔黄清昊〕

JavaScript进阶实战课 -〔石川〕

零基础学Python(2023版) -〔尹会生〕

现代C++20实战高手课 -〔卢誉声〕