OpenDSA 完整目录

Chapter 0 Introduction to Software Design

| 关于   «  3. 方法、选择与重复动作   ::   目录   ::   5. 变量、字段和参数  »

4. 软件测试基础与测试驱动开发

"程序测试可以非常有效地表明缺陷的存在, 但要证明缺陷不存在则完全无能为力。" -- Edsger W. Dijkstra

在上一章中,你学习了如何创建子类、声明自定义方法,以及使用 if 语句和 while 循环控制执行流程。你还通过为 Jeroo 方法编写基本检查,初步体验了自动化测试。在本章中,我们将深入探讨 软件测试 这一基本工程学科。你将学习如何设计全面的单元测试套件、使用测试夹具(setUp)、用 AssertJ 编写流式断言、实践 测试驱动开发(TDD) ,以及使用 代码覆盖率 评估你的测试。

4.1. 什么是软件测试?

有缺陷的代码 是软件行业的一个主要问题,造成了大量的停机时间,并使美国公司每年损失数十亿美元(一些估计高达 2000 亿美元 !)。 软件测试 是开发人员用来降低成本、提高软件质量以及减少计算机系统停机时间的重要工具。

我们都知道测试是一项重要的技能,那么我们应该从哪里开始呢?

首先,让我们回答一些关于 软件测试到底是什么 的简单问题。

软件测试是 执行程序 并 以发现错误为目的 的过程。软件测试是一种验证和确认技术,确保软件的开发既符合其规范又满足用户需求。

换句话说,测试就是 发现缺陷 (也称为缺陷),即证明某个软件在某种程度上会失败。

因此,Glenford Myers(经典著作 The Art of Software Testing 的作者)认为 成功的测试 是 确实 揭示了一个缺陷的测试。这样的测试明确地证明了缺陷的存在。相反,如果我们运行一个测试而软件的行为符合预期,我们只知道它在这一个测试场景下行为正确,但缺陷可能仍然隐藏在其他地方。因此,Edsger Dijkstra 宣称 "程序测试可以用来证明缺陷的存在,但永远不能证明它们的不存在!"

这个测试定义与 调试 形成对比,调试是关于 定位和修复缺陷。测试表明缺陷存在,然后调试用来找到缺陷的来源并修复它。

4.1.1. 测试用例和测试套件

好的,如果目标是以发现错误为目的执行程序(或其一部分),那就让我们运行它!但是我们应该使用什么输入值或用户交互序列呢?

举个例子,假设你正在测试一些非常简单的东西,比如一个名为 isEven() 的单个函数,它接受一个整数值,如果整数是偶数则返回 true,如果是奇数则返回 false。

记住,仅仅运行程序并不算 测试;相反,我们必须试图以某种方式证明失败。这意味着要仔细计划我们将如何运行程序——使用什么输入值或场景,以及我们将寻找什么具体行为或结果。

测试用例 是软件将在其下运行以尝试证明失败的单个场景,通常包含三个基本部分。

  1. 首先,测试用例必须定义测试中使用的所有 输入值、条件或变量。例如,我们可以想象一个场景,我们希望在输入值 2 上测试 isEven()。

  2. 其次,测试用例必须定义一个 过程 来执行被测软件。在 isEven() 的情况下,过程可以非常简单:用给定的输入值调用该方法,并存储布尔结果。然而,如果我们正在测试一个更大的软件部件或整个应用程序,我们可能需要执行一个更长、更复杂的操作序列来执行它,这个过程是测试用例的一部分。

  3. 第三,测试用例必须定义当软件以这种方式执行时应产生的 预期行为。例如,如果我们用值 2 调用 isEven(),我们期望它产生结果 true。

此外,测试用例是在 测试执行之前 制定(并写下来)的。也就是说,我们在运行测试之前就要弄清楚我们将做什么以及期望它产生什么结果。这将测试与简单地"运行代码看看会发生什么"区分开来。

当然,可能需要不止一个测试用例来确定某个所需行为是否被完全满足。例如,一个总是返回 true 而不管输入值如何的 isEven() 实现将通过上述示例测试用例。因此,程序员通常设计一组协同工作的测试用例。为执行同一段软件而设计的测试用例集合称为 测试套件。

例如,可以为 isEven() 设计五个测试用例:一个测试正偶整数,一个测试正奇整数,一个测试负偶整数,一个测试负奇整数,以及一个测试零。另一个人可能想出一个不同的测试套件,但这个组合将有很大机会成功演示该方法中可能出现的大多数简单缺陷。

4.1.2. 好的测试用例

即使测试像 isEven() 这样的单个方法,也有大量的测试用例可供选择。对于 32 位整数,有 232 个可能的输入值我们可以尝试。那么为什么我们要在测试用例中选择这些值中的一个而不是另一个呢?是什么让一个测试用例成为"好的"测试用例?

根据 Glenford Myers 的说法,一个 好的测试用例 应该满足以下条件:

  • 有很高的发现错误概率。由于我们的目标是证明缺陷,我们应该选择我们认为最有可能成功的测试用例。

  • 不冗余。如果我们已经有一个值为 2 的测试用例,我们就不需要另一个。此外,为什么不使用其他整数,比如 147652 呢?是否有理由认为这个测试用例有很高的发现错误概率?如果我们已经有其他测试用例覆盖偶数,为什么还要测试这个数字呢?如果它只是重新测试已经被充分演示的行为,那么它就是 冗余的——它执行的行为已经被我们测试套件中的其他测试用例测试过了。

  • 是"最佳选择"。换句话说,如果有几个冗余的测试用例你可能考虑,都覆盖相同的行为,选择最有可能发现错误的那个测试用例。例如,如果我们想在负偶整数上测试 isEven(),我们应该选择哪个值?任何随机值都可以吗?在这种情况下,选择最大的负整数(-231)可能更好,因为它还将检查该方法在整数范围的极端边界上是否有效。

  • 既不太简单也不太复杂。假设你正在测试一个类上的一组方法,而不仅仅是专注于 isEven()。你可能有一个测试用例,它以某种顺序调用类中的每个方法(并且可能多次调用某些方法!)。这样的测试用例可能覆盖很多方面——所有方法都必须在至少一些测试值上正确工作,这个测试用例才能通过。但是,如果测试揭示了一个缺陷,它在哪里呢?它可能在任何方法中,几乎在任何地方。这是一个测试用例过于复杂的例子,因为它涉及太多的行为都在同一系列操作中。最好是使用许多更小的测试用例,每个都专注于检查单个行为,这样测试结果会更有意义。同样,一个只是创建对象而什么都不做的测试用例可能太简单而无法揭示任何缺陷。努力追求范围狭窄但仍然可能揭示它们所执行行为中的缺陷的测试用例。

4.1.3. 软件测试方法

显然,穷举测试(测试每个可能的执行序列)是不切实际的,即使对于像 isEven() 这样非常简单的代码也是如此。有太多的可能性要尝试,而且需要太长时间。因此,我们需要一种方法来选择一组适当的测试用例(测试套件),该套件有很高的可能性揭示最可能的缺陷。已经开发了许多不同的测试方法来设计测试用例,所有方法的目标都是帮助你选择有效的测试套件。

两类最大的测试方法是 黑盒测试 方法和 白盒测试 方法,尽管还有其他方法。

测试活动还根据被测单元的性质以及你要验证的内容的重点来分类。一些最常见的测试活动是 单元测试 、 集成测试 和 系统测试 。

黑盒测试 或 功能测试 是用于一类测试方法的术语,其中测试用例源自规范、接口定义或行为描述。在使用此策略时,测试人员将被测软件视为"黑盒",其行为只能通过研究其输入和相关输出来确定。对于学生来说,这就是你在根据作业描述思考编写测试时使用的方法。

相反,另一类测试方法称为 白盒测试 、 结构测试 或 逻辑驱动测试 。白盒测试方法要求测试人员检查被测软件的 内部 结构。在使用此策略时,测试人员在审查程序的内部逻辑和结构后推导测试用例。

单元测试 用于描述在隔离状态下测试单个软件"单元"的活动,独立于任何其他正在编写的代码。这通常是你刚开始时使用的技巧,一次为一个类编写测试。

集成测试:一旦单个程序组件或类(单元)经过测试,它们必须被集成以创建部分或完整的系统。集成过程涉及 测试组合在一起工作的单元,以发现组件交互时出现的问题。被测试的组合可以从小到两个单元交互开始,增长到完整的应用程序。通常,这是由不同开发人员编写的完整系统的各部分一起测试的时候。

系统测试 是在典型工作环境下对整个完全集成的程序进行测试。这涉及将所有开发人员的代码组合成完整的软件产品。

4.1.4. 单元测试的更多细节

单元测试 用于描述在隔离状态下测试单个软件"单元"的活动,独立于任何其他正在编写的代码。

单元 的确切含义可以从一种编程语言到另一种,从一种编程范式到另一种,从一个组织到另一个组织而有所不同,但旨在表示一个可以独立执行的、清晰分隔的、可识别的软件部分。在 Pascal 中,"单元"通常是过程或函数。在面向对象的语言中,"单元"通常是一个类,尽管有时它也可以是单个方法。除非另有说明,从现在开始我们将把"单元"解释为面向对象语言中的单个类, 被测单元 ( UUT )表示我们当前正在测试或为其编写测试的类。

单元测试通常由编写被测单元(UUT)的程序员或程序员们在该单元与其他软件部件组合形成更大的应用程序之前执行。目标是尽可能在将该单元与其他软件组合 之前 确认它自身没有错误。这是因为你正在测试的软件部分越小,就越容易定位和消除被揭示的缺陷。

对于学生来说,事实证明你在自己的作业上做的大部分测试都是单元测试:在隔离状态下测试各个类,以确保它们满足各自的设计需求。

4.1.5. 软件测试的好处

让我们考虑软件测试的一些好处。这将给我们一个视角,为什么我们要在作业中采用软件测试:

  • 它增加你对代码的信心

  • 它增加你对需求的理解

  • 它预防"大爆炸"集成问题

  • 它提高你的成绩

4.1.5.1. 软件测试增加信心

当你采用更系统的方法来测试自己的软件时,它会增加你对自己编写的代码正确性的信心。为了最大化这个好处, 边写代码边写测试 而不是把所有测试留到编码完成后的"最后"是很重要的。如果你为每个小功能或增量编写新的测试,你可以逐步地一块一块地"增长"一个完整的测试套件。

此外,由于这个测试套件涵盖了你到目前为止编写的所有代码,你可以在每次添加新功能或实现另一个方法时重新运行所有测试(包括新的测试)。这就是你可以获得 最大信心增长 的地方——随着你编写测试的技能提高,以及你对不断增长的代码库运行和重新运行测试,你获得了越来越多的证据,证明你到目前为止编写的代码按预期工作。如果任何测试失败,你在定位缺陷方面也有很大的优势,因为它几乎肯定在你自上次运行所有测试以来添加的(小增量)代码中。最后,通过边写测试边运行,每次完成一个小的代码更改时重新运行它们,你可以立即知道任何新更改是否实际上破坏了之前正常工作的旧功能。换句话说,你获得了更大的信心,新的更改不会破坏或冲突之前正常工作的代码。

以这种方式测试软件促进增量开发。它促进了始终拥有程序的 可运行(尽管不完整)版本 的概念。最重要的是,它促进了对编码更改引入的错误的早期检测和纠正。

4.1.5.2. 软件测试增加对需求的理解

当你编写一个新的测试用例时,你必须写下当测试运行时期望发生什么输出、结果或行为。要做到这一点,你必须清楚地了解程序应该如何行为。

此外,如果你正在为你编写的每一小段代码编写测试——在你编写它们的时候——那么你不断地问自己 在这种情况下正确的行为是什么?

有时,你会在作业描述(或程序规范)中找到答案。其他时候,期望的行为可能取决于你作为内部设计选择。偶尔,正确的行为可能有歧义,你将不得不向讲师或助教寻求澄清。最终的结果是你将更好地理解真正需要什么。此外,如果你正在为所有这些功能编写测试用例,你也将更有信心你的解决方案确实满足作业的所有要求。

因此,编写测试不仅仅是简单地检查你的代码。它还通过迫使你阐述你在所有编写的测试用例中期望的行为,增加了你对作业及其需求的理解深度。这有助于你理解整个系统需求以及代码中每个方法的前置条件和后置条件。

4.1.5.3. 预防"大爆炸"集成

如果你在开发过程中增量地编写测试,它还将帮助防止学生经常遇到的特定类别的问题:与 "大爆炸"集成 相关的问题。"大爆炸"集成是软件工程中的一个术语,指的是集成或组合软件的较小部分以制作最终应用程序的特定策略。"大爆炸"策略简单易懂:

  • 为所有单元(或类)编写代码

  • 将它们全部组合到最终系统中

  • 一旦系统完成,开始对整个系统执行测试

这个策略看起来很简单,但它通常导致低质量的结果(通常是根本无法工作的项目!)。它的名字来自于当你最终开始测试最终系统时发生的"大爆炸": 什么都不工作 ,通常需要大量的(且令人精疲力竭的)时间、精力和努力来尝试在项目截止日期之前消除尽可能多的问题。最终,项目必须按原样交付,许多缺陷仍未修复。

信不信由你,许多商业软件项目很久以前就使用了这个策略,结果相同。同样在很久以前,开发组织就学会了如何通过增量集成和测试来预防它。所有问题的根源在于,在大爆炸方法中,当系统级测试失败时(它们肯定会失败), 没有简单的方法来定位缺陷 。你发现的缺陷可能在整个系统的任何地方,缩小搜索范围直到你定位到失败源需要时间和技巧。这比必要的时间和精力要多得多。

如果你 把所有测试留到最后一刻,你肯定会冒着遭受同样命运的风险。预防"大爆炸"集成的最佳工具是在开发解决方案时编写测试并增量运行它们。交替进行:"写一点测试,写一点代码"。不断地重新运行你的测试。一次添加几个单元(类)并测试它们的交互。将一个小的(可能不完整的)最终程序组合在一起并测试它,然后增量集成和测试功能,而不是一次性把所有东西都组合在一起。

通过采用 增量方法 进行测试和集成,你确保在任何给定时间测试相对较小的代码部分。因此,缺陷很容易定位,因为它们在你编写的最新代码中,或者在你集成到系统中的最新单元中。这立即缩小了你寻找缺陷的焦点。并且在修复缺陷时不断重新运行现有的测试,有助于你确保你的修复不会意外地破坏你编写的其他任何东西。

当你选择这种方法进行集成时,你的软件测试工作也提供了一种 生动的进展感,因为你总是清楚地意识到测试套件的增长规模以及多少所需行为已经"在手"并得到验证。

4.1.5.4. 软件测试提高成绩

好的,以下是底线。我们已经要求学生做自己的测试好几年了,我们也研究了结果。

编写自己的测试的学生产生的缺陷更少: 平均每千行程序代码(当然不包括注释)少约 28% 的缺陷 。这是 平均值 ,一些学生消除的缺陷比这多得多。此外,我们发表的研究表明 每个人 都全面受益:即使是最弱的学生在做自己的测试时也能提高代码质量,而且他们往往比最强的学生取得更大的进步,因为他们有更大的成长空间。

编写自己的测试的学生更有可能按时提交作业: 我们的经验表明,与不需要提交测试的学生相比,编写自己测试的学生更有可能按时提交作业并避免延迟扣分。这是一个统计上显著的差异,避免延迟扣分会带来更高的作业分数。

学生的程序更有可能完整且正确: 在我们对一门初级课程的研究中,在学生被鼓励增量测试并要求提交他们的测试之前,他们很少产出无缺陷的程序。即使是最好的学生仍然提交具有严重行为缺陷的工作。一旦我们开始要求学生编写并提交自己的软件测试(他们因此获得评分),学生最终提交的近 20% 完全或几乎没有缺陷。

简而言之,如果你测试你的代码,你更有可能完成作业,不太可能延迟提交作业,并且更有可能获得更高的成绩。经验上,它似乎也表明你的程序经过了更彻底的测试,大约减少了 28% 的缺陷。

4.1.6. 测试驱动开发

测试驱动开发 ( TDD )是一种编程技术,涉及不断交替编写一个或多个小的测试用例,然后编写一小段增量代码,以便你可以逐步建立一个可工作的代码库。TDD 背后有三个主要思想:

  • 测试优先。 也就是说,每次你即将编写解决方案的某一部分时,首先 写下确认你的解决方案按你期望工作所需的测试用例,然后才 编写代码。因此,TDD 也被称为 测试优先编码。

  • 以极小的增量编写。 不要一次编写大块代码,你应该以"婴儿学步"的方式添加新代码:一次一个小方法,或一次一小段方法,为每一小段编写一两个新的测试用例。

    例如,前面讨论的 isEven() 足够小,你可以为它编写测试用例,然后在一步中编写方法体(只需要一行代码),最后运行你的测试用例并根据需要调试。

    然而,如果你正在编写一个行为更复杂的方法,它可能太复杂而无法在一步中编写。考虑一个方法,它接受三个数字表示三角形三条边的长度,该方法应该返回相应的三角形是等边的、等腰的还是不等边的,同时如果这些边长不能构成三角形则报告该信息。

    如果你正在为这个方法编写测试,你将需要检查许多不同类型的情况。它能处理零长度的边吗?它能处理负数吗?它能处理不能构成任何三角形的长度吗?等腰三角形呢?等边三角形呢?不等边三角形呢?你可以将解决方案逻辑中的每个"情况"或分支分离成一个单独的小增量。为你想要的零长度边编写测试用例。然后只实现该部分方法并运行你的测试。接下来,为负数添加测试用例,只为此情况添加额外的代码,并重新运行 所有 测试。一步一步地处理剩余的情况,编写一些测试然后编写实现相应行为的代码。

    使用 案例分析 ——即将问题分解为两个或多个子问题,并定义一个或另一个子问题适用的条件——是一个强大的问题解决工具。它在计算机科学中一直出现,并提供了一种将复杂方法分解为更小步骤的方法,这些步骤可以增量测试。

  • "当条是绿色的,代码就是干净的。" 这条 TDD 格言描述了第三个关键思想:每次你添加一小段代码时,你重新运行 所有 为开发中的单元编写的测试,并且在 所有 测试 100% 通过之前不要进入下一步。

    简而言之,先添加少量测试,然后添加相应的(小段)代码,运行所有测试,并立即调试任何问题。除非所有测试都通过,否则你永远没有准备好进入下一个编码步骤(或完成你的解决方案,或准备将你的代码贡献给开源项目等)。你的测试用例是你对代码"正确行为"的表述。因此,这些测试用例是你衡量成功的 标准,随着你增量增长测试套件,你可以看到你离完成所有所需行为有多近。

    大多数自动运行测试用例的测试工具会在测试运行时显示一个进度条,只要测试成功就将其涂成绿色,任何测试失败时涂成红色。

传统测试中成功的测试会发现一个或多个缺陷。但在 TDD 中, 当测试失败时你已经取得了进展 ,因为你现在知道你需要解决这个问题。TDD 增加了你对系统确实满足为它定义的需求并且系统确实可以工作的信心。据说你应该"有目的地测试",并知道你为什么测试某个东西以及需要测试到什么程度。此外,使用 TDD 时,当你实现了一个经过良好测试的程序时,每一行代码都被测试了。通常,这是传统测试所推荐的,但并不保证的。

4.1.7. 了解更多关于 TDD

TDD 是关于编写 "可工作的干净代码"。以下是 Kent Beck 的一些直观描述 TDD 的引言:

这里的风格是写几行代码,然后写一个应该能运行的测试,或者更好的是,写一个不会运行的测试,然后写使它运行的代码。 ... [在弄清楚如何写一小段代码之后...] 现在,我们不想继续编码,而是想获得即时反馈,实践"写一点代码,测试一点,写一点代码,测试一点。" [... 所以我们立即为它编写一个测试。]

TDD 源自 极限编程 并从简单的 XP 思想"建一点,测一点"在编码过程中演化而来。基本上,你的代码总是有一组完整的测试来执行其功能,并且你在添加代码时编写新的测试。

TDD 提供的一个显著优势是它使你在编写软件时能够(鼓励你!)采取小步骤。例如,假设你添加一小段新代码,编译并测试它。迟早当你这样做时,由于代码中的一个或多个缺陷,你的一个或多个测试将失败。然而,通过以小步骤进行,找到并修复这些缺陷要容易得多。问题最可能在你刚才编写的极小段代码中,因为你之前编写的其他代码通过了所有其他测试。如果某些之前正常工作的行为再次中断,它很可能是新添加代码造成的干扰的结果。而且如果你只编写了五行新代码而不是五百行或五千行,缺陷会容易得多地被找到。

4.2. 编写你的第一个软件测试

在开发过程中为每个方法编写软件测试是你确认理解代码功能、确认代码按预期行为以及尽早发现问题以避免日后引起麻烦的最佳防御。你推迟测试的时间越长,就越难发现问题,你必须修复的缺陷就越多——如果你让太多缺陷堆积起来,你的代码会越来越难以正常工作。

假设你已经创建了一个名为 FlowerPicker 的 Jeroo 子类,你正在编写一个方法来拾取一行花。你将如何测试这个方法?每个软件步骤有三个关键部分,即使有时该部分极其简单:

  1. 设置测试的初始条件(创建所需的对象、将它们置于正确的状态、将所有必要的东西放在需要的位置等)。

  2. 调用你正在测试的方法。

  3. 检查预期的行为是否已经发生。这可能涉及检查方法的返回值,或检查测试中涉及的对象的状态。确保 检查所有 你期望发生的事情,而不仅仅是最大项。

因此,如果你的 FlowerPicker 有一个你想要测试的 pickFlowers() 方法,你需要将它放置在一个可以测试的环境中。你负责,这意味着你可以设置你需要的确切情况。例如,你可以创建你自己的岛屿,并在上面放置花朵。考虑这个岛屿,上面已经放置了几个矩形区域的花朵:

_images/lab04-island-1.png

虽然你肯定可以为你需要编写的任何测试制作自己的岛屿,但让我们使用这个名为 Lab04Island 的岛屿。

要创建你的软件测试,它们是什么样的以及放在哪里?我们将把我们的软件测试编写为普通的 Java 方法,每个测试用例一个方法。像所有 Java 方法一样,你将它们放在某个类中。但是哪个类?我们将使用一个单独的 Java 类来存放我们的测试,并将其称为 测试类。由于我们的 Jeroo 子类名为 FlowerPicker,我们将把它的软件测试放在一个名为 FlowerPickerTest 的新 Java 类中。

备注

按照惯例,我们所有的测试类都以它们正在测试的类命名,并在名称末尾添加 Test。

一个常见的错误是将 Test 放在名称的前面而不是末尾,所以始终仔细检查你的测试类名称。

要在 BlueJ 中创建测试类,右键单击你想要测试的类,然后从菜单中选择"Create Test Class"。BlueJ 将为你创建一个具有正确名称的新测试类。

由于我们的类是一个 Jeroo 类,请确保在顶部添加以下导入语句。导入语句是我们声明希望在代码中访问库类的方式——如果我们不这样做,我们无法使用它们。在顶部添加以下内容:

import student.micro.jeroo.*;
import static student.micro.jeroo.CompassDirection.*;
import static student.micro.jeroo.RelativeDirection.*;

我们编写的每个测试用例都将以单个测试方法的形式出现。我们使用以 test 开头的名称来命名测试方法,后面跟着我们正在测试的方法的描述,以及此测试用例捕获的情况(如果有多种情况的话)。我们可以在 FlowerPickerTest 类中这样编写我们的第一个测试:

public void testPickFlowers()
{
    // 1. set up initial conditions

    // 2. call the method

    // 3. check expected results
}

要设置测试条件,我们可以创建一个岛屿,创建 Jeroo,并将其放在我们选择的岛屿位置上。我们可以将 Jeroo 放在 (1, 2) 处,就在离左上角最近的花前面(注意这些条件以及相关的方法 可能与你的作业不同(如果你有的话),只是展示过程的示例):

public void testPickFlowers()
{
    // 1. set up initial conditions
    Lab04Island island = new Lab04Island();
    FlowerPicker picker = new FlowerPicker();
    island.addObject(picker, 1, 2);

    // 2. call the method

    // 3. check expected results
}

我们想要测试的方法是 pickFlowers(),所以在 Jeroo 被放置在岛屿上调用该方法很容易:

public void testPickFlowers()
{
    // 1. set up initial conditions
    Lab04Island island = new Lab04Island();
    FlowerPicker picker = new FlowerPicker();
    island.addObject(picker, 1, 2);

    // 2. call the method
    picker.pickFlowers();

    // 3. check expected results
}

最后,我们必须考虑我们期望发生什么。我们期望 pickFlowers() 会拾取整行花直到用完为止。然而,我们怎么说呢?Jeroo 拾取了多少朵花?Jeroo 最终在什么 (x, y) 位置?Jeroo 将面向哪个方向?对于任何 Jeroo,你已经知道 Jeroo 具有的基本属性。但是,你也可能希望检查岛屿的状态。它将有多少朵花,或者它是否在特定位置缺少花朵?

如果你仔细查看岛屿地图,当 Jeroo 放在 (1, 2) 处面向东时,(2, 2) 处的花将直接在它前面。从 (2, 2) 开始向东的一行花包含五朵花,然后结束,最后一朵花在 (6, 2)。所以也许你可能期望在运行 pickFlowers() 之后,以下内容为真:

  • Jeroo 将在 (6, 2)

  • Jeroo 将拾取 5 朵花

  • Jeroo 仍将面向东

我们如何在代码中写出来?我们使用一种特殊结构在测试用例方法中编写我们的期望,这种结构由常规 Java 方法组成,但我们以非常程式化的方式使用它们。我们的期望将使用一种旨在使其清晰可读的形式。首先,我们将使用这个基本形式,随着程序的增长我们将在此基础上构建:

assertThat(<something we want to check>).isEqualTo(<expected value>);

因此,我们可以将 Jeroo 的期望翻译为以下代码:

assertThat(picker.getX()).isEqualTo(6);
assertThat(picker.getY()).isEqualTo(2);
assertThat(picker.getFlowers()).isEqualTo(5);
assertThat(picker.getHeading()).isEqualTo(EAST);

我们可以将这些添加到我们的测试用例方法中:

public void testPickFlowers()
{
    // 1. set up initial conditions
    Lab04Island island = new Lab04Island();
    FlowerPicker picker = new FlowerPicker();
    island.addObject(picker, 1, 2);

    // 2. call the method
    picker.pickFlowers();

    // 3. check expected results
    assertThat(picker.getX()).isEqualTo(6);
    assertThat(picker.getY()).isEqualTo(2);
    assertThat(picker.getFlowers()).isEqualTo(5);
    assertThat(picker.getHeading()).isEqualTo(EAST);
}

现在我们实际上可以编译并运行我们的代码了。实际上,它可能无法编译,因为我们甚至还没有编写 pickFlowers() 方法!我们可以向 FlowerPicker 类添加一个 pickFlowers() 的方法存根,这样我们的测试就可以编译:

public void pickFlowers()
{
    // To be filled in later
}

现在我们的测试类可以编译了。在所有东西都编译后,通过右键单击测试类,我们可以选择"Run All Tests"来执行我们到目前为止的测试。BlueJ 将执行我们的测试,显示以下结果:

_images/junit-failure-msg.png

测试结果窗口的上半部分将显示所有运行的测试用例,每个通过的测试旁边有一个勾选标记,每个失败的测试前面有一个"X"。单击任何失败的测试以在窗口的下半部分查看相应的消息。在这里,第一个期望(Jeroo 的 x 坐标为 6)没有满足,因为我们还没有实现 pickFlowers() 而且 Jeroo 根本没有移动。我们的测试正在工作,但它告诉我们 pickFlowers() 没有按我们的意图行为。

现在我们可以实现 pickFlowers() 来拾取一行花(这 可能不是 你在作业中需要的行为):

public void pickFlowers()
{
    while (this.seesFlower(AHEAD))
    {
        this.hop();
        this.pick();
    }
}

如果你再次运行你的测试,这次它们通过了。正如俗话所说,"当条是绿色的,代码就是干净的。"

_images/junit-success.png

对于处理多种情况或条件的更复杂方法,为每种情况或条件编写单独的测试用例。你的初始条件将不同,事实上你的预期结果也可能不同。但是如果你不编写测试,你将不知道是否存在缺陷。

在使用 Jeroo 测试时,记住以下可以混合使用来表达条件并填入你自己值的方法示例(当然确保使用你自己的 Jeroo 名称):

  • assertThat(jeroo.getX()).isEqualTo(...);

  • assertThat(jeroo.getY()).isEqualTo(...);

  • assertThat(jeroo.getFlowers()).isEqualTo(...);

  • assertThat(jeroo.getHeading()).isEqualTo(...);

  • assertThat(jeroo.hasFlower()).isTrue();

  • assertThat(jeroo.seesJeroo(AHEAD)).isFalse();

  • assertThat(jeroo.seesWater(LEFT)).isTrue();

你也可以表达对岛屿的期望(记住选择你自己的值并使用你自己的岛屿名称):

  • assertThat(island.countFlowers()).isEqualTo(...);

  • assertThat(island.countNets()).isEqualTo(...);

  • assertThat(island.hasFlowerAt(3, 7)).isTrue();

  • assertThat(island.hasNetAt(4, 2)).isFalse();

在测试用例中表达你期望发生什么行为的选择实际上是无限的,但这些方法将帮助你开始编写你的第一个测试。

4.3. 代码覆盖率:语句和条件覆盖

一旦你编写了单元测试套件并且所有测试都以绿色条通过,一个自然的问题就出现了: 你是否编写了足够的测试?

即使你所有的现有测试用例都通过了,你的测试套件可能只执行了你编写的代码的一小部分。如果有些方法、分支或错误处理语句你的测试从未执行,关键的缺陷很容易隐藏在那些未执行的代码中。

为了衡量测试套件执行程序的彻底程度,软件工程师使用一种称为 代码覆盖率 的自动化指标。

4.3.1. 什么是代码覆盖率?

备注

代码覆盖率 是在自动化测试套件运行时执行的源代码比例的度量。它通常以 0% 到 100% 的百分比表示。

覆盖率工具(如 JaCoCo,它集成在 Web-CAT 和现代 Java 开发工具中)在你的测试运行时监控程序执行。通过记录哪些特定行和决策被到达,工具计算覆盖率报告。

在 CS 1114 中你会遇到两种主要的代码覆盖率形式: 语句覆盖率 和 条件(分支)覆盖率 。

4.3.2. 语句覆盖率(行覆盖率)

语句覆盖率 衡量你的程序中在测试运行期间至少执行一次的可执行语句的百分比。

例如,考虑以下方法:

public void clearObstacle()
{
    if (this.seesNet(AHEAD))
    {
        this.toss();
    }
    this.hop();
}

如果你的测试套件只在前面有网的场景中测试此方法,条件 this.seesNet(AHEAD) 为 true,因此第 5 行(this.toss())和第 7 行(this.hop())都会执行。在此场景中,你已实现 100% 语句覆盖率,因为每一行至少运行了一次。

然而,拥有 100% 语句覆盖率并 不 意味着你已经彻底测试了该方法!当前面 没有 网时会发生什么?

4.3.3. 条件覆盖率(分支覆盖率)

条件覆盖率 (也称为 分支覆盖率 )是一个更严格的指标。它衡量你的代码中的每个决策点(如 if 语句、if-else 梯子或 while 循环)在你的测试用例中是否都被评估为 ``true`` 和 ``false`` 两种情况。

在上面的 clearObstacle() 示例中:

  • 要实现 100% 条件覆盖率,你需要 两个不同的测试用例:

    1. 一个 seesNet(AHEAD) 为 true 的测试用例(验证网被投掷且 Jeroo 跳跃)。

    2. 一个 seesNet(AHEAD) 为 false 的测试用例(验证 Jeroo 跳跃而没有投掷花)。

如果你只测试 true 分支,你的条件覆盖率只有 50%,即使你的语句覆盖率为 100%!

4.3.4. 测试循环:0、1、多次规则

测试重复结构(while 循环)需要特别注意以实现高覆盖率和强大的缺陷检测。全面的测试套件应该在三个边界条件下测试循环:

The 0, 1, Many Loop Testing Strategy

场景

测试内容

示例场景

0 次迭代 (跳过)

条件初始为 false;循环体永远不执行。

Jeroo 面向水或一行中花数为零。

1 次迭代 (单步)

循环条件为 true 一次,然后在下一次检查时变为 false。

恰好包含一朵花的一行。

多次迭代 (一般情况)

循环条件在终止前多次为 true。

包含 4 或 5 朵花的一行。

测试 0 次迭代 场景尤其关键:它确保你的程序在接收到空任务时不会崩溃或执行无效操作。

4.3.5. 解读测试失败和诊断

当测试失败时,JUnit 和 AssertJ 提供详细的诊断反馈来帮助你定位缺陷:

org.opentest4j.AssertionFailedError:
expected: 5
 but was: 4
   at FlowerSweeperTest.testSweepRow(FlowerSweeperTest.java:42)

此诊断消息提供三个重要线索:

  1. 预期值与实际值:测试期望 Jeroo 的 x 坐标为 5,但实际上为 4。

  2. 失败的测试方法:失败发生在 testSweepRow() 内部。

  3. 确切行号:FlowerSweeperTest.java 中的第 42 行是进行失败断言的位置。

小技巧

在诊断测试失败时,从上到下阅读堆栈跟踪。找到跟踪中引用你自己的测试类文件的第一行。在 BlueJ 中单击该行可直接跳转到失败的断言。

4.3.6. CS 1114 中的覆盖率期望

在 CS 1114 中,提交给 Web-CAT 的作业会自动分析 语句覆盖率 和 条件覆盖率 。要获得测试规范(编程作业中的 SPEC-6)的满分,你的测试套件必须达到:

  • 100% 语句覆盖率:解决方案中的每一行代码都必须由你的测试用例执行。

  • 100% 条件覆盖率:每个 if、if-else 和 while 循环条件都必须在 true 和 false 两种结果下进行测试。

通过实践测试驱动开发(TDD)并在构建每个方法时编写测试用例,实现 100% 代码覆盖率将成为你设计工作流程的自然组成部分!

4.4. 编程练习 3

   «  3. 方法、选择与重复动作   ::   目录   ::   5. 变量、字段和参数  »

关闭窗口