Java 挑战(十)
十三、JUnit 5 简介
JUnit 是用 Java 编写的框架,支持测试用例的创建和自动化。它很容易学习,并且需要大量的工作来编写和管理测试用例。特别是,只需要实现测试用例本身的逻辑。因此,该框架支持各种方法,利用这些方法可以建立和评估测试断言。
1 编写和运行测试
1.1 示例:第一个单元测试
为了测试一个应用程序类,通常会编写一个相应的测试类。通常,您会通过测试一些核心方法来验证自己的类的重要功能。这是可取的逐步延长。测试用例被表示为特殊的测试方法,必须用注释@Test标记,并且不能定义返回类型。否则,它们不会被 JUnit 视为测试用例,在测试执行过程中会被忽略。
让我们看一个简单的例子,它仅仅说明了所说的内容,但是还没有测试任何功能;相反,它只是提供了一个基本框架:
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;
class FirstTestWithJUunit5
{
@Test
void test()
{
fail("Not yet implemented");
}
}
注释@Test来自包org.junit.jupiter.api,而fail()是从类org.junit.jupiter.api.Assertions导入的。后者是静态导入的,以便在调用测试方法时允许更短的符号和更好的可读性。
1.2 编写和运行测试的基础
现在你知道评估条件的方法了。类Assertions提供了一组测试方法,可以用来表达条件,从而检查关于被测源代码的断言:
-
重载方法
assertTrue()和assertFalse()允许你检查布尔条件。前一种方法假设条件的计算结果为true。反之对assertFalse()有效。 -
使用重载方法
assertNull()或assertNotNull()方法,可以检查null或不等于null的对象引用。 -
重载方法
assertEquals()检查两个对象的内容是否相等(调用equals(Object))或者两个原始类型的变量是否相等。由于float和double类型的计算中可能存在舍入误差,因此可以注意到与预期值的最大偏差。 -
使用重载方法
assertSame()或assertNotSame()根据==检查对象引用是否相等。 -
使用
fail(),有可能故意让测试用例失败。这有时对于能够对意外情况做出反应是有用的。 -
JUnit 5 通过使用
assertThrows()方法提供了一种检查预期测试用例是否失败的简洁方法。
下面的代码(JUnit5ExampeTest)展示了一些正在使用的方法。请注意,在本例中,各种测试方法会故意引发错误:
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;
import java.util.List;
public class JUnit5ExampleTest
{
@Test
public void testAssertTrue()
{
final List<String> names = List.of("Max", "Moritz", "Tom");
assertTrue(names.size() > 2);
}
@Test
public void testAssertFalse()
{
final List<Integer> primes = List.of(2, 3, 5, 7);
// an error is intentionally provoked here
assertFalse(primes.isEmpty());
}
@Test
public void testAssertNull()
{
assertNull(null);
}
@Test
public void testAssertNotNull()
{
// an error is intentionally provoked here
assertNotNull(null, "Unexpected null value");
}
@Test
public void testAssertEquals()
{
assertEquals("EXPECTED", "expected".toUpperCase());
}
@Test
public void testAssertEqualsWithPrecision()
{
assertEquals(2.75, 2.74999, 0.1);
}
@Test
public void testFailWithExceptionJUnit5()
{
assertThrows(java.lang.NumberFormatException.class, () ->
{
// an error is intentionally provoked here
final int value = Integer.parseInt("Fehler simulieren!");
});
}
}
用 assertAll()测试多个断言
当制定测试用例时,经常需要检查多个条件,例如地址的各个部分。在 JUnit 5 中,这种语义分类和所有断言的执行都可以通过方法assertAll()来实现:
@Test
void assertAachenZipAndCityAndCountry()
{
final Address address = // ...
assertAll("Address components",
() -> assertEquals(52070, address.getZipCode()),
() -> assertEquals("Aachen", address.getCity()),
() -> assertEquals("Deutschland", address.getCountry()));
}
测试执行
JUnit 与流行的 ide 完美集成。这允许直接从 IDE 中执行测试。要执行测试,您可以使用 GUI 中的上下文菜单或按钮。输出类似于图 B-1 中所示的输出。红色条表示错误。理想情况下,你会看到一个令人放心的绿色,报告所有测试用例成功完成。
图 B-1
从 IDE 的 GUI 执行测试
Eclipse 插件 MoreUnit
即使 JUnit 与 Eclipse 很好地集成,测试用例不仅可以执行,甚至可以调试,仍然有改进的空间。比如执行单元测试的键盘快捷键(Alt+Shift+X,T)就相当笨拙。Eclipse 插件 MoreUnit 解决了这个问题和其他问题。它可以在 Eclipse Marketplace 中免费安装,并提供以下特性:
-
MoreUnit 提供了执行(Ctrl+R)和在类的实现和单元测试之间切换(Ctrl+J)的键盘快捷键。如果没有可用的测试,Ctrl+J 会打开一个对话框来创建相应的单元测试。
-
会显示一个图标装饰,以便您可以直接在包资源管理器中看到某个类是否存在测试(由绿点指示)。
-
在重构过程中,类和相应的测试类彼此同步移动或重命名。
1.3 用 assertThrows()处理预期异常
有时,测试用例应该检查处理过程中异常的出现,缺少测试用例就意味着错误。一个例子是故意访问数组中不存在的元素。一个ArrayIndexOutOfBoundsException应该是结果。为了处理测试用例中的预期异常,使它们代表测试成功而不是失败,有几种替代方法。
从 JUnit 5 开始,通过使用方法assertThrows(),处理测试用例中的异常变得更加容易。如果执行的方法没有引发预期的异常,它将失败(产生测试失败)。此外,该方法返回触发的异常,以便可以执行进一步的检查,例如,异常的文本中是否包含期望的和预期的信息。以下代码可作为AssertThrowsTest执行:
public class AssertThrowsTest
{
@Test
public void arrayIndexOutOfBoundsExceptionExpected()
{
var numbers = new int[] { 1, 2, 3, 4, 5, 6, 7 };
final Executable action = () ->
{
numbers[1_000] = 13;
};
assertThrows(ArrayIndexOutOfBoundsException.class, action);
}
@Test
public void illegalStateExceptionWithMessageTextExpected()
{
final String errorMsg = "XYZ is not initialized";
final Executable action = () ->
{
throw new IllegalStateException(errorMsg,
new IOException("IO"));
};
final IllegalStateException exception =
assertThrows(IllegalStateException.class,
action);
assertEquals(errorMsg, exception.getMessage());
assertEquals(IOException.class, exception.getCause().getClass());
}
}
在第二个测试案例中,很明显访问异常的内容是多么容易(例如,检查文本或其他细节)。
使用 JUnit 5 的 2 个参数化测试
在某些情况下,您必须测试大量的值。如果您必须为它们中的每一个创建单独的测试方法,这将会使测试类变得相当臃肿和混乱。为了更优雅地解决这个问题,有几种变体。所有这些都有其特定的优势和劣势。
在下文中,假设计算使用固定范围的值或一组选定的输入。 1
2.1 JUnit 5 参数化测试简介
使用 JUnit 5,定义参数化测试相当简单。让我们从这样一个场景开始,您只想为您的测试方法指定参数,而不想传递结果。当只需要测试一个条件时,例如一个字符串是否为非空或者一个数字是否为质数,这是很方便的。您可以使用注释@ParameterizedTest和@ValueSource对一小组给定的输入进行这两种操作,如下所示:
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
// a few errors are produced for demonstration purposest
public class FirstParameterizedTest
{
@ParameterizedTest(name = "run {index}: ''{0}'' is not empty")
@ValueSource(strings = { "Tim", "Tom", "", "Mike" })
void isNotEmpty(String value)
{
assertFalse(value.isEmpty());
}
@ParameterizedTest(name = "run {index}: {0} is a prime")
@ValueSource(ints = { 1, 2, 3, 4, 5, 6, 7 })
void ensureIsPrime(int value)
{
assertTrue(MathUtils.isPrime(value));
}
}
代码显示每个参数化测试必须用@ParameterizedTest进行注释。作为参数,您可以在name属性中使用带有占位符的字符串。占位符的含义如下:{index}对应测试数据中的指标,{0}、{1}、{2}等。所有都分别引用参数和相应的数据元素。更常见的是,会有几个输入,正如您将在下面看到的。此外,测试生成器需要知道要测试哪些输入。该信息可以由@ValueSource指定。在这个例子中,您使用了字符串和int值的专门化。此外,long和double还有预定义的变体。
让我们快速看一下这一切是如何进行的。为每个指定的参数创建并执行一个单独的测试用例。从图 B-2 中,您可以看到这在 Eclipse 中的样子。请记住,出于演示目的,我们已经包含了一些测试错误。
图 B-2
程序的参数化测试用例FirstParameterizedTest
2.2 更实用的参数化测试
然而,实际上,几乎所有的测试都需要一组输入和结果。注释@CsvSource可以对此有所帮助。可以为测试方法的各个输入或参数的所需组合创建独立的逗号分隔数据。合理地,第一个或者最好是最后一个参数代表预期的结果。如果使用最后一个参数,这更符合(欧洲)从左到右的思维方式。
在下面,我展示了一个可能的参数化来反转一个字符串:
@ParameterizedTest(name = "reverse({0}) => {1}")
@CsvSource({ "ABCD, DCBA", "OTTO, OTTO", "PETER, RETEP" })
void testReverse(final String input, final String expectedOutput)
{
final String result = Ex03_ReverseStringV1.reverse(input);
assertEquals(expectedOutput, result);
}
另一个例子是两个值相加,包括预期结果。在这里,您会发现文本值会自动转换为参数所使用的类型:
@ParameterizedTest(name = "{index}: {0} + {1} = {2}")
@CsvSource({ "1, 1, 2", "2, -2, 0", "3, 4, 7" })
void testAdd(int first, int second, int expected)
{
int sum = first + second;
assertEquals(expected, sum);
}
例如,对于日期和时间 API 的类型,有一些巧妙的预定义转换,您可以用它们编写可理解的测试。在下面的代码中,这是为确定季度中的第一天而显示的:
@ParameterizedTest
@CsvSource({ "2014-03-15, 2014-01-01", "2014-06-16, 2014-04-01",
"2014-09-15, 2014-07-01", "2014-11-15, 2014-10-01"})
void adjustToFirstDayOfQuarter(LocalDate startDate, LocalDate expected)
{
final Temporal result = new Ex10_FirstDayOfQuarter().adjustInto(startDate);
assertEquals(expected, result);
}
2.3 使用@MethodSource 的 JUnit 参数化测试
提供这些值还有一个潜在的困难。有时,值的文本规范会变得混乱,或者是不可能的(例如,对于列表)。这是为输入值列表和预期结果显示的。因此,有另一种方法来提供数据作为一个静态方法返回的Stream<Arguments>。方法名用@MethodSource指定。不幸的是,这只能在文本上实现,但链接仍然非常直观。
@ParameterizedTest(name = "removeDuplicates({0}) = {1}")
@MethodSource("listInputsAndExpected")
void removeDuplicates(List<Integer> inputs, List<Integer> expected)
{
List<Integer> result = Ex02_ListRemove.removeDuplicates(inputs);
assertEquals(expected, result);
}
static Stream<Arguments> listInputsAndExpected()
{
return Stream.of(Arguments.of(List.of(1, 1, 2, 3, 4, 1, 2, 3),
List.of(1, 2, 3, 4)),
Arguments.of(List.of(1, 3, 5, 7),
List.of(1, 3, 5, 7)),
Arguments.of(List.of(1, 1, 1, 1),
List.of(1)));
}
Footnotes 1
对于(非常)大量的值,检查所有的值并不是一个好主意。这通常会显著增加单元测试的执行时间,而不会提供任何(更大的)附加值。特别推荐使用等价类的代表,这将大大减少所需的测试次数。详情参考我的书Der Weg zum Java-Profi【Ind20a】。
十四、O 符号快速入门
在本书中,所谓的 O-notation 是用来对算法的运行时间进行分类的。这允许对算法的复杂性进行更正式的分类。
1 使用 O 符号的估计值
要估计和描述算法的复杂性并对它们的运行时行为进行分类,总是进行度量是不切实际的。此外,测量仅反映在硬件(处理器时钟、存储器等)的某些限制下的运行时间行为。).为了能够独立于这些细节并在更抽象的层面上对设计决策的后果进行分类,计算机科学使用了所谓的 O-notation ,这表明了算法复杂性的上限。为了做到这一点,人们希望能够回答下面的问题:*当不是处理 1000 个输入值,例如,处理 10000 或 100000 个输入值时,程序如何执行?*要回答这个问题,必须考虑算法的各个步骤并进行分类。目的是形式化复杂性的计算,以估计输入数据数量的变化对程序运行时间的影响。
考虑以下while循环作为介绍性示例:
int i = 0; // O(1)
while (i < n) // O(n)
{
createPersonInDb(i); // O(1)
i++; // O(1)
}
任何单个指令都被赋予了 O (1)的复杂度。由于循环体的 n 执行,循环本身被赋予复杂度 O ( n )。 1 将这些值加在一起,那么运行程序的成本就是O(1)+O(n)∫(O(1)+O(1))=O(1)+O(n)∫对于复杂性的估计,常数和因子并不重要。只对 n 的最高功率感兴趣。因此,对于程序的图示部分,您得到的复杂度为 O ( n )。这种简化是允许的,因为对于较大的 n 值,因子和较小复杂度等级的影响是不明显的。为了理解以下各节中的注意事项,这个非正式的定义应该足够了。
下面,我想引用罗伯特·塞奇威克的两句话来描述 O-符号,这两句话摘自他的标准著作算法[sed 92]:“...O 符号是指定运行时间上限的有用工具,它独立于输入数据的细节和实现。”它进一步指出,“在帮助分析师根据算法的性能对算法进行分类方面,以及在帮助算法搜索最佳算法方面,O 符号被证明是非常有用的。”
1.1 复杂性类别
为了能够相互比较不同算法的运行时行为,七个不同的复杂性类别通常就足够了。下表列出了各自的复杂性类别和一些示例:
-
O (1):恒定复杂度导致复杂度与输入数据的数量 n 无关。这种复杂性通常代表一条指令或者由几个计算步骤组成的简单计算。
-
O ( log ( n ):对数复杂度下,输入数据集 n 的平方时运行时间翻倍。这种复杂性的一个众所周知的例子是二分搜索法。
-
O ( n ):在线性复杂度的情况下,运行时间与元素数量 n 成正比增长。简单的循环和迭代就是这种情况,比如在数组或列表中进行搜索。
-
O ( n )。 log ( n ):这种复杂性是线性和对数增长的结合。一些最快的排序算法(例如 Mergesort)显示了这种复杂性。
-
O(n2):当输入数据量翻倍 n 时,二次复杂度导致运行时间翻两番。输入数据的十倍增长已经导致运行时间的百倍增长。实际上,这种复杂性是用两个嵌套的
for或while循环发现的。简单的排序算法通常具有这种复杂性。 -
O ( n 3 ):以立方复杂度来说, n 翻倍已经导致运行时间增加八倍。矩阵的简单乘法就是这种复杂性的一个例子。
-
O (2 n ):指数复杂度导致在运行时间的平方中 n 翻倍。起初,这听起来并不多。但是,如果增加 10 倍,运行时间将增加 200 亿倍!指数复杂性经常出现在优化问题中,例如所谓的旅行推销员问题,其目标是在访问所有城市的同时找到不同城市之间的最短路径。为了解决运行时间过长的问题,程序使用试探法,这种方法可能找不到最优解,只是一个近似解,但是复杂度低得多,运行时间也短得多。
表 [C-1 令人印象深刻地显示了对于不同的输入数据组 n 所提到的复杂性类别的影响。22
表 C-1
不同时间复杂性的影响
|N
|
O ( 日志 ( n ))
|
O ( n
|
O ( n )。日志 ( n ))
|
O(n2
|
O(n3
| | --- | --- | --- | --- | --- | --- | | Ten | one | Ten | Ten | One hundred | One | | One hundred | Two | One hundred | Two hundred | Ten | 1.000.000 | | One | three | One | Three | 1.000.000 | 1.000.000.000 | | Ten | four | Ten | Forty | 100.000.000 | 1.000.000.000.000 | | One hundred | five | One hundred | Five hundred | 10.000.000.000 | 1.000.000.000.000.000 | | 1.000.000 | six | 1.000.000 | 6.000.000 | 1.000.000.000.000 | 1.000.000.000.000.000.000 |
根据显示的值,您可以感受不同复杂性的影响。大约到了 O ( n )。 log ( n ))复杂度等级有利。虽然对于许多算法来说是无法实现的,但最佳和理想的是复杂度 O (1)和O(log(n))。已经O(n2)通常不适合较大的输入集,但它可以用于简单的计算和较小的 n 值,没有任何问题。
NOTE: INFLUENCE OF INPUT DATA
根据输入数据的不同,某些算法的行为会有所不同。对于快速排序,一般情况下的复杂度为 n 。 log ( n ,但这在极端情况下可以增加到n2。由于 O 符号描述了最差情况*,快速排序被赋予了复杂度O(n2)。*
*### 1.2 复杂性和程序运行时间
对于一组输入值 n ,通过特殊的 O 复杂度计算出的数字有时可能令人望而生畏。尽管如此,他们并没有提到实际的执行时间,而只是提到了当输入集增加时它的增长。正如已经基于介绍性示例的那样,O-符号没有声明单个计算步骤的持续时间:增量i++和数据库访问createPersonInDb(i)都被评定为 O (1),即使数据库访问比关于执行时间的增量贵几个数量级。
对于普通指令,无需访问外部系统,如文件系统、网络或数据库(即添加、分配等)。),在许多情况下, n 的影响对于今天具有用户交互的典型商业应用的计算机来说不是决定性的。对于小的 n ( < 1000)在复杂度 O ( n )或O(n2)甚至有时在O(n3)时,对实际运行时间的影响几乎无关紧要——但这并不意味着你不应该使用尽可能优化的算法相反,反之亦然:您也可以从第一个功能正确的实现开始,并将其投入生产。优化版本可能会在稍后推出。
总而言之,我想再次强调,即使是复杂度为 O ( n 2 )或 O ( n 3 )的多重嵌套循环,从绝对意义上来说,其执行速度通常也比网络上一些复杂度为 O ( n )的数据库查询快得多。对于数组中的搜索( O ( n ))和对基于散列的数据结构的元素的访问( O (1))也是如此。对于小的 n ,哈希值的计算可能比线性搜索花费更长的时间。然而, n 越大,越差的复杂度类对实际运行时间的影响就越大。
Footnotes 1在下一页,通过展示其他复杂性类别的示例,符号的含义将变得更容易理解。有关更高级的插图,请参见 www.linux-related.de/index.html?/coding/o-notation.htm 。
2
时间复杂度 O (2 n )没有显示,因为它的增长太强了,如果不使用 10 的幂,就无法有意义地表达。
*