2.3. 软件测试¶
2.3.1. 什么是软件测试?¶
有缺陷的代码 是软件行业的一个主要问题,造成了大量的停机时间,并使美国公司每年损失数十亿美元(一些估计高达 2000 亿美元 !)。 软件测试 是开发人员用来降低成本、提高软件质量以及减少计算机系统停机时间的重要工具。
我们都知道测试是一项重要的技能,那么我们应该从哪里开始呢?
首先,让我们回答一些关于 软件测试到底是什么 的简单问题。
软件测试是 执行程序 并 以发现错误为目的 的过程。软件测试是一种验证和确认技术,确保软件的开发既符合其规范又满足用户需求。
换句话说,测试就是 发现缺陷 (也称为缺陷/瑕疵),即证明某个软件在某种程度上会失败。
因此,Glenford Myers(经典著作 The Art of Software Testing 的作者)认为 成功的测试 是 确实 揭示了一个缺陷的测试。这样的测试明确地证明了缺陷/瑕疵的存在。相反,如果我们运行一个测试而软件的行为符合预期,我们只知道它在这一个测试场景下行为正确,但缺陷/瑕疵可能仍然隐藏在其他地方。因此,Edsger Dijkstra 宣称 "程序测试可以用来证明缺陷/瑕疵的存在,但永远不能证明它们的不存在!"
这个测试定义与 调试 形成对比,调试是关于 定位和修复缺陷。测试表明缺陷存在,然后调试用来找到缺陷的来源并修复它。
2.3.1.1. 测试用例和测试套件¶
好的,如果目标是以发现错误为目的执行程序(或其一部分),那就让我们运行它!但是我们应该使用什么输入值或用户交互序列呢?
举个例子,假设你正在测试一些非常简单的东西,比如一个名为 isEven() 的单个函数,它接受一个整数值,如果整数是偶数则返回 true,如果是奇数则返回 false。
记住,仅仅运行程序并不算 测试;相反,我们必须试图以某种方式证明失败。这意味着要仔细计划我们将如何运行程序——使用什么输入值或场景,以及我们将寻找什么具体行为或结果。
测试用例 是软件将在其下运行以尝试证明失败的单个场景,通常包含三个基本部分。
首先,测试用例必须定义测试中使用的所有 输入值、条件或变量。例如,我们可以想象一个场景,我们希望在输入值 2 上测试
isEven()。其次,测试用例必须定义一个 过程 来执行被测软件。在
isEven()的情况下,过程可以非常简单:用给定的输入值调用该方法,并存储布尔结果。然而,如果我们正在测试一个更大的软件部件或整个应用程序,我们可能需要执行一个更长、更复杂的操作序列来执行它,这个过程是测试用例的一部分。第三,测试用例必须定义当软件以这种方式执行时应产生的 预期行为。例如,如果我们用值 2 调用
isEven(),我们期望它产生结果true。
此外,测试用例是在 测试执行之前 制定(并写下来)的。也就是说,我们在运行测试之前就要弄清楚我们将做什么以及期望它产生什么结果。这将测试与简单地"运行代码看看会发生什么"区分开来。
当然,可能需要不止一个测试用例来确定某个所需行为是否被完全满足。例如,一个总是返回 true 而不管输入值如何的 isEven() 实现将通过上述示例测试用例。因此,程序员通常设计一组协同工作的测试用例。为执行同一段软件而设计的测试用例集合称为 测试套件。
例如,可以为 isEven() 设计五个测试用例:一个测试正偶整数,一个测试正奇整数,一个测试负偶整数,一个测试负奇整数,以及一个测试零。另一个人可能想出一个不同的测试套件,但这个组合将有很大机会成功演示该方法中可能出现的大多数简单缺陷/瑕疵。
2.3.1.2. 好的测试用例¶
即使测试像 isEven() 这样的单个方法,也有大量的测试用例可供选择。对于 32 位整数,有 232 个可能的输入值我们可以尝试。那么为什么我们要在测试用例中选择这些值中的一个而不是另一个呢?是什么让一个测试用例成为"好的"测试用例?
根据 Glenford Myers 的说法,一个 好的测试用例 应该满足以下条件:
有很高的发现错误概率。由于我们的目标是证明缺陷/瑕疵,我们应该选择我们认为最有可能成功的测试用例。
不冗余。如果我们已经有一个值为 2 的测试用例,我们就不需要另一个。此外,为什么不使用其他整数,比如 147652 呢?是否有理由认为这个测试用例有很高的发现错误概率?如果我们已经有其他测试用例覆盖偶数,为什么还要测试这个数字呢?如果它只是重新测试已经被充分演示的行为,那么它就是 冗余的——它执行的行为已经被我们测试套件中的其他测试用例测试过了。
是"最佳选择"。换句话说,如果有几个冗余的测试用例你可能考虑,都覆盖相同的行为,选择最有可能发现错误的那个测试用例。例如,如果我们想在负偶整数上测试
isEven(),我们应该选择哪个值?任何随机值都可以吗?在这种情况下,选择最大的负整数(-231)可能更好,因为它还将检查该方法在整数范围的极端边界上是否有效。既不太简单也不太复杂。假设你正在测试一个类上的一组方法,而不仅仅是专注于
isEven()。你可能有一个测试用例,它以某种顺序调用类中的每个方法(并且可能多次调用某些方法!)。这样的测试用例可能覆盖很多方面——所有方法都必须在至少一些测试值上正确工作,这个测试用例才能通过。但是,如果测试揭示了一个缺陷/瑕疵,它在哪里呢?它可能在任何方法中,几乎在任何地方。这是一个测试用例过于复杂的例子,因为它涉及太多的行为都在同一系列操作中。最好是使用许多更小的测试用例,每个都专注于检查单个行为,这样测试结果会更有意义。同样,一个只是创建对象而什么都不做的测试用例可能太简单而无法揭示任何缺陷/瑕疵。努力追求范围狭窄但仍然可能揭示它们所执行行为中的缺陷/瑕疵的测试用例。
2.3.1.3. 软件测试方法¶
显然,穷举测试(测试每个可能的执行序列)是不切实际的,即使对于像 isEven() 这样非常简单的代码也是如此。有太多的可能性要尝试,而且需要太长时间。因此,我们需要一种方法来选择一组适当的测试用例(测试套件),该套件有很高的可能性揭示最可能的缺陷/瑕疵。已经开发了许多不同的测试方法来设计测试用例,所有方法的目标都是帮助你选择有效的测试套件。
两类最大的测试方法是 黑盒测试 方法和 白盒测试 方法,尽管还有其他方法。
测试活动还根据被测单元的性质以及你要验证的内容的重点来分类。一些最常见的测试活动是 单元测试 、 集成测试 和 系统测试 。
黑盒测试 或 功能测试 是用于一类测试方法的术语,其中测试用例源自规范、接口定义或行为描述。在使用此策略时,测试人员将被测软件视为"黑盒",其行为只能通过研究其输入和相关输出来确定。对于学生来说,这就是你在根据作业描述思考编写测试时使用的方法。
相反,另一类测试方法称为 白盒测试 、 结构测试 或 逻辑驱动测试 。白盒测试方法要求测试人员检查被测软件的 内部 结构。在使用此策略时,测试人员在审查程序的内部逻辑和结构后推导测试用例。
单元测试 用于描述在隔离状态下测试单个软件"单元"的活动,独立于任何其他正在编写的代码。这通常是你刚开始时使用的技巧,一次为一个类编写测试。
集成测试:一旦单个程序组件或类(单元)经过测试,它们必须被集成以创建部分或完整的系统。集成过程涉及 测试组合在一起工作的单元,以发现组件交互时出现的问题。被测试的组合可以从小到两个单元交互开始,增长到完整的应用程序。通常,这是由不同开发人员编写的完整系统的各部分一起测试的时候。
系统测试 是在典型工作环境下对整个完全集成的程序进行测试。这涉及将所有开发人员的代码组合成完整的软件产品。
2.3.1.4. 单元测试的更多细节¶
单元测试 用于描述在隔离状态下测试单个软件"单元"的活动,独立于任何其他正在编写的代码。
单元 的确切含义可以从一种编程语言到另一种,从一种编程范式到另一种,从一个组织到另一个组织而有所不同,但旨在表示一个可以独立执行的、清晰分隔的、可识别的软件部分。在 Pascal 中,"单元"通常是过程或函数。在面向对象的语言中,"单元"通常是一个类,尽管有时它也可以是单个方法。除非另有说明,从现在开始我们将把"单元"解释为面向对象语言中的单个类, 被测单元 ( UUT )表示我们当前正在测试或为其编写测试的类。
单元测试通常由编写被测单元(UUT)的程序员或程序员们在该单元与其他软件部件组合形成更大的应用程序之前执行。目标是尽可能在将该单元与其他软件组合 之前 确认它自身没有错误。这是因为你正在测试的软件部分越小,就越容易定位和消除被揭示的缺陷/瑕疵。
对于学生来说,事实证明你在自己的作业上做的大部分测试都是单元测试:在隔离状态下测试各个类,以确保它们满足各自的设计需求。
2.3.1.5. 软件测试的好处¶
让我们考虑软件测试的一些好处。这将给我们一个视角,为什么我们要在作业中采用软件测试:
它增加你对代码的信心
它增加你对需求的理解
它预防"大爆炸"集成问题
它提高你的成绩
2.3.1.5.1. 软件测试增加信心¶
当你采用更系统的方法来测试自己的软件时,它会增加你对自己编写的代码正确性的信心。为了最大化这个好处, 边写代码边写测试 而不是把所有测试留到编码完成后的"最后"是很重要的。如果你为每个小功能或增量编写新的测试,你可以逐步地一块一块地"增长"一个完整的测试套件。
此外,由于这个测试套件涵盖了你到目前为止编写的所有代码,你可以在每次添加新功能或实现另一个方法时重新运行所有测试(包括新的测试)。这就是你可以获得 最大信心增长 的地方——随着你编写测试的技能提高,以及你对不断增长的代码库运行和重新运行测试,你获得了越来越多的证据,证明你到目前为止编写的代码按预期工作。如果任何测试失败,你在定位缺陷/瑕疵方面也有很大的优势,因为它几乎肯定在你自上次运行所有测试以来添加的(小增量)代码中。最后,通过边写测试边运行,每次完成一个小的代码更改时重新运行它们,你可以立即知道任何新更改是否实际上破坏了之前正常工作的旧功能。换句话说,你获得了更大的信心,新的更改不会破坏或冲突之前正常工作的代码。
以这种方式测试软件促进增量开发。它促进了始终拥有程序的 可运行(尽管不完整)版本 的概念。最重要的是,它促进了对编码更改引入的错误的早期检测和纠正。
2.3.1.5.2. 软件测试增加对需求的理解¶
当你编写一个新的测试用例时,你必须写下当测试运行时期望发生什么输出、结果或行为。要做到这一点,你必须清楚地了解程序应该如何行为。
此外,如果你正在为你编写的每一小段代码编写测试——在你编写它们的时候——那么你不断地问自己 在这种情况下正确的行为是什么?
有时,你会在作业描述(或程序规范)中找到答案。其他时候,期望的行为可能取决于你作为内部设计选择。偶尔,正确的行为可能有歧义,你将不得不向讲师或助教寻求澄清。最终的结果是你将更好地理解真正需要什么。此外,如果你正在为所有这些功能编写测试用例,你也将更有信心你的解决方案确实满足作业的所有要求。
因此,编写测试不仅仅是简单地检查你的代码。它还通过迫使你阐述你在所有编写的测试用例中期望的行为,增加了你对作业及其需求的理解深度。这有助于你理解整个系统需求以及代码中每个方法的前置条件和后置条件。
2.3.1.5.3. 预防"大爆炸"集成¶
如果你在开发过程中增量地编写测试,它还将帮助防止学生经常遇到的特定类别的问题:与 "大爆炸"集成 相关的问题。"大爆炸"集成是软件工程中的一个术语,指的是集成或组合软件的较小部分以制作最终应用程序的特定策略。"大爆炸"策略简单易懂:
为所有单元(或类)编写代码
将它们全部组合到最终系统中
一旦系统完成,开始对整个系统执行测试
这个策略看起来很简单,但它通常导致低质量的结果(通常是根本无法工作的项目!)。它的名字来自于当你最终开始测试最终系统时发生的"大爆炸": 什么都不工作 ,通常需要大量的(且令人精疲力竭的)时间、精力和努力来尝试在项目截止日期之前消除尽可能多的问题。最终,项目必须按原样交付,许多缺陷/瑕疵仍未修复。
信不信由你,许多商业软件项目很久以前就使用了这个策略,结果相同。同样在很久以前,开发组织就学会了如何通过增量集成和测试来预防它。所有问题的根源在于,在大爆炸方法中,当系统级测试失败时(它们肯定会失败), 没有简单的方法来定位缺陷 。你发现的缺陷/瑕疵可能在整个系统的任何地方,缩小搜索范围直到你定位到失败源需要时间和技巧。这比必要的时间和精力要多得多。
如果你 把所有测试留到最后一刻,你肯定会冒着遭受同样命运的风险。预防"大爆炸"集成的最佳工具是在开发解决方案时编写测试并增量运行它们。交替进行:"写一点测试,写一点代码"。不断地重新运行你的测试。一次添加几个单元(类)并测试它们的交互。将一个小的(可能不完整的)最终程序组合在一起并测试它,然后增量集成和测试功能,而不是一次性把所有东西都组合在一起。
通过采用 增量方法 进行测试和集成,你确保在任何给定时间测试相对较小的代码部分。因此,缺陷/瑕疵很容易定位,因为它们在你编写的最新代码中,或者在你集成到系统中的最新单元中。这立即缩小了你寻找缺陷/瑕疵的焦点。并且在修复缺陷/瑕疵时不断重新运行现有的测试,有助于你确保你的修复不会意外地破坏你编写的其他任何东西。
当你选择这种方法进行集成时,你的软件测试工作也提供了一种 生动的进展感,因为你总是清楚地意识到测试套件的增长规模以及多少所需行为已经"在手"并得到验证。
2.3.1.5.4. 软件测试提高成绩¶
好的,以下是底线。我们已经要求学生做自己的测试好几年了,我们也研究了结果。
编写自己的测试的学生产生的缺陷/瑕疵更少: 平均每千行程序代码(当然不包括注释)少约 28% 的缺陷/瑕疵 。这是 平均值 ,一些学生消除的缺陷/瑕疵比这多得多。此外,我们发表的研究表明 每个人 都全面受益:即使是最弱的学生在做自己的测试时也能提高代码质量,而且他们往往比最强的学生取得更大的进步,因为他们有更大的成长空间。
编写自己的测试的学生更有可能按时提交作业: 我们的经验表明,与不需要提交测试的学生相比,编写自己测试的学生更有可能按时提交作业并避免延迟扣分。这是一个统计上显著的差异,避免延迟扣分会带来更高的作业分数。
学生的程序更有可能完整且正确: 在我们对一门初级课程的研究中,在学生被鼓励增量测试并要求提交他们的测试之前,他们很少产出无缺陷/瑕疵的程序。即使是最好的学生仍然提交具有严重行为缺陷/瑕疵的工作。一旦我们开始要求学生编写并提交自己的软件测试(他们因此获得评分),学生最终提交的近 20% 完全或几乎没有缺陷/瑕疵。
简而言之,如果你测试你的代码,你更有可能完成作业,不太可能延迟提交作业,并且更有可能获得更高的成绩。经验上,它似乎也表明你的程序经过了更彻底的测试,大约减少了 28% 的缺陷/瑕疵。
2.3.1.6. 测试驱动开发¶
测试驱动开发 ( TDD )是一种编程技术,涉及不断交替编写一个或多个小的测试用例,然后编写一小段增量代码,以便你可以逐步建立一个可工作的代码库。TDD 背后有三个主要思想:
测试优先。 也就是说,每次你即将编写解决方案的某一部分时,首先 写下确认你的解决方案按你期望工作所需的测试用例,然后才 编写代码。因此,TDD 也被称为 测试优先编码。
以极小的增量编写。 不要一次编写大块代码,你应该以"婴儿学步"的方式添加新代码:一次一个小方法,或一次一小段方法,为每一小段编写一两个新的测试用例。
例如,前面讨论的
isEven()足够小,你可以为它编写测试用例,然后在一步中编写方法体(只需要一行代码),最后运行你的测试用例并根据需要调试。然而,如果你正在编写一个行为更复杂的方法,它可能太复杂而无法在一步中编写。考虑一个方法,它接受三个数字表示三角形三条边的长度,该方法应该返回相应的三角形是等边的、等腰的还是不等边的,同时如果这些边长不能构成三角形则报告该信息。
如果你正在为这个方法编写测试,你将需要检查许多不同类型的情况。它能处理零长度的边吗?它能处理负数吗?它能处理不能构成任何三角形的长度吗?等腰三角形呢?等边三角形呢?不等边三角形呢?你可以将解决方案逻辑中的每个"情况"或分支分离成一个单独的小增量。为你想要的零长度边编写测试用例。然后只实现该部分方法并运行你的测试。接下来,为负数添加测试用例,只为此情况添加额外的代码,并重新运行 所有 测试。一步一步地处理剩余的情况,编写一些测试然后编写实现相应行为的代码。
使用 案例分析 ——即将问题分解为两个或多个子问题,并定义一个或另一个子问题适用的条件——是一个强大的问题解决工具。它在计算机科学中一直出现,并提供了一种将复杂方法分解为更小步骤的方法,这些步骤可以增量测试。
"当条是绿色的,代码就是干净的。" 这条 TDD 格言描述了第三个关键思想:每次你添加一小段代码时,你重新运行 所有 为开发中的单元编写的测试,并且在 所有 测试 100% 通过之前不要进入下一步。
简而言之,先添加少量测试,然后添加相应的(小段)代码,运行所有测试,并立即调试任何问题。除非所有测试都通过,否则你永远没有准备好进入下一个编码步骤(或完成你的解决方案,或准备将你的代码贡献给开源项目等)。你的测试用例是你对代码"正确行为"的表述。因此,这些测试用例是你衡量成功的 标准,随着你增量增长测试套件,你可以看到你离完成所有所需行为有多近。
大多数自动运行测试用例的测试工具会在测试运行时显示一个进度条,只要测试成功就将其涂成绿色,任何测试失败时涂成红色。
传统测试中成功的测试会发现一个或多个缺陷/瑕疵。但在 TDD 中, 当测试失败时你已经取得了进展 ,因为你现在知道你需要解决这个问题。TDD 增加了你对系统确实满足为它定义的需求并且系统确实可以工作的信心。据说你应该"有目的地测试",并知道你为什么测试某个东西以及需要测试到什么程度。此外,使用 TDD 时,当你实现了一个经过良好测试的程序时,每一行代码都被测试了。通常,这是传统测试所推荐的,但并不保证的。
2.3.1.7. 了解更多关于 TDD¶
TDD 是关于编写 "可工作的干净代码"。以下是 Kent Beck 的一些直观描述 TDD 的引言:
这里的风格是:先编写几行代码,然后编写一个应该能通过的测试;或者更好的是,先编写一个通不过的测试,然后再编写代码让它通过。 ... [在弄清楚如何写一小段代码之后...] 现在,我们不想继续编码,而是想获得即时反馈,实践"写一点代码,测试一点,写一点代码,测试一点。" [... 所以我们立即为它编写一个测试。]
TDD 源自 极限编程 并从简单的 XP 思想"建一点,测一点"在编码过程中演化而来。基本上,你的代码总是有一组完整的测试来执行其功能,并且你在添加代码时编写新的测试。
TDD 提供的一个显著优势是它使你在编写软件时能够(鼓励你!)采取小步骤。例如,假设你添加一小段新代码,编译并测试它。迟早当你这样做时,由于代码中的一个或多个缺陷/瑕疵,你的一个或多个测试将失败。然而,通过以小步骤进行,找到并修复这些缺陷/瑕疵要容易得多。问题最可能在你刚才编写的极小段代码中,因为你之前编写的其他代码通过了所有其他测试。如果某些之前正常工作的行为再次中断,它很可能是新添加代码造成的干扰的结果。而且如果你只编写了五行新代码而不是五百行或五千行,缺陷/瑕疵会容易得多地被找到。
2.3.2. 编写你的第一个软件测试¶
在开发过程中为每个方法编写软件测试是你确认理解代码功能、确认代码按预期行为以及尽早发现问题以避免日后引起麻烦的最佳防御。你推迟测试的时间越长,就越难发现问题,你必须修复的缺陷/瑕疵就越多——如果你让太多缺陷/瑕疵堆积起来,你的代码会越来越难以正常工作。
假设你已经创建了一个名为 FlowerPicker 的 Jeroo 子类,你正在编写一个方法来拾取一行花。你将如何测试这个方法?每个软件步骤有三个关键部分,即使有时该部分极其简单:
设置测试的初始条件(创建所需的对象、将它们置于正确的状态、将所有必要的东西放在需要的位置等)。
调用你正在测试的方法。
检查预期的行为是否已经发生。这可能涉及检查方法的返回值,或检查测试中涉及的对象的状态。确保 检查所有 你期望发生的事情,而不仅仅是最大项。
因此,如果你的 FlowerPicker 有一个你想要测试的 pickFlowers() 方法,你需要将它放置在一个可以测试的环境中。你负责,这意味着你可以设置你需要的确切情况。例如,你可以创建你自己的岛屿,并在上面放置花朵。考虑这个岛屿,上面已经放置了几个矩形区域的花朵:
虽然你完全可以为自己需要编写的任何测试创建自己的小岛,但这里我们先使用这个名为 Lab0204Island 的小岛。
要创建你的软件测试,它们是什么样的以及放在哪里?我们将把我们的软件测试编写为普通的 Java 方法,每个测试用例一个方法。像所有 Java 方法一样,你将它们放在某个类中。但是哪个类?我们将使用一个单独的 Java 类来存放我们的测试,并将其称为 测试类。由于我们的 Jeroo 子类名为 FlowerPicker,我们将把它的软件测试放在一个名为 FlowerPickerTest 的新 Java 类中。
备注
按照惯例,我们所有的测试类都以它们正在测试的类命名,并在名称末尾添加 Test。
一个常见的错误是将 Test 放在名称的前面而不是末尾,所以始终仔细检查你的测试类名称。
要在 Eclipse 中创建测试类,请在包资源管理器视图中右键单击要测试的类,然后从菜单中选择 New -> JUnit Test Case (新建 -> JUnit 测试用例)。
Eclipse 会按照我们的约定自动为新类命名。在 "New JUnit Test Case"(新建 JUnit 测试用例)对话框中,请务必:
在顶部附近选择 New JUnit 3 test。
选择合适的 superclass (超类):
对基于 jeroo 的项目(“微世界”项目)使用
student.micro.TestCase;或者非 jeroo 项目使用
student.TestCase。
勾选 Generate comments 复选框。
由于我们的类是一个 Jeroo 类,请确保在顶部添加以下导入语句。导入语句是我们声明希望在代码中访问库类的方式——如果我们不这样做,我们无法使用它们。在顶部添加以下内容:
import student.micro.jeroo.*;
import static student.micro.jeroo.CompassDirection.*;
import static student.micro.jeroo.RelativeDirection.*;
import static org.assertj.core.api.Assertions.*;
我们编写的每个测试用例都将以单个测试方法的形式出现。我们使用以 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
Lab0204Island island = new Lab0204Island();
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
Lab0204Island island = new Lab0204Island();
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
Lab0204Island island = new Lab0204Island();
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 As -> JUnit Test (运行为 -> JUnit 测试)来执行我们目前已有的测试。Eclipse 会执行我们的测试,但和我们的第一个运行配置一样,它会在控制台视图中产生一个错误:
使用 Run -> Run Configurations... 菜单命令,找到单元测试的运行配置。在其 Arguments 选项卡中,为 Jeroo 微世界程序添加所需的 VM 参数(保留 -ea 参数):
--add-modules javafx.controls,javafx.fxml
添加这些参数后,你就可以运行测试而不会出现此错误了。Eclipse 会在其 JUnit 视图中显示以下结果:
JUnit 视图会在上半部分显示所有已运行的测试用例,每个通过的测试旁边都有一个对勾标记,每个失败的测试前面都有一个 "X"。单击任何失败的测试,可以在窗口的下半部分看到相应的消息。这里,第一个断言(Jeroo 最终 x 坐标为 6)没有满足,因为我们还没有实现 pickFlowers(),而且 Jeroo 根本没有移动。我们的测试正在工作,但它在告诉我们 pickFlowers() 的行为不符合我们的预期。
现在我们可以实现 pickFlowers() 来拾取一行花(这 可能不是 你在作业中需要的行为):
public void pickFlowers()
{
while (this.seesFlower(AHEAD))
{
this.hop();
this.pick();
}
}
如果你再次运行你的测试,这次它们通过了。正如俗话所说,"当条是绿色的,代码就是干净的。"
对于处理多种情况或条件的更复杂方法,为每种情况或条件编写单独的测试用例。你的初始条件将不同,事实上你的预期结果也可能不同。但是如果你不编写测试,你将不知道是否存在缺陷/瑕疵。
在使用 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();
在测试用例中表达你期望发生什么行为的选择实际上是无限的,但这些方法将帮助你开始编写你的第一个测试。
2.3.3. 自检:软件测试概念¶
2.3.4. 更多关于方法¶
方法 (method)对应一个动作或一种行为,是一段命名的代码,可以被调用或 唤起 (invoke)来执行一组预先定义的动作。
方法定义由两部分组成:方法头和方法体。一般来说,方法头采用下面的形式,其中某些部分是可选的:
组合在一起,方法定义可能看起来像这样:
把各部分组合起来,一个方法定义看起来可能像这样:
public int addHops()
上面,此方法以访问修饰符 public 开头,声明此方法可以被其他类访问或引用。方法头的下一部分是方法的返回类型。这是方法返回的值的类型(如果有的话)。在上面的方法声明中,我们指定该方法返回一个 int 值作为结果。到目前为止,当我们编写方法时,我们编写了像这样的方法:
public void pickFlowersAndDisableNets()
这里我们看到的不是 int 而是关键字 void,它表示该方法不返回任何东西,只是为它执行的动作而被调用,不期望它返回某种答案。我们稍后会更详细地讨论返回类型。
在方法声明中,方法的名称跟在方法的返回类型后面。这是调用方法时使用的名称。我们可以将方法命名为任何我们想要的名称,但不能包含空格。方法名称之后是方法的 参数列表,我们将在下一节中讨论。
2.3.5. 自检:方法签名¶
2.3.5.1. 使用参数传递信息¶
我们使用过的一些方法需要参数(arguments),即你在调用方法时提供的值。例如,一只 Jeroo 有两个名为 'hop' 的方法。调用 hop() 会让 Jeroo 向前跳一格。但是,如果你在括号里指定一个数字,比如 hop(4) ,Jeroo 就会向前跳四格。
使用方法时,你提供参数。当 编写 方法时,你要为参数命名。参数列表说明需要哪些参数。
例如:
public void turnAndDisable(RelativeDirection direction)
{
this.turn(direction);
this.toss();
}
要调用这个方法,我们必须提供一个相对方向作为参数:
turnAndDisable(RIGHT);
这将使 Jeroo 向右转并禁用一个网。
2.3.5.2. 使用多个参数¶
下面是接收两个参数的方法示例:
public void turnThenHop(RelativeDirection direction, int hops)
{
this.turn(direction);
this.hop(numHops);
}
要调用这个方法,我们必须提供一个整数和一个相对方向作为参数:
turnThenHop(RIGHT, 7);
这将使 Jeroo 向右转然后跳跃七次。
2.3.6. 自检:带参数的方法¶
2.3.7. 编写条件语句的良好习惯¶
和加注释一样,可读性是编写条件语句时的重要因素。
2.3.7.1. 逻辑非与 if-else 结构¶
有一点需要考虑:在编写 if/else 语句时,以 ! 开头通常会让代码更难阅读。
if (!this.isClear(AHEAD))
{
this.toss();
}
else
{
this.hop();
}
上面的 ! 很容易被忽略,从而导致对这个条件语句的误读。相反,更好的做法是像这样改写同一个条件:
if (this.isClear(AHEAD))
{
this.hop();
}
else
{
this.toss();
}
可以看到,从逻辑上讲,这两个 if-then-else 结构达到了同样的效果,但其中一个更容易阅读。
备注
请记住,不使用 ! 运算符来编写正确的条件并非总是可行。尤其是当你没有 else 子句时,你可能需要使用它,但如果能绕过它,那会是很好的做法。
2.3.7.2. 过多的条件语句¶
另一件要记住的事情是不要编写过多的条件。在解决复杂问题时,很容易忍不住为遇到的每一种新情况不断添加新条件。然而,这既更难阅读,也可能在你的代码中引入以后很难发现的 bug。
例如:
if (this.isClear(AHEAD))
{
this.hop();
}
else if (!this.isClear(AHEAD))
{
this.toss();
}
else
{
this.turn(RIGHT);
}
从逻辑上讲,这个条件语句的 if 分支和 else-if 分支与我们上面看到的一样。然而,这里存在第三个永远不会执行的分支。这是因为 Jeroo 前方的区域要么总是畅通的,要么总是不畅通。代码总能在前两个选择之间找到一个要执行的分支,而且永远不存在 else 分支适用的情形。
在计算机术语中,你编写的在任何可能的情况下都无法执行的代码称为 不可达代码。这种代码通常是一个编程问题,因为它无法执行的原因通常是由于不正确的编程逻辑,就像这里的示例一样。前两个分支涵盖了所有可能的情况,所以第三个选项是无用的。
如果你不完全确定两个布尔语句是否等价,写出一个真值表可能会有所帮助。例如,我们可以看到下面 b 和 !!b 是等价的。
|
|
|
|---|---|---|
True |
False |
True |
False |
True |
False |
无论 b 有什么值,我们都可以看到 !!b 与之匹配!
2.3.7.3. 空条件分支¶
不在代码中留下空条件也是一个好习惯。
if (this.isClear(AHEAD))
{
// do nothing
}
else
{
this.turn(RIGHT);
}
人们总是更喜欢只用一个 if 语句,而不是一个空的 if-else。
if (this.isClear(AHEAD))
{
// do nothing
}
else
{
this.turn(RIGHT);
}
这里,比起空条件,更偏好使用 ! 运算符:
if (!this.isClear(AHEAD))
{
this.turn(RIGHT);
}
2.3.7.4. 多个条件与复合条件¶
看一下下面这段代码片段:
if (this.isClear(AHEAD))
{
if (this.seesNet(RIGHT))
{
this.turn(RIGHT);
}
}
这里我们看到一个条件嵌套在另一个条件中。通常,更好的做法是把同样的条件改写成这样:
if (this.isClear(AHEAD) && this.seesNet(RIGHT))
{
this.turn(RIGHT);
}
2.3.8. 更复杂的条件语句¶
有时你想检查相关的条件,并从几个动作中选择一个。一种方法是使用我们的级联 if 结构:
if (molly.isFacing(NORTH))
{
molly.hop();
}
else if (molly.isFacing(SOUTH))
{
molly.hop(2);
}
else if (molly.isFacing(EAST))
{
molly.hop(3);
}
else
{
molly.hop();
molly.toss();
}
这些链可以任意长,尽管如果长得过分会难以阅读。
你还可以把一个条件语句嵌套在另一个条件语句中,从而做出复杂的决策。我们可以把前面的例子写成:
if (molly.isFacing(WEST))
{
molly.hop();
molly.toss();
}
else
{
if (molly.isFacing(NORTH))
{
molly.hop();
}
else if (molly.isFacing(SOUTH))
{
molly.hop(2);
}
else
{
molly.hop(3);
}
}
外部条件语句有两个分支。第一个分支让 Jeroo 跳一次再投掷,第二个分支包含另一个条件语句,它自己有三个分支。
这类结构很常见,但很快就会变得难以阅读。良好的缩进对于让读者看清结构(或预期的结构)至关重要。
2.3.9. 一种不同类型的复杂 if 语句¶
if 语句变得复杂的另一种方式,是创建更长的复合条件。
例如,
if ((caroline.isFacing(NORTH) && caroline.hasFlower())
|| caroline.seesNet(AHEAD))
这个语句可以概括为 if (A || B),其中:
A = caroline.isFacing(NORTH) && caroline.hasFlower()B = caroline.seesNet(AHEAD)
如果 Jeroo 在面朝北方时有一朵花 OR 看到前方有一个网,这个 if 语句将被触发。值得注意的是,如果 Jeroo 只有一朵花,逻辑与将强制语句 caroline.isFacing(NORTH) && caroline.hasFlower() 为 false。因此,Jeroo 必须看到前方有一个网才能触发此 if 语句。
逻辑非也可以否定一个复合语句。
if (!(caroline.isFacing(NORTH) && caroline.hasFlower()))
记住,对于 caroline.isFacing(NORTH) && caroline.hasFlower() 为真,Jeroo 必须有一朵花并且面朝北方。编写 !(caroline.isFacing(NORTH) && caroline.hasFlower()) 只要括号内的复合条件为假就为真。
看这类复杂操作时,人们很容易混淆。当考虑否定的复合条件时,根据 德摩根定律 重写它们可能会对你有帮助:
!(A && B)与!A || !B相同!(A || B)与!A && !B相同
利用这一点,我们不写
if (!(caroline.isFacing(NORTH) && caroline.hasFlower()))
而是写一个逻辑上等价的式子:
if (!caroline.isFacing(NORTH) || !caroline.hasFlower())
同样,如果我们使用真值表,我们可以看到这两列匹配:
|
|
|
|
|
|
|
|---|---|---|---|---|---|---|
True |
True |
True |
False |
False |
False |
False |
True |
False |
False |
True |
False |
True |
True |
False |
True |
False |
True |
True |
False |
True |
False |
False |
False |
True |
True |
True |
True |
2.3.10. 短路求值¶
布尔运算符的另一个重要特性是它们采用了一种称为短路求值(short-circuit evaluation)的求值形式。在 短路求值 中,布尔表达式从左到右求值,一旦表达式的值能够确定,求值就会停止,无论其中是否还包含其他运算符和操作数。例如,在表达式
basil.isFacing(WEST) && basil.seesNet(AHEAD)
如果 basil.isFacing(WEST) 为 false,则 AND 表达式必须为 false。因为计算机已经知道整个 AND 表达式为 false,它将不会求值 basil.seesNet(AHEAD),因为没有必要。
类似地,在表达式:
basil.isFacing(NORTH) || basil.seesNet(AHEAD)
如果 basil.isFacing(NORTH) 为 true,则计算机知道整个 OR 表达式也将为 true,因此它不会求值 basil.seesNet(AHEAD),因为这是不必要的。
2.3.11. 自检:逻辑等价¶
2.3.11.1. 使用基本数据类型的比较运算符¶
比较运算符用于检查条件,如两个值是否相等,或者一个是否大于另一个。这类运算符完全不适用于对象,因此你不能将它们用于 Jeroo。不过,它们适用于数值,比如 int。下面的表达式展示了它们的使用方式。
运算符 |
示例 |
含义 |
|---|---|---|
|
|
x 等于 y |
|
|
x 不等于 y |
|
|
x 大于 y |
|
|
x 小于 y |
|
|
x 大于或等于 y |
|
|
x 小于或等于 y |
关系运算符的结果是两个布尔值之一:true 或 false。这些值属于数据类型 boolean;事实上,它们是唯一的 boolean 值。
一个常见的错误是当你想比较两个值时使用单个 = 而不是双等号 ==。请记住 = 是赋值运算符,== 是比较运算符。此外,意外编写 =< 或 => 将产生编译器错误。等号总是在 < 或 > 之后,就像你在英语中说出那些比较的名称一样:"less than or equal"先有小于符号,然后是等号。
一个常见的错误是,当你想比较两个值时,误用单个 = 而不是双 ==。请记住,= 是赋值运算符,而 == 是比较运算符。另外,不小心写出 =< 或 => 会产生编译错误。等号总是位于 < 或 > 之后,就像你用英文说出这些比较的名称一样:“小于或等于”是先写小于号,再写等号。
