探索TDD的世界

简而言之,TDD 是一种技术,它允许我们通过短反馈循环编写自动化测试。它是一个迭代过程,将测试融入到软件开发过程中,使开发人员在编写测试时能够使用与编写生产代码相同的技术。

TDD 作为一种敏捷工作实践被创建,因为它允许团队以迭代的方式交付代码,过程包括编写功能代码、通过测试验证新代码,并在需要时迭代重构新代码。

敏捷方法论简介

敏捷运动的前身是瀑布模型,它曾是最流行的项目管理方法。这一过程将软件项目分阶段交付,每个阶段的工作在前一个阶段完成后开始,就像水流向下游一样。图 1.1 展示了瀑布模型的五个阶段:

image 2025 01 02 10 12 58 485
Figure 1. Figure 1.1 – The five stages of the waterfall methodology

从制造业和建筑项目中获得的直觉可能会让人觉得,将软件交付过程分成顺序阶段,且在项目开始时就收集并制定所有需求是合乎自然的。然而,这种工作方式在交付大型软件项目时存在三个困难:

  • 改变项目方向或需求是困难的。只有在整个过程结束时,才能提供一个可工作的解决方案,这需要对一个大交付物进行验证。测试整个项目比测试小的交付物要困难得多。

  • 客户需要在项目开始时详细决定所有的需求。瀑布模型允许客户的参与最小化,因为他们仅在需求和验证阶段被咨询。

  • 这个过程需要详细的文档,文档中不仅指定了需求,还有软件设计方法。至关重要的是,项目文档包括时间表和估算,客户需要在项目启动之前批准这些内容。

瀑布模型注重规划工作

使用瀑布方法进行项目管理,可以将项目规划为定义明确、线性的阶段。这种方法直观且适用于具有明确目标和边界的项目。然而,在实际应用中,瀑布模型缺乏交付复杂软件项目所需的灵活性和迭代性。

一种更好的工作方式——敏捷方法(Agile)应运而生,能够解决瀑布方法的挑战。TDD(测试驱动开发)依赖于敏捷方法的原则。关于敏捷工作实践的文献非常丰富,因此我们不会深入讨论敏捷,但简要了解 TDD 的起源将帮助我们理解它的方法,并进入其思维方式。

敏捷软件开发是多个代码交付和项目规划实践的总称,如 SCRUM、看板(Kanban)、极限编程(XP)和 TDD

顾名思义,敏捷方法强调的是响应和适应变化的能力。瀑布工作方式的一个主要缺点就是其缺乏灵活性,而敏捷就是为了解决这个问题而设计的。

敏捷宣言(Agile Manifesto)由 17 位软件工程领域的领导者和先驱于 2001 年撰写并签署。它概述了敏捷的四个核心价值观和 12 条中心原则。敏捷宣言可以在 http://agilemanifesto.org 上自由获取。

四个核心的敏捷价值观体现了这一运动的精神:

  • 个人与互动高于流程与工具:这意味着,参与项目交付的团队比他们使用的技术工具和流程更为重要。

  • 工作的软件高于详尽的文档:这意味着,向客户交付可用功能是首要任务。虽然文档很重要,但团队应始终专注于持续交付价值。

  • 客户协作高于合同谈判:这意味着,客户应在项目生命周期内持续参与反馈环节,确保项目和工作不断交付价值并满足他们的需求和要求。

  • 响应变化高于遵循计划:这意味着,团队应更关注响应变化,而不是一味遵循预定的计划或路线图。团队应能够在需要时进行调整并改变方向。

敏捷方法关注的是人

敏捷方法并不是一套规定的实践清单,它关注的是团队在项目生命周期中如何共同应对不确定性和变化。敏捷团队是跨学科的,成员包括工程师、软件测试专业人员、产品经理等。这样确保了具备多种技能的团队成员能够协作,共同交付完整的软件项目。

与瀑布模型不同,敏捷软件交付方法的各个阶段是重复进行的,重点是在小增量或迭代中交付软件,而不是像瀑布模型那样的 “大型交付”。在敏捷术语中,这些迭代被称为 “冲刺”。

图1.2展示了敏捷项目交付的各个阶段:让我们来看看敏捷软件交付的循环阶段:

image 2025 01 02 10 14 04 468
Figure 2. Figure 1.2 – The stages of Agile software delivery
  1. 计划阶段:产品负责人与关键利益相关者讨论当前冲刺中要交付的项目需求。此阶段的结果是根据优先级排序的客户需求清单,这些需求将在本次冲刺中实现。

  2. 设计阶段:一旦项目的需求和范围确定,设计阶段开始。这个阶段包括技术架构设计以及 UI/UX 设计。此阶段建立在计划阶段的需求基础上。

  3. 实现阶段:接下来,实施阶段开始。设计作为指导,团队开始实现定义的功能。由于冲刺周期较短,如果在实现过程中发现任何不一致,团队可以很容易地回到早期阶段进行调整。

  4. 测试阶段:一旦一个交付项完成,测试阶段开始。测试阶段与实施阶段几乎是同时进行的,因为测试规范可以在设计阶段完成后立即编写。只有当交付项的测试通过后,才能认为其完成。工作可以在实现和测试阶段之间来回切换,工程师修复任何发现的缺陷。

  5. 发布阶段:最后,一旦所有的测试和实现都成功完成,发布阶段开始。此阶段包括完成任何面向客户的文档或发布说明。在此阶段结束时,冲刺被认为已关闭。接下来可以开始新的冲刺,按照相同的循环进行。

每个冲刺结束时,客户都会获得一个新的交付项,能够查看产品是否仍然符合他们的需求,并为未来的冲刺提供改动的依据。每个冲刺的交付项在发布前都会进行测试,确保后续的冲刺不会破坏现有功能,并且能够交付新的功能。所执行的测试的范围和工作量仅限于验证本次冲刺中开发的功能。

敏捷宣言的签署人之一是软件工程师 Kent Beck,他被认为是重新发现并正式化了 TDD 方法论。自那时以来,敏捷方法在许多团队中取得了巨大的成功,并成为行业标准,因为它使得团队能够在交付的同时验证功能。它将测试与软件交付和重构结合起来,消除了代码编写与测试过程之间的隔阂,并缩短了工程团队与客户需求之间的反馈循环。这种更短的反馈循环是敏捷方法的核心原则,赋予了敏捷方法灵活性。

在本书的各章中,我们将重点学习如何在我们自己的 Go 项目中利用这些过程和技术。

自动化测试类型

自动化测试套件是使用工具和框架来验证软件系统行为的测试。它们提供了一种可重复的方式来执行系统需求的验证。对于敏捷团队来说,自动化测试是常规做法,因为它们必须在每次冲刺和发布后对系统进行测试,确保新功能的发布不会破坏旧功能或现有功能。

所有的自动化测试都会根据系统需求定义其输入和预期输出。我们将根据三个标准将其划分为几种类型的测试:

  • 它们对系统的了解程度

  • 它们验证的需求类型

  • 它们覆盖的功能范围

我们将根据这三个特征来描述每种测试。

系统知识

如图1.3所示,自动化测试可以根据对测试系统的内部了解程度分为三类:

image 2025 01 02 10 14 50 766
Figure 3. Figure 1.3 – Types of tests according to system knowledge

让我们进一步探讨这三类测试:

  • 黑盒测试:黑盒测试从用户的角度进行,测试编写者对系统内部的实现不做了解,就像用户一样。测试和预期输出根据它们验证的需求来制定。黑盒测试通常不容易受到系统内部实现变化的影响。

  • 白盒测试:白盒测试从开发人员的角度进行,测试编写者完全了解系统的内部实现。此类测试可以更详细,并且能够发现黑盒测试无法发现的隐藏错误。白盒测试通常会受到系统内部变化的影响,容易变得脆弱。

  • 灰盒测试:灰盒测试是黑盒测试和白盒测试的结合,测试编写者部分了解系统内部实现,就像专业用户或特权用户一样。与黑盒测试相比,灰盒测试可以验证更复杂的用例和需求(例如,安全性或某些非功能需求),通常编写和运行更为耗时。

需求类型

一般来说,我们应该提供能够验证系统功能性和可用性的测试。例如,页面上如果有所有正确的功能,但加载时间超过 5 秒,用户会放弃使用。在这种情况下,系统是功能性的,但没有满足客户的需求。

我们可以根据测试验证的需求类型进一步将自动化测试分为两类:

  • 功能测试:这些测试覆盖了在当前冲刺中添加的系统功能,先前冲刺的功能测试确保后续冲刺中功能不会发生回归。这类测试通常是黑盒测试,因为它们应该根据普通用户能够访问的功能来编写和运行。

  • 非功能测试:这些测试涵盖了不属于功能需求范围,但影响用户体验和系统运作的所有方面。例如性能、可用性和安全性等方面的测试。这类测试通常是白盒测试,因为它们通常需要根据实现细节来制定。

正确性和可用性测试

验证系统正确性的测试被称为功能测试,而验证系统可用性和性能的测试被称为非功能测试。常见的非功能测试包括性能测试、负载测试和安全测试。

测试金字塔

敏捷中的一个重要概念是 “测试金字塔”。它列出了应该包含在软件系统自动化测试套件中的不同类型的测试。它为每种类型的测试提供了执行顺序和优先级指导,以确保新功能在发布时得到适当的测试,同时不会破坏旧有功能。

图1.4展示了测试金字塔,包含三种类型的测试:单元测试、集成测试和端到端测试。

image 2025 01 02 10 16 21 535
Figure 4. Figure 1.4 – The testing pyramid and its components

每种类型的测试可以根据之前提到的系统知识、需求类型和测试范围进一步描述。

单元测试

在测试金字塔的底部是单元测试。它们位于金字塔底部,因为它们数量最多,覆盖了单个组件在各种条件下的功能。良好的单元测试应该与其他组件隔离进行测试,以便完全控制测试环境和设置。

随着新功能的增加,单元测试的数量会增加,因此它们需要稳健且执行快速。通常,测试套件会在每次代码变更时运行,因此它们需要快速向工程师提供反馈。

单元测试传统上被认为是白盒测试,因为它们通常由了解所有实现细节的开发人员编写。然而,Go 的单元测试通常只测试包的导出/公共功能,这使得它们更接近灰盒测试。我们将在第 2 章《单元测试基础》中进一步探讨单元测试。

集成测试

在测试金字塔的中间是集成测试。它们是金字塔中不可或缺的一部分,但它们不应像底部的单元测试那样数量庞大,也不应像单元测试那样频繁运行。

单元测试验证单一功能的正确性,而集成测试则扩展了范围,测试多个组件之间的通信。这些组件可以是系统内外部的——如数据库、外部 API 或其他微服务。集成测试通常在专用环境中运行,这可以帮助我们将生产数据和测试数据分开,并降低成本。

集成测试可以是黑盒测试或灰盒测试。如果测试涉及外部 API 或面向客户的功能,则可以归类为黑盒测试,而更专业的安全性或性能测试则被认为是灰盒测试。我们将在第 4 章《构建高效的测试套件》中进一步探讨集成测试。

端到端测试

在测试金字塔的顶部是端到端测试。它们是我们所讨论的所有测试中最少的一类。端到端测试测试整个应用程序的功能(在每次冲刺中添加的功能),确保项目交付符合要求,并可以在特定冲刺结束时发布。

这些测试可能是最耗时的编写、维护和运行的,因为它们可能涉及大量不同的场景。与集成测试一样,它们通常在专用环境中运行,以模拟生产环境。

在微服务架构中,集成测试和端到端测试之间有许多相似之处,因为一个服务的端到端功能可能涉及与另一个服务端到端功能的集成。我们将在第 5 章《执行集成测试》和第 8 章《微服务架构测试》中进一步探讨端到端测试。

现在我们已经了解了自动化测试的不同类型,是时候看看如何利用敏捷实践 TDD 来实现这些测试,并将其与代码一同实现。TDD 将帮助我们编写经过良好测试的代码,交付测试金字塔中的所有组件。

TDD的迭代方法

正如我们之前提到的,TDD(测试驱动开发)是一种敏捷实践,它将是我们探索的重点。TDD 的原则很简单:在实现某个功能之前,先编写单元测试。TDD 将测试过程与实现过程结合起来,确保每个功能在编写的同时就经过测试,使得软件开发过程变得迭代,并为开发人员提供快速反馈。

图1.5展示了 TDD 过程的步骤,也叫做 “红绿重构” 过程:

image 2025 01 02 10 16 57 147
Figure 5. Figure 1.5 – The steps of TDD

让我们来看一下 TDD 工作过程中的循环阶段:

  1. 红色阶段:我们从红色阶段开始。首先,考虑我们想要测试的内容,并将这个需求转化为测试。一些需求可能由多个小需求组成,在此阶段,我们只测试第一个小需求。在新的功能实现之前,这个测试会失败,这也是红色阶段的名字由来。失败的测试是关键,因为我们希望确保无论写什么代码,测试都会可靠地失败。

  2. 绿色阶段:接下来,进入绿色阶段。我们从测试代码转向实现代码,只编写足够的代码以使得失败的测试通过。代码不需要完美或最优,但它应该足够正确,使得测试通过。它应当专注于先前编写的失败测试所验证的需求。

  3. 重构阶段:最后,进入重构阶段。这个阶段的目标是清理实现和测试代码,消除重复并优化解决方案。

  4. 重复这个过程,直到所有需求都被测试和实现,且所有测试通过。开发人员频繁地在测试和实现代码之间切换,相应地扩展功能和测试。

这就是 TDD 的全部过程!

TDD 是以开发人员为中心的

TDD 是一个以开发人员为中心的过程,其中单元测试是在实现之前编写的。开发人员首先编写一个失败的测试,然后编写最简单的实现来使得测试通过。最后,一旦功能实现并按预期工作,他们可以根据需要重构代码和测试。这个过程根据需要反复进行。没有任何一块代码或功能是在没有相应测试的情况下编写的。

TDD最佳实践

TDD 的红绿重构方法简单,但非常强大。尽管这个过程本身很简单,但我们可以提出一些建议和最佳实践,帮助我们编写更容易通过 TDD 交付的组件和测试。

结构化你的测试

我们可以制定一个共享的、可重复的测试结构,使测试更具可读性和可维护性。图1.6展示了 TDD 中常用的 “安排-行动-断言”(AAA)模式:

image 2025 01 02 10 17 41 879
Figure 6. Figure 1.6 – The steps of the Arrange-Act-Assert pattern

AAA模式描述了如何以统一的方式结构化测试:

  1. 安排(Arrange):这是测试的设置部分。在此阶段,我们设置待测试单元(UUT)及其所需的所有依赖项。同时,我们还在这一阶段设置测试场景所需的输入和前提条件。

  2. 行动(Act):在这个阶段,我们执行测试场景中指定的操作。根据我们实现的测试类型,这可能仅仅是调用一个函数、外部 API,甚至是数据库操作。此阶段使用在 “安排” 阶段定义的前提条件和输入。

  3. 断言(Assert):这是我们确认待测试单元(UUT)是否按需求行为的阶段。此步骤将 UUT 的输出与需求中定义的预期输出进行比较。

  4. 如果断言步骤显示 UUT 的实际输出不符合预期,那么测试失败,测试结束。

  5. 如果断言步骤显示 UUT 的实际输出符合预期,我们有两个选择:一种是如果没有更多的测试步骤,测试就视为通过,测试结束。另一种是如果有更多的测试步骤,我们返回到 “行动” 阶段继续执行。

  6. “行动” 和 “断言” 步骤可以根据测试场景需要重复多次。但应该避免编写冗长复杂的测试,最佳实践将在本节中进一步描述。

你的团队可以利用测试助手和框架来最小化设置和断言代码的重复。使用 AAA 模式 将帮助你为测试编写和阅读设定标准,减少新旧团队成员的认知负担,并提高代码库的可维护性。

控制测试范围

如我们所见,测试的范围取决于你编写的测试类型。无论是哪种类型的测试,你都应尽量限制组件的功能和测试断言的范围。TDD 允许我们在编写代码的同时进行测试和实现,这使得限制范围成为可能。

保持简单立即带来了以下几个好处:

  • 失败时更容易调试

  • 当 “安排” 和 “断言” 步骤简单时,更容易维护和调整测试

  • 测试执行时间更短,特别是能够并行运行测试时

测试输出,而非实现

从前面定义的测试可以看出,测试是关于定义输入和预期输出的。作为了解实现细节的开发人员,我们可能会有将断言添加到测试中以验证 UUT 内部工作原理的冲动。

然而,这是一种反模式,会导致测试和实现之间的紧密耦合。一旦测试了解了实现细节,就必须随着代码的变化而修改。因此,在构建测试时,专注于测试外部可见的输出,而不是实现细节。

保持测试独立

测试通常组织成测试套件,涵盖各种场景和需求。虽然这些测试套件允许开发人员利用共享功能,但测试应该彼此独立地运行。

测试应从预定义且可重复的起始状态开始,并且不应受运行次数和执行顺序的影响。设置和清理代码确保每个测试的起始点和结束状态符合预期。

因此,最好是测试为每个测试创建自己的待测试单元(UUT),而不是与其他测试共享一个。总体而言,这将确保你的测试套件更强大,并且可以并行运行。

采用 TDD 及其最佳实践,能够帮助敏捷团队交付易于维护和修改的高质量代码。这是 TDD 的诸多好处之一,我们将在下一部分继续探索。