Lab 2:JUnit 与调试
目录#
Lab 前准备#
- 完成 Lab 2 Setup ↗。
- 在你的仓库中运行
git pull skeleton master。你应该会得到一个lab2/文件夹。
简介#
在本 Lab 中,你将学习如何使用 IntelliJ 调试器,以及如何在 IntelliJ 中使用 JUnit 测试。
调试器基础#
重复 Lab 2 Setup 中的“Project Setup”流程。不过,这一次你应当“打开或导入”lab2/pom.xml 文件,而不是 lab2setup/pom.xml 文件。
导入完成后,你的 IntelliJ 应当大致如下所示:

断点与 Step Into#
我们先运行 DebugExercise1 中的 main 方法。在 IntelliJ 中打开这个文件,然后单击运行按钮。控制台中应当打印出三条语句,其中有一条应该会让你觉得明显不正确。假如你不确定怎样运行 DebugExercise1,请在文件列表中右键单击它,然后单击下图所示的 Run DebugExercise1.main 按钮:

我们的代码中某个地方存在 bug,但不要开始仔细阅读代码来寻找它!虽然这个特定的 bug 也许能被你直接看出来,但很多时候,如果不真正运行代码并在代码执行时探查正在发生的事情,bug 几乎不可能被看见。
你们中的许多人都非常熟悉使用打印语句来探查程序运行时正在“思考”什么。打印语句对调试非常有用,但它们也有几个缺点:
- 它们要求你修改代码(以便添加打印语句)。
- 它们要求你明确说出自己想知道什么(因为你必须精确指定要打印的内容)。
- 它们以一种可能很难阅读的格式提供结果,因为执行窗口中只会出现一大团文本。
如果使用调试器,寻找 bug 通常(但并非总是)会耗费更少的时间和脑力。IntelliJ 调试器允许你在代码执行到一半时暂停程序,逐行执行代码,甚至可以把链表之类的复杂数据结构组织方式可视化出来。
调试器虽然强大,但必须正确使用,才能真正获得优势。我们鼓励你采用一种可以称为“科学调试”的方式,也就是使用一种与科学方法非常相似的过程进行调试!
一般来说,你应当先对某段代码应该怎样运行提出假设,然后使用调试器判断这些假设是否成立。每获得一条新的证据,你就进一步修正自己的假设,直到最后不得不直接撞上那个 bug。
第一个练习将介绍两个核心工具:breakpoint 和 step over 按钮。在左侧的 Project 视图中,右键单击(或双指单击)DebugExercise1 文件;这一次不要选择 Run,而要选择 Debug。假如没有出现 Debug 选项,说明你没有正确导入 lab2 项目(请查看 lab2setup 中的说明)。

你会看到程序只是再次运行了一遍,看起来没有任何区别!这是因为我们还没有交给调试器任何有趣的事情。让我们通过“设置断点”来解决这个问题。滚动到写有 int t3 = 3; 的那一行,然后单击行号右侧的位置。你应该会看到一个略微像停车标志的红点,这意味着我们现在已经设置了一个断点。如果再次以调试模式运行程序,它就会在这一行停下来。
假如你不想通过右键单击来再次运行程序,也可以单击屏幕右上角的虫子图标。展示本段步骤的动画 GIF(来自以前的学期)可以在这个链接 ↗中看到。

如果你单击调试按钮之后没有看到文本控制台(其中会显示类似 round(10/2) 的内容),那么继续之前可能还需要执行一个额外步骤。在底部面板的信息窗口左上方,你应该能看到名为 “Debugger” 和 “Console”(以及 “Java Visualizer”)的标签页。单击并把 “Console” 窗口拖到下方面板的最右侧。这样就能同时显示调试器和控制台。
展示这一过程的动画 GIF(来自以前的学期)可以在这个链接 ↗中看到。
单击调试按钮后(并在必要时让控制台窗口可见),你应该会看到程序暂停在设置断点的那一行;底部还应该显示所有变量的列表,其中包括 t、b、result、t2、b2 和 result2。单击 “step into” 按钮可以让程序向前执行一步;它是一个如下所示、箭头朝下的按钮:

本 Lab 稍后会讨论其他按钮。请确保按下的是 step into,而不是 step over。Step Into 的箭头笔直朝下,而 Step Over 的箭头会先向右上方,再向右下方。
每单击一次这个按钮,程序就会向前执行一步。每次单击之前,先对变量将会如何变化提出一个假设。
注意:当前高亮显示的行是即将执行的行,而不是刚刚已经执行完的行。
重复这一过程,直到找到某一行,其结果与你的预期或代码编写者的预期不符。尝试判断为什么这一行没有做出你预期的事情。如果第一次错过了 bug,请单击停止按钮(红色方块),然后单击调试按钮,从头重新开始。找到 bug 后,你可以选择修复它。
Step Over 与 Step Out#
正如我们在构造和组合程序时依赖分层抽象一样,在调试程序时也应当依赖抽象。IntelliJ 中的 “step over” 按钮使这成为可能。前一个练习中的 “step into” 会显示程序字面意义上的下一步,而 “step over” 按钮则允许我们完成一次函数调用,却不展示这个函数内部的执行过程。
DebugExercise2 中的 main 方法应当接收两个数组,计算两个数组逐元素的最大值,然后把得到的这些最大值相加。例如,假设两个数组是 {2, 0, 10, 14} 和 {-5, 5, 20, 30}。逐元素最大值为 {2, 5, 20, 30};例如,在第二个位置中,“0”和“5”中较大的数是 5。这个逐元素最大值数组的元素总和为 2 + 5 + 20 + 30 = 57。
提供的代码中有两个不同的 bug。这个练习的任务是修复这两个 bug,但有一条特殊规则:你不应当进入 max 或 add 函数,也不应当尝试理解它们。这些函数非常奇怪,它们使用某些语法(而且代码风格很差),以一种极其晦涩的方式完成本来很简单的工作。如果你不小心进入了这两个函数之一,请使用 “step out” 按钮(一个朝上的箭头)退出。
即使不进入这些函数,你仍然应当能够判断它们是否有 bug。这正是抽象的美妙之处!即使我不了解鱼在分子层面是如何运作的,在某些情况下,我仍然能够清楚判断一条鱼已经死了。
如果发现这些函数中的某一个存在 bug,应当完全重写它,而不是尝试修补它。
现在我们已经告诉你 “step over” 的作用,请自己探索它究竟如何工作,并尝试找出这两个 bug。假如你遇到的问题是,右上角的运行(或调试)按钮一直运行 DebugExercise1,请右键单击 DebugExercise2 来运行它。
如果卡住了,或者只是想获得更多指导,请阅读下面的说明。
进一步指导(提供给需要的人)#
首先尝试运行程序。main 方法会计算一个答案并把它打印到控制台。尝试手动计算答案,你会看到打印出来的答案不正确。如果不知道怎样手动计算答案,请重新阅读上面对函数目标的说明,或者阅读所提供代码中的注释。
接着,在 main 中调用 sumOfElementwiseMaxes 的那一行设置断点。然后单击调试按钮,再使用 Step Into 到达 sumOfElementWiseMaxes 的第一行。接着,在调用 arrayMax 的那一行使用 “step over” 按钮。输出有什么问题(如果有)?也就是说,它在哪些方面没有符合你的预期?注意,为了查看数组的内容,你可能需要在下方面板调试器窗口的 Variables 标签页中,单击变量名称旁边朝右的小三角形。
如果你认为其中存在 bug,请进入 arrayMax(而不是跨过它)并尝试找到 bug。提醒:不要进入 max。你应当能够通过 Step Over 判断 max 是否有 bug。如果 max 有 bug,请把它完全替换掉。
对 arraySum 和 add 重复同样的过程。修复两个 bug 后,再次确认 sumOfElementwiseMaxes 方法能够针对给定输入正确工作。注意:这并不能证明 sumOfElementwiseMaxes 是正确的,但现在没有必要编写额外测试来帮助验证这一点(下周将会做这件事)。
回顾:调试#
到这里,你应当理解以下工具:
- 断点;
- Step Over;
- Step Into;
- Step Out(尽管本 Lab 中你可能实际上没有使用这个功能)。
不过,这些内容只触及了调试器功能的表面!你可以自由进行试验。例如,你可以尝试弄清楚 “Watches” 标签页的作用。另一个我们不会讲解、但很方便的功能是 “Evaluate Expression” 按钮(Step Into/Over/Out 按钮所在那一行最后几个按钮中的一个,看起来像计算器)。在 Lab 3 和 Lab 4 中,我们还会展示一些额外的调试器功能。
还务必查看课程的调试指南 ↗!其中讨论了本 Lab 没有覆盖的一些功能(例如条件断点);当你需要调试帮助时,它仍然是一份非常好的总览。
JUnit 与单元测试#
现在,我们把注意力转向 Lecture 3 中介绍过的 JUnit 与单元测试。
单元测试是一种严格测试代码中各个方法的好办法,并最终帮助你确保自己拥有一个能够工作的项目。
单元测试中的 “Unit” 来自这样一种思想:可以把程序拆分为若干单元,也就是应用程序中最小的可测试部分。因此,单元测试会促使你使用良好的代码结构(每个方法应当只做“一件事”),并且允许你考虑每个方法的所有边界情况,再分别对它们进行测试。
在本课程中,你将使用 JUnit 创建并运行测试,以确保代码的正确性。当 JUnit 测试失败时,你也会获得一个非常好的调试起点。此外,假如遇到了某个难以修复的糟糕 bug,你可以使用 Git 回退到根据 JUnit 测试判断、代码仍然能够正常工作的状态(到 Lab 4 时,我们会讨论如何把代码恢复到旧版本)。
JUnit 语法#
正如 Lecture 中讨论的那样,JUnit 测试使用 Java 编写。
打开 ArithmeticTest.java。
你首先会注意到顶部的 import(IntelliJ 有时会把它们缩短为 import ...;只需单击 ...,即可展开并查看具体导入了什么)。这些 import 让你能够轻松访问运行 JUnit 测试所需的 JUnit 方法和功能。有关更多信息,请查看测试 Lecture 视频 ↗。
接下来,你会看到 ArithmeticTest.java 中有两个方法:testProduct 和 testSum。这些方法遵循以下格式:
@Test
public void testMethod() {
assertEquals(<expected>, <actual>);
}javaassertEquals 是 JUnit 测试中常用的方法。它会测试某个变量的实际值是否等于其预期值。
创建 JUnit 测试文件时,应当在每个测试方法之前添加 @Test 注解;一个测试方法中可以包含一个或多个 assertEquals 或 assertTrue 方法(由 JUnit 库提供)。所有测试都必须是非静态的。由于测试不会使用实例变量,而且你大概也不会实例化这个类,这一点可能显得很奇怪。不过,JUnit 的设计者决定测试就应当这样编写,所以我们照做即可。
在 IntelliJ(或其他 IDE)中运行 JUnit 测试#
注意:如果你决定不使用 IntelliJ,那么在运行 JUnit 测试这件事上,你需要自行解决。课程工作人员没有接受过其他 IDE、命令行编译工具或命令行执行工具的培训,也不会为选择这些工具的学生提供支持。
打开 ArithmeticTest.java 后,单击 IntelliJ 顶部 Run 菜单下的 Run... 选项,如下面的截图所示。

单击 “Run…” 后,你应该会看到若干个选项,外观大致如下。你列表中的项目数量可能不同。

我们关心的是红绿箭头旁边写有 “ArithmeticTest” 的那个选项(上图中编号 1 旁边的项目)。
选择它,你应该会看到类似下面的结果:

这表示 ArithmeticTest.java 第 25 行的测试失败了。测试预期 5 + 6 等于 11,但 Arithmetic 类声称 5 + 6 等于 30。你会看到,尽管 testSum 中包含多条 assert 语句,却只显示了一次失败。
这是因为 JUnit 测试会短路:一个方法中的某条断言一旦失败,它就会输出失败信息并转到下一个测试。
尝试单击屏幕底部窗口中的 ArithmeticTest.java:27,IntelliJ 会直接把你带到导致测试失败的那一行。在后续项目中运行自己的测试时,这个功能会很方便。
现在修复这个 bug。你可以检查 Arithmetic.java 并找出 bug,也可以使用 IntelliJ 调试器逐步执行代码,直到到达 bug 所在位置。
修复后,重新运行测试;如果使用默认渲染器,你应该会看到一条漂亮而光荣的绿色横条。享受这种快感吧。
应用:IntList#
正如周一 Lecture 中讨论的那样,IntList 是 CS61B 对整数裸递归链表的实现。每个 IntList 都有一个 first 变量和一个 rest 变量。first 是这个结点所包含的 int 元素,而 rest 是链表中的下一段(另一个 IntList!)。
我们创建了一个 IntListExercises.java 文件,其中包含三个方法,并且每个方法都有 bug。本节的任务是找出并修复这些 bug!为了帮助你,我们添加了一些有用的起始代码和测试骨架,下面将对此进行说明。
起始代码#
相对于周一 Lecture 中的实现,我们在 IntList 类中增加了两个方法:print 和 of。of 方法是一种用于创建 IntList 的便捷方法。下面快速演示它的使用方式。考虑你在 Lecture 中见过的以下代码,它会创建一个包含元素 1、2 和 3 的 IntList。
IntList lst = new IntList(1, new IntList(2, new IntList(3, null)));java这需要输入很多内容,而且相当令人困惑!IntList.of 方法解决了这个问题。要创建一个包含元素 1、2 和 3 的 IntList,只需输入:
IntList lst = IntList.of(1, 2, 3);java是不是很棒?!它适用于包含任意数量元素的列表!
// Creates an empty list!
IntList empty = IntList.of();
// Creates an IntList one element, 7
IntList oneElem = IntList.of(7);
// Creates an IntList with many elements
IntList manyElems = IntList.of(5, 4, 3, 2, 1);java另一个方法 print 会返回 IntList 的 String 表示。
IntList lst = IntList.of(1, 2, 3);
System.out.println(lst.toString())
// Output: 1 -> 2 -> 3java严格来说,这些方法没有为 IntList 类添加任何真正的新功能,但它们分别提供了创建和显示 IntList 的便捷方式。我们会使用这些便捷方法让测试变得更容易;在编写自己的 JUnit 测试来调试 IntList 时,你也会练习使用它们!
A 部分:遍历 IntList#
这一部分将调试 IntListExercises.java 中的 addConstant 方法。这个方法的目标是接收一个 IntList,并以可变方式把一个常量加到列表中的每个元素上。
/* Expected Behavior */
IntList lst = IntList.of(1, 2, 3);
addConstant(lst, 1);
System.out.println(lst.toString());
// Output: 2 -> 3 -> 4
addConstant(lst, 4);
System.out.println(lst.toString());
// Output: 6 -> 7 -> 8java糟糕!起始代码中提供的 addConstant 实现有 bug!我们在 AddConstantTest.java 中提供了三个测试,可以帮助你隔离这个 bug。这些测试会使用上面提到的 IntList.toString 和 IntList.of 方法!使用 Java 调试器逐步执行每个测试,以帮助你隔离 bug。隔离出 bug 后,修复它。
B 部分:嵌套辅助方法,以及为了调试而重构#
这一部分将调试 IntListExercises.java 中的 setToZeroIfMaxFEL 方法。
这个方法会执行一项非常奇怪的任务。具体来说,如果且仅当从 IntList 中某个结点开始的子列表的最大值首位数字与末位数字相同,它就会把该结点的值替换为 0。因此,方法名中的 FEL 是 “first equals last” 的缩写。
例如,如果传入 IntList 55 -> 22 -> 45 -> 44 -> 5,它会把 55、44 和 5 的值设为零,使列表变成 0 -> 22 -> 45 -> 0 -> 0。原因如下:
- 从 55 开始的 IntList 最大值是 55,其首位数字与末位数字相同,因此这个值被设为零。
- 从 22 开始的 IntList 最大值是 45,其首位数字与末位数字不同,因此 22 不变。
- 从 45 开始的 IntList 最大值是 45,其首位数字与末位数字不同,因此 45 不变。
- 从 44 开始的 IntList 最大值是 44,其首位数字与末位数字相同,因此 44 被设为零。
- 从 5 开始的 IntList 最大值是 5,其首位数字与末位数字相同,因此 5 被设为零。
为了测试你的理解,请考虑 IntList 5 -> 535 -> 35 -> 11 -> 10 -> 0。调用 setToZeroIfMaxFEL 后,列表应当是什么?查看 SetToZeroIfMaxFELTest 中的 testZeroOutFELMaxes3,以核对课程给出的答案。
运行测试后,你会看到这个方法有 bug。具体来说,测试 3 会失败。
在 setToZeroIfMaxFEL 的第一行设置断点,并且只调试 testZeroOutFELMaxes3。要调试单个测试,请打开 SetToZeroIfMaxFELTest.java,找到方法 testZeroOutFELMaxes3(),然后单击方法定义左侧的绿色箭头图标。
如果这个测试以前已经运行过,绿色箭头图标可能会变成一个绿色对勾和绿色箭头的组合(如果测试以前通过),或者变成一个红色感叹号和绿色箭头的组合(如果测试以前失败)。下面展示了后面这两种情况的示例。

使用几次 Step Into,它会把你带到写有 if (firstDigitEqualsLastDigit(max(p))) 的那一行。第三次单击 Step Into 时,你会看到 firstDigitEqualsLastDigit 和 max 同时被高亮显示,如下所示:

由于这里存在嵌套函数调用,IntelliJ 正在询问你想进入哪个函数。你可以任选其一。如果单击 max,就会看到 max 调用的所有细节。如果单击 firstDigitEqualsLastDigit,则会跨过对 max 的调用。
我个人认为,这样的代码很难调试!在这种情况下,我使用的一种策略是重构代码,让它更适合调试。让我们试一试!
把代码改成下面这样:
int currentMax = max(p);
boolean firstEqualsLast = firstDigitEqualsLastDigit(currentMax);
if (firstEqualsLast) {
p.first = 0;
}java完成修改后,使用 Step Over 找出是哪一次对 max 或 firstDigitEqualsLastDigit 的调用产生了错误答案。重要:在你找到某次产生错误答案的 max 或 firstDigitEqualsLastDigit 调用之前,不要使用 Step Into。否则你只是在浪费时间,逐行查看每一行代码。也就是说,如果你在观察 max 函数每次调用的每次迭代,就说明你没有正确使用调试器!
找到产生奇怪结果的 max 或 firstDigitEqualsLastDigit 调用后,从头重新开始调试;这一次,当再次到达那个曾产生奇怪结果的参数时,请单击 Step Into,而不是 Step Out。注意:在 Lab 4 中,我们会讨论一种叫作“条件断点”的实用思想,它可以让你不必从头重新开始。
识别出 bug 后,修复它。你也可以把重构后的代码恢复为单行版本,即 if (firstDigitEqualsLastDigit(max(p)))。
注意:在真实世界中,理想情况下,你会在 setToZeroIfMaxFEL 方法中使用 max 和 firstDigitEqualsLastDigit 之前,分别测试它们。
C 部分:棘手的 IntList#
这一部分将调试 IntListExercises.java 中的 squarePrimes 方法。这个方法的目标是接收一个 IntList,把其中所有质数元素平方,并让合数(非质数)元素保持不变。如果至少有一个元素被平方,它返回 true;否则返回 false。
例如,考虑一个包含元素 14、15、16、17 和 18 的 IntList。运行 squarePrimes 后,我们希望质数元素 17 被平方,而合数元素 14、15、16 和 18 保持不变。squarePrimes 函数的输出应当是 true,因为它应当修改 IntList,使其变为 14, 15, 16, 289, 18。用代码表示如下:
/* Expected Behavior */
IntList lst = IntList.of(14, 15, 16, 17, 18);
System.out.println(lst.toString());
// Output: 14 -> 15 -> 16 -> 17 -> 18
boolean changed = squarePrimes(lst);
System.out.println(lst.toString());
// Output: 14 -> 15 -> 16 -> 289 -> 18
System.out.println(changed);
// Output: truejavasquarePrimes 方法使用函数 Primes.isPrime(int x) 作为辅助方法。isPrime 的作用很简单:如果参数是质数就返回 true,如果参数是合数就返回 false。你不需要关心它如何判断一个数是不是质数——那相当复杂!(可选:感兴趣的读者可以在网上查阅 “Fermat Primality Test”。)相反,我们希望你把 isPrime 函数视为黑盒。
调试时,请对 isPrime 函数使用 “Step Over”。这样你就能验证它的输入和输出是否正确,而无须关心它的实现。
上面已经解释了 squarePrimes 方法应当做什么。不幸的是,squarePrimes 方法有 bug。你的任务是找到并修复这个 bug!为此,我们建议你:
- 创建一个或多个 JUnit 测试,针对多种不同输入测试
squarePrimes方法。务必测试它是否正确更新传入的IntList,以及是否返回正确的boolean值。 - 一旦创建出了一个会让
squarePrimes失败的 JUnit 测试,你就取得了进展!太棒了!现在使用 Java 调试器逐步执行有问题的代码并隔离 bug。 - 最后,为 bug 编写修复代码。对于这个特定 bug,修复只需要很少几行代码。找到 bug 比修复它困难得多!
为了帮助你开始这项任务,我们已经创建了一个 JUnit 测试:SquarePrimesTest.testSquarePrimesSimple。这个方法会检查上面给出的示例(包含 14、15、16、17 和 18 的 IntList)是否被 squarePrimes 函数正确修改,以及 squarePrimes 函数是否返回正确的值(在这个例子中是 true)。不幸的是,这个测试会通过:你必须另外编写一个会失败的测试!你可以把 testSquarePrimesSimple 作为编写 JUnit 测试的示例。
祝你好运!
提交#
和以前一样,把代码推送到 GitHub,并提交到 Gradescope 来测试代码。你会注意到,有一些测试显示为 “Hidden”。这意味着我们不会向你透露测试做了什么;如果测试失败,我们会故意给出一条含糊的错误消息。这是因为在本 Lab 中,我们希望你专注于学习如何自行调试,而不是依赖自动评分器提供的详细消息。
完整回顾#
本 Lab 介绍了:
- 在 IntelliJ 调试器中 Step Into、Step Over 和 Step Out(这些功能在项目中会很有用!);
- 单元测试(整体概念);
- JUnit 的语法与细节;
- 编写 JUnit 测试;
- 使用 JUnit 进行调试;
- 运行代码风格检查器。
FAQ 与常见问题#
String 或 String.equals() 之类的内容变红了#
这是 JDK 问题。请前往 File > Project Structure > Project > Project SDK 进行排查。如果你的 Java 版本是 15.0,就应当拥有 15.0 SDK,并把 “Project Language Level” 设为 Level 15。