CS61B 课程笔记(Lecture 07)

测试

3.1 一种新方式

编程中最重要的技能之一是判断代码是否正确。在这一部分,我们将探讨如何编写自己的测试,并引入一种新的思维方式。我们将先编写一个测试方法 testSort(),然后再编写实际的排序代码。这种方法有助于确保我们的排序实现是正确的。

临时测试(Ad Hoc Testing)

下面是一个简单的测试实现示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class TestSort {
/** 测试 Sort 类的 sort 方法。 */
public static void testSort() {
// 定义输入和期望输出
String[] input = {"i", "have", "an", "egg"};
String[] expected = {"an", "egg", "have", "i"};

// 调用待测试的排序方法
Sort.sort(input);

// 验证排序结果
for (int i = 0; i < input.length; i += 1) {
if (!input[i].equals(expected[i])) {
// 如果结果不匹配,打印错误信息
System.out.println("Mismatch in position " + i + ", expected: " + expected[i] + ", but got: " + input[i] + ".");
break; // 找到第一个不匹配后停止检查
}
}
}

public static void main(String[] args) {
// 执行测试
testSort();
}
}

运行 testSort() 方法时,如果 Sort.sort() 方法为空,将输出:

1
Mismatch in position 0, expected: an, but got: i.

这表明排序方法尚未实现,因此测试失败。

JUnit 测试

为了简化测试的编写,Java 提供了 org.junit 库。这个库允许我们使用断言来检查实际输出是否与预期输出相匹配。使用 JUnit 进行测试的实现如下:

1
2
3
4
5
6
7
8
9
10
11
public static void testSort() {
// 定义输入和期望输出
String[] input = {"i", "have", "an", "egg"};
String[] expected = {"an", "egg", "have", "i"};

// 调用待测试的排序方法
Sort.sort(input);

// 使用 JUnit 的断言检查数组是否相等
org.junit.Assert.assertArrayEquals(expected, input);
}

如果运行此代码时发现不匹配,会抛出如下异常:

1
Exception in thread "main" arrays first differed at element [0]; expected:<[an]> but was:<[i]>

这个代码片段比前面的实现更简洁,并且使用了 JUnit 提供的断言来自动检查结果。

assertArrayEquals 语法

assertArrayEquals 方法用于比较两个数组是否相等。如果两个数组的内容相同,则测试通过;如果不同,则测试失败并抛出异常。其基本语法如下:

1
org.junit.Assert.assertArrayEquals(expectedArray, actualArray);

参数说明

  • expectedArray:预期的数组,表示你希望排序后得到的结果。
  • actualArray:实际的数组,表示经过排序方法处理后的结果。

示例代码

以下是完整的 testSort 方法示例,包含了对 assertArrayEquals 的使用:

1
2
3
4
5
6
7
8
9
10
11
public static void testSort() {
// 定义输入和期望输出
String[] input = {"i", "have", "an", "egg"};
String[] expected = {"an", "egg", "have", "i"};

// 调用待测试的排序方法
Sort.sort(input);

// 使用 JUnit 的断言检查数组是否相等
org.junit.Assert.assertArrayEquals(expected, input);
}

运行测试

当你运行这个测试时,如果 Sort.sort(input) 方法正确地对数组进行了排序,input 数组将与 expected 数组相等,测试将通过。如果排序不正确,JUnit 将会输出错误信息,指出实际结果与预期结果不符,并显示具体的差异。

错误示例

如果 Sort.sort(input) 没有正确排序,可能会看到类似以下的错误信息:

1
Exception in thread "main" arrays first differed at element [0]; expected:<[an]> but was:<[i]>

这个错误信息表明在数组的第一个元素(索引为0)处,预期的值是 "an",但实际得到的值是 "i"。这可以帮助你快速定位问题。

选择排序(Selection Sort)

反思开发过程,拥有一组自动化测试可以帮助减轻认知负担。测试的意义在于:

  • 提供信心:通过测试,我们可以对程序的基本部分充满信心,这样如果出现问题,就可以更容易找到问题所在。
  • 促进重构:测试使得重构代码变得更简单。我们可以在重构代码时,依赖已有的测试来确保新代码的正确性。

更好的 JUnit

两项主要增强功能,使代码更清晰、更易于使用。

  1. 使用测试注解

    • 在每个测试方法前加上 @org.junit.Test 注解(不需要分号)。
    • 将测试方法改为非静态方法。
    • TestSort 类中移除 main 方法。这样做之后,如果在 JUnit 中使用 Run->Run 命令,所有测试会自动执行,无需手动调用。
  2. 简化导入和调用

    • 在文件顶部添加导入语句:

      1
      2
      import org.junit.Test;
      import static org.junit.Assert.*;
    • 使用简化的注解 @Test 替代 @org.junit.Test

    • 这样一来,我们可以省略 org.junit.Assert. 的前缀。

测试哲学

单元测试的重要性

单元测试是软件开发中的一种关键实践,它要求开发者在编写代码之前,先清晰地定义每个代码单元(如函数或方法)的预期行为。通过这种方式,单元测试提供了一种结构化的方法来确保代码的正确性和可靠性。

主要目标
  1. 明确功能预期
    • 在编写测试之前,开发者需要仔细考虑方法的目的。这包括明确该方法应该完成什么任务、处理哪些输入以及预期的输出是什么。
    • 这种过程促使开发者思考代码的设计,减少模糊性和不确定性。
  2. 定义输入和输出
    • 每个测试用例应具体描述输入数据及其类型。例如,如果一个方法接受整数列表作为输入,测试用例需要明确给定的列表内容。
    • 同样,输出的预期结果也需要明确。例如,如果一个方法对列表进行排序,预期的输出应该是已排序的列表。
  3. 考虑边界条件
    • 边界条件是指输入数据的极限情况,例如空输入、非常大的数字、特殊字符等。
    • 测试时应包括这些边界情况,以确保代码能够稳健地处理所有可能的输入。比如,如果一个函数处理数组,测试用例应包括空数组、单元素数组以及非常大的数组。
测试用例的设计

有效的测试用例设计应考虑以下要素:

  • 功能性测试:确保方法按照预期工作,返回正确的结果。
  • 异常情况测试:测试代码在面对错误输入或异常情况下的行为,确保能适当地处理这些情况(如抛出异常)。
  • 性能测试:对于性能敏感的代码,测试其在不同规模输入下的表现,确保满足性能要求。
  • 可维护性测试:确保方法在代码重构后仍然通过测试,维护其功能的完整性。

测试驱动开发(TDD)

测试驱动开发是一种开发过程,强调在编写代码之前先编写测试。具体步骤如下:

  1. 识别新特性:确定需要实现的新功能。
  2. 编写单元测试:为该功能编写测试代码,描述预期行为。
  3. 运行测试:执行测试,确认测试失败(因为尚未实现该功能)。
  4. 编写实现代码:编写代码以实现功能,使测试通过。
  5. 可选:重构代码:对代码进行重构,以提升性能和可读性,但要确保现有测试仍然通过。

虽然 TDD 在本课程中并非强制要求,单元测试却是一个良好的编程实践,可以帮助开发者在代码中保持信心。

集成测试

集成测试用于验证多个组件之间的交互是否正常。这种测试确保不同模块协同工作,确保系统的整体功能。

总结

编写测试是良好编程习惯的体现,只有在测试有意义时才进行测试。可选材料包括对测试驱动开发和单元测试的深入讨论,帮助理解其在现代软件开发中的重要性。