java学习笔记之基础:异常处理、单元测试
异常处理
异常
Java 内置了一套异常处理机制,总是使用异常来表示错误。异常是一种 class,因此它本身带有类型信息。异常可以在任何地方抛出,但只需要在上层捕获。
Throwable 是异常体系的根,它继承自 Object。Throwable 有两个体系:Error 和 Exception。Error 表示严重的错误,程序对此一般无能为力,例如:OutOfMemoryError、StackOverflowError。某些异常是应用程序逻辑处理的一部分应该捕获并处理。例如:NumberFormatException、FileNotFoundException。还有一些异常是程序逻辑编写不对造成的,应该修复程序本身。例如:NullPointerException、IndexOutOfBoundsException。Exception 又分为两大类:RuntimeException 以及它的子类、非 RuntimeException。
Java 规定:
- 必须捕获的异常,包括 Exception 及其子类,但不包括 RuntimeException 及其子类。
- 不需要捕获的异常,包括 Error 及其子类,RuntimeException 及其子类。
捕获异常
在 Java 中,凡是可能抛出异常的语句,都可以用 try ... catch 捕获。把可能发生异常的代码放到 try {...} 中,然后使用 catch 捕获对应的 Exception 及其子类 。
String s = "中文";
try {
// 用指定编码转换String为byte[]:
s.getBytes("GBK");
} catch (UnsupportedEncodingException e) {
// 如果系统不支持GBK编码,会捕获到UnsupportedEncodingException:
System.out.println(e); // 打印异常信息
//打印异常栈
e.printStackTrace();
return s.getBytes(); // 尝试使用默认编码
}
可以使用多个 catch 语句,每个 catch 分别捕获对应的 Exception 及其子类。JVM 在捕获到异常后,会从上到下匹配 catch 语句,匹配到某个 catch 后,执行 catch 代码块,然后不再继续匹配。存在多个 catch 的时候,catch 的顺序非常重要:子类必须写在前面。多个 catch 语句只有一个能被执行。
try {
process1();
process2();
process3();
} catch (UnsupportedEncodingException e) {
System.out.println("Bad encoding");
} catch (IOException e) {
System.out.println("IO error");
}
处理逻辑相同的异常可以合并:
try {
process1();
process2();
process3();
} catch (IOException | NumberFormatException e) {
// IOException或NumberFormatException
System.out.println("Bad input");
} catch (Exception e) {
System.out.println("Unknown error");
}
finally 语句
Java 的 try ... catch 机制还提供了 finally 语句,finally 语句块保证有无错误都会执行。
try {
process1();
process2();
process3();
} catch (UnsupportedEncodingException e) {
System.out.println("Bad encoding");
} catch (IOException e) {
System.out.println("IO error");
} finally {
System.out.println("END");
}
finally 的特点:finally 语句不是必须的,可写可不写;finally 总是最后执行。如果没有发生异常,就正常执行 try { ... } 语句块,然后执行 finally。如果发生了异常,就中断执行 try { ... } 语句块,然后跳转执行匹配的 catch 语句块,最后执行 finally。可见 finally 是用来保证一些代码必须执行的。
某些情况下,可以没有 catch,只使用 try ... finally 结构。因为方法签名声明了可能抛出的异常,所以可以不写 catch。
void process(String file) throws IOException {
try {
...
} finally {
System.out.println("END");
}
}
抛出异常
当某个方法抛出了异常时,如果当前方法没有捕获异常,异常就会被抛到上层调用方法,直到遇到某个 try ... catch 被捕获为止。
if (s==null) {
throw new NullPointerException();
}
如果一个方法捕获了某个异常后,又在 catch 子句中抛出新的异常,就相当于把抛出的异常类型“转换”了:
try {
process2();
} catch (NullPointerException e) {
//抛出异常时传入参数e,可以追踪到完整的异常栈
throw new IllegalArgumentException(e);
}
有了完整的异常栈的信息,我们才能快速定位并修复代码的问题。捕获到异常并再次抛出时,一定要保留原始异常。
异常屏蔽
finally 抛出异常后,原来在 catch 中准备抛出的异常就“消失”了,因为只能抛出一个异常。没有被抛出的异常称为“被屏蔽”的异常。
// exception
public class Main {
public static void main(String[] args) {
try {
Integer.parseInt("abc");
} catch (Exception e) {
System.out.println("catched");
throw new RuntimeException(e);
} finally {
System.out.println("finally");
throw new IllegalArgumentException();
}
}
}
在极少数的情况下,我们需要获知所有的异常。可以用变量保存原始异常,然后调用 Throwable.addSuppressed() ,把原始异常添加进来,最后在 finally 抛出:
Exception origin = null;
try {
System.out.println(Integer.parseInt("abc"));
} catch (Exception e) {
origin = e;
throw e;
} finally {
Exception e = new IllegalArgumentException();
if (origin != null) {
e.addSuppressed(origin);
}
throw e;
}
绝大多数情况下,在 finally 中不要抛出异常。我们通常不需要关心 Suppressed Exception。
自定义异常
Java 标准库定义的常用异常包括:Exception 和继承自 Exception 的 RuntimeException(子类: NullPointerException、IndexOutOfBoundsException、SecurityException、IllegalArgumentException、NumberFormatException)、IOException(子类: UnsupportedCharsetException、FileNotFoundException、SocketException)、ParseException、GeneralSecurityException、SQLException、TimeoutException。
当我们在代码中需要抛出异常时,尽量使用 JDK 已定义的异常类型。例如参数检查不合法应该抛出 IllegalArgumentException:
if (age <= 0) {
throw new IllegalArgumentException();
}
在一个大型项目中,可以自定义新的异常类型,但是保持一个合理的异常继承体系是非常重要的。一个常见的做法是自定义一个 BaseException 作为“根异常”,然后派生出各种业务类型的异常。BaseException 需要从一个适合的 Exception 派生,通常建议从 RuntimeException 派生。其他业务类型的异常就可以从 BaseException 派生。
自定义的 BaseException 应该提供多个构造方法。
public class BaseException extends RuntimeException {
public BaseException() {
super();
}
public BaseException(String message, Throwable cause) {
super(message, cause);
}
public BaseException(String message) {
super(message);
}
public BaseException(Throwable cause) {
super(cause);
}
}
NullPointerException
NullPointerException 即空指针异常,俗称 NPE。如果一个对象为 null,调用其方法或访问其字段就会产生 NullPointerException,这个异常通常是由 JVM 抛出的。NullPointerException 是一种代码逻辑错误,遇到 NullPointerException,遵循原则是早暴露早修复,严禁使用 catch 来隐藏这种编码错误。
String s = null;
System.out.println(s.toLowerCase());
好的编码习惯可以极大地降低 NullPointerException 的产生,例如:成员变量在定义时初始化;使用空字符串 "" 而不是默认的 null 可避免很多 ullPointerException;
返回空字符串 "" 、空数组而不是 null。
使用断言
断言是一种调试程序的方式。在 Java 中,使用 assert 关键字来实现断言。
double x = Math.abs(-123.45);
assert x >= 0;
System.out.println(x);
语句 assert x >= 0; 即为断言,断言条件 x >= 0 预期为 true。如果计算结果为 false,则断言失败,抛出 AssertionError。使用 assert 语句时,还可以添加一个可选的断言消息:assert x >= 0 : "x must >= 0";。这样断言失败的时候,AssertionError 会带上消息 x must >= 0 ,更加便于调试。
Java 断言的特点是:断言失败时会抛出 AssertionError,导致程序结束退出。因此断言不能用于可恢复的程序错误,只应该用于开发和测试阶段。JVM 默认关闭断言指令,即遇到 assert 语句就自动忽略了,不执行。要执行 assert 语句,必须给 Java 虚拟机传递 -enableassertions(可简写为 -ea )参数启用断言。所以上述程序必须在命令行下运行才有效果:java -ea Main.java 。实际开发中,很少使用断言。更好的方法是编写单元测试。
使用 JDK Logging
Java 标准库内置了日志包 java.util.logging ,我们可以直接用。JDK 的 Logging 定义了 7 个日志级别,从严重到普通:SEVERE、WARNING、INFO、CONFIG、FINE、FINER、FINEST。默认级别是 INFO,INFO 级别以下的日志不会被打印出来。使用日志级别的好处在于,调整级别就可以屏蔽掉很多调试相关的日志输出。
// logging
import java.util.logging.Level;
import java.util.logging.Logger;
public class Hello {
public static void main(String[] args) {
Logger logger = Logger.getGlobal();
logger.info("start process...");
logger.warning("memory is running out...");
logger.fine("ignored.");
logger.severe("process will be terminated...");
}
}
运行上述代码,得到类似如下的输出:
Mar 02, 2019 6:32:13 PM Hello main
INFO: start process...
Mar 02, 2019 6:32:13 PM Hello main
WARNING: memory is running out...
Mar 02, 2019 6:32:13 PM Hello main
SEVERE: process will be terminated...
Logging 系统在 JVM 启动时读取配置文件并完成初始化,一旦开始运行 main() 方法,就无法修改配置;配置不太方便,需要在 JVM 启动时传递参数 -Djava.util.logging.config.file=<config-file-name> 。因此,Java 标准库内置的 Logging 使用并不是非常广泛。
使用 Commons Logging
Commons Logging 是使用最广泛的日志模块。和 Java 标准库提供的日志不同,Commons Logging 是一个第三方日志库,它是由 Apache 创建的日志模块。Commons Logging 的特色是,它可以挂接不同的日志系统,并通过配置文件指定挂接的日志系统。默认情况下,Commons Loggin 自动搜索并使用 Log4j(Log4j 是另一个流行的日志系统),如果没有找到 Log4j,再使用 JDK Logging。Commons Logging 定义了 6 个日志级别:FATAL、ERROR、WARNING、INFO、DEBUG、TRACE。默认级别是 INFO。
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
public class Main {
public static void main(String[] args) {
Log log = LogFactory.getLog(Main.class);
log.info("start...");
log.warn("end.");
}
}
使用 Commons Logging 时,如果在静态方法中引用 Log,通常直接定义一个静态类型变量:
// 在静态方法中引用Log:
public class Main {
static final Log log = LogFactory.getLog(Main.class);
static void foo() {
log.info("foo");
}
}
在实例方法中引用 Log,通常定义一个实例变量:
// 在实例方法中引用Log:
public class Person {
protected final Log log = LogFactory.getLog(getClass());
void foo() {
log.info("foo");
}
}
注意到实例变量 log 的获取方式是 LogFactory.getLog(getClass()) ,也可以用 LogFactory.getLog(Person.class) ,但是前一种方式有个非常大的好处,就是子类可以直接使用该 log 实例。
此外,Commons Logging 的日志方法,例如 info(),除了标准的 info(String) 外,还提供了一个非常有用的重载方法:info(String, Throwable),这使得记录异常更加简单:
try {
...
} catch (Exception e) {
log.error("got exception!", e);
}
使用 Log4j
Log4j 是一种非常流行的日志框架,是一个组件化设计的日志系统,它的架构大致如下:当我们使用 Log4j 输出一条日志时,Log4j 自动通过不同的 Appender 把同一条日志输出到不同的目的地。例如:console输出到屏幕;file输出到文件;socket通过网络输出到远程计算机;jdbc输出到数据库。在输出日志的过程中,通过 Filter 来过滤哪些 log 需要被输出,哪些 log 不需要被输出。例如仅输出 ERROR 级别的日志。最后通过 Layout 来格式化日志信息,例如自动添加日期、时间、方法名称等信息。上述结构虽然复杂,但我们在实际使用的时候,并不需要关心 Log4j 的 API,而是通过配置文件来配置它。
以 XML 配置为例,使用 Log4j 的时候,我们把一个 log4j2.xml 的文件放到 classpath 下就可以让 Log4j 读取配置文件并按照我们的配置来输出日志。下面是一个配置文件的例子:
<?xml version="1.0" encoding="UTF-8"?>
<Configuration>
<Properties>
<!-- 定义日志格式 -->
<Property name="log.pattern">%d{MM-dd HH:mm:ss.SSS} [%t] %-5level %logger{36}%n%msg%n%n</Property>
<!-- 定义文件名变量 -->
<Property name="file.err.filename">log/err.log</Property>
<Property name="file.err.pattern">log/err.%i.log.gz</Property>
</Properties>
<!-- 定义Appender,即目的地 -->
<Appenders>
<!-- 定义输出到屏幕 -->
<Console name="console" target="SYSTEM_OUT">
<!-- 日志格式引用上面定义的log.pattern -->
<PatternLayout pattern="${log.pattern}" />
</Console>
<!-- 定义输出到文件,文件名引用上面定义的file.err.filename -->
<RollingFile name="err" bufferedIO="true" fileName="${file.err.filename}" filePattern="${file.err.pattern}">
<PatternLayout pattern="${log.pattern}" />
<Policies>
<!-- 根据文件大小自动切割日志 -->
<SizeBasedTriggeringPolicy size="1 MB" />
</Policies>
<!-- 保留最近10份 -->
<DefaultRolloverStrategy max="10" />
</RollingFile>
</Appenders>
<Loggers>
<Root level="info">
<!-- 对info级别的日志,输出到console -->
<AppenderRef ref="console" level="info" />
<!-- 对error级别的日志,输出到err,即上面定义的RollingFile -->
<AppenderRef ref="err" level="error" />
</Root>
</Loggers>
</Configuration>
虽然配置 Log4j 比较繁琐,但一旦配置完成,使用起来就非常方便。对上面的配置文件,凡是 INFO 级别的日志,会自动输出到屏幕,而 ERROR 级别的日志,不但会输出到屏幕,还会同时输出到文件。并且一旦日志文件达到指定大小(1MB),Log4j 就会自动切割新的日志文件,并最多保留 10 份。
有了配置文件还不够,因为 Log4j 是一个第三方库,我们需要下载 Log4j 解压后,把以下 3 个 jar 包放到 classpath 中:log4j-api-2.x.jar、log4j-core-2.x.jar、log4j-jcl-2.x.jar。Commons Logging 会自动发现并使用 Log4j。commons-logging-1.2.jar 也放到 classpath 中。
要打印日志,只需要按 Commons Logging 的写法,不需要改动任何代码,就可以得到 Log4j 的日志输出。
最佳实践
在开发阶段,始终使用 Commons Logging 接口来写入日志,并且开发阶段无需引入 Log4j。如果需要把日志写入文件,只需要把正确的配置文件和 Log4j 相关的 jar 包放入 classpath,就可以自动把日志切换成使用 Log4j 写入,无需修改任何代码。
使用 SLF4J 和 Logback
SLF4J 和 Logback 可以取代 Commons Logging 和 Log4j。使用 SLF4J 的接口写入日志,使用 Logback 只需要配置,不需要修改代码。
Commons Logging 打印日志
int score = 99;
p.setScore(score);
log.info("Set score " + score + " for Person " + p.getName() + " ok.");
SLF4J 打印日志
int score = 99;
p.setScore(score);
logger.info("Set score {} for Person {} ok.", score, p.getName());
单元测试
测试驱动开发,是指先编写接口紧接着编写测试。编写完测试后,我们才开始真正编写实现代码。在编写实现代码的过程中边写边测,测试全部通过表示编写的实现完成了。这是一种理想情况。大部分情况是我们已经编写了实现代码,需要对已有的代码进行测试。
单元测试是针对最小的功能单元编写测试代码。Java 程序最小的功能单元是方法,因此对 Java 程序进行单元测试就是针对单个 Java 方法的测试。单元测试可以确保单个方法按照正确预期运行,如果修改了某个方法的代码,只需确保其对应的单元测试通过,即可认为改动正确。此外测试代码本身就可以作为示例代码,用来演示如何调用该方法。
JUnit 是一个开源的 Java 语言的单元测试框架,专门针对 Java 设计,使用广泛。JUnit 是事实上的单元测试的标准框架,任何 Java 开发者都应当学习并使用 JUnit 编写单元测试。使用 JUnit 编写单元测试的好处在于,我们可以非常简单地组织测试代码,并随时运行它们,JUnit 就会给出成功的测试和失败的测试,还可以生成测试报告,不仅包含测试的成功率,还可以统计测试的代码覆盖率,即被测试的代码本身有多少经过了测试。对于高质量的代码来说,测试覆盖率应该在 80% 以上。使用 JUnit 进行单元测试,我们可以使用断言来测试期望结果,可以方便地组织和运行测试,并方便地查看测试结果。此外 JUnit 既可以直接在 IDE 中运行,也可以方便地集成到 Maven 这些自动化工具中运行。
在编写单元测试的时候,我们要遵循一定的规范:
- 单元测试代码本身必须非常简单,能一下看明白,决不能再为测试代码编写测试;
- 每个单元测试应当互相独立,不依赖运行的顺序;
- 测试时不但要覆盖常用测试用例,还要特别注意测试边界条件,例如输入为 0,null,空字符串""等情况。
编写 JUnit 测试
当我们已经编写了一个 Factorial.java 文件后,我们想对其进行测试,需要编写一个对应的 FactorialTest.java 文件,以 Test 为后缀是一个惯例,并分别将其放入 src 和 test 目录中。
package com.example.learnjava;
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;
public class FactorialTest {
@Test
void testFact() {
assertEquals(1, Factorial.fact(1));
assertEquals(2, Factorial.fact(2));
assertEquals(6, Factorial.fact(3));
assertEquals(3628800, Factorial.fact(10));
assertEquals(2432902008176640000L, Factorial.fact(20));
}
}
核心测试方法 testFact() 加上了 @Test 注解,这是 JUnit 要求的,它会把带有 @Test 的方法识别为测试方法。在测试方法内部,我们用 assertEquals(1, Factorial.fact(1)) 表示期望 Factorial.fact(1) 返回 1。
assertEquals(expected, actual) 是最常用的测试方法,它在 Assertion 类中定义。Assertion 还定义了其他断言方法,例如:
- assertTrue(): 期待结果为 true
- assertFalse(): 期待结果为 false
- assertNotNull(): 期待结果为非 null
- assertArrayEquals(): 期待结果为数组并与期望数组每个元素的值均相等
使用浮点数时,由于浮点数无法精确地进行比较,因此我们需要调用 assertEquals(double expected, double actual, double delta) 这个重载方法,指定一个误差值:assertEquals(0.1, Math.abs(1 - 9 / 10.0), 0.0000001);
使用 Fixture
在一个单元测试中,我们经常编写多个 @Test 方法,来分组、分类对目标代码进行测试。在测试的时候我们经常遇到一个对象需要初始化,测试完可能还需要清理的情况。如果每个 @Test 方法都写一遍这样的重复代码,显然比较麻烦。为此 JUnit 提供了编写测试前准备、测试后清理的固定代码,我们称之为 Fixture。
我们来看一个具体的 Calculator 的例子:
public class Calculator {
private long n = 0;
public long add(long x) {
n = n + x;
return n;
}
public long sub(long x) {
n = n - x;
return n;
}
}
这个类的功能很简单,但是测试的时候我们要先初始化对象,但不必在每个测试方法中都写上初始化代码,而是通过 @BeforeEach 来初始化,通过 @AfterEach 来清理资源:
public class CalculatorTest {
Calculator calculator;
@BeforeEach
public void setUp() {
this.calculator = new Calculator();
}
@AfterEach
public void tearDown() {
this.calculator = null;
}
@Test
void testAdd() {
assertEquals(100, this.calculator.add(100));
assertEquals(150, this.calculator.add(50));
assertEquals(130, this.calculator.add(-20));
}
@Test
void testSub() {
assertEquals(-100, this.calculator.sub(100));
assertEquals(-150, this.calculator.sub(50));
assertEquals(-130, this.calculator.sub(-20));
}
}
在 CalculatorTest 测试中,有两个标记为 @BeforeEach 和 @AfterEach 的方法,它们会在运行每个 @Test 方法前后自动运行。还有一些资源初始化和清理可能更加繁琐,而且会耗费较长的时间,例如初始化数据库。JUnit 还提供了 @BeforeAll 和@AfterAll,它们在运行所有 @Test 前后运行。
因为 @BeforeAll 和 @AfterAll 在所有 @Test 方法运行前后仅运行一次,因此它们只能初始化静态变量。@BeforeAll 和 @AfterAll 只能标注在静态方法上。
public class DatabaseTest {
static Database db;
@BeforeAll
public static void initDatabase() {
db = createDb(...);
}
@AfterAll
public static void dropDatabase() {
...
}
}
因此对于实例变量,在 @BeforeEach 中初始化,在 @AfterEach 中清理,它们在各个 @Test 方法中互不影响,因为是不同的实例;对于静态变量,在 @BeforeAll 中初始化,在 @AfterAll 中清理,它们在各个 @Test 方法中均是唯一实例,会影响各个 @Test 方法。大多数情况下,使用 @BeforeEach 和 @AfterEach 就足够了。只有某些测试资源初始化耗费时间太长,以至于我们不得不尽量“复用”时才会用到 @BeforeAll 和 @AfterAll。
每次运行一个 @Test 方法前,JUnit 首先创建一个 XxxTest 实例,因此每个 @Test 方法内部的成员变量都是独立的,不能也无法把成员变量的状态从一个 @Test 方法带到另一个 @Test 方法。
异常测试
在 Java 程序中,异常处理是非常重要的。我们自己编写的方法,也经常抛出各种异常。对于可能抛出的异常进行测试,本身就是测试的重要环节。因此在编写 JUnit 测试的时候,除了正常的输入输出,我们还要特别针对可能导致异常的情况进行测试。
public class Factorial {
public static long fact(long n) {
if (n < 0) {
throw new IllegalArgumentException();
}
long r = 1;
for (long i = 1; i <= n; i++) {
r = r * i;
}
return r;
}
}
在方法入口,我们增加了对参数 n 的检查,如果为负数,则直接抛出 IllegalArgumentException。现在我们希望对异常进行测试。在 JUnit 测试中,我们可以编写一个 @Test 方法专门测试异常:
@Test
void testNegative() {
assertThrows(IllegalArgumentException.class, new Executable() {
@Override
public void execute() throws Throwable {
Factorial.fact(-1);
}
});
}
JUnit 提供 assertThrows() 来期望捕获一个指定的异常。第二个参数 Executable 封装了我们要执行的会产生异常的代码。当我们执行 Factorial.fact(-1) 时,必定抛出 IllegalArgumentException。assertThrows() 在捕获到指定异常时表示通过测试,未捕获到异常或者捕获到的异常类型不对,均表示测试失败。
从 Java 8 开始引入了函数式编程,所有单方法接口都可以简写如下:
@Test
void testNegative() {
assertThrows(IllegalArgumentException.class, () -> {
Factorial.fact(-1);
});
}
条件测试
在运行测试的时候,有时我们需要排除某些 @Test 方法不让它运行,这时我们就可以给它标记一个 @Disabled 。类似 @Disabled 这种注解就称为条件测试,JUnit 根据不同的条件注解,决定是否运行当前的 @Test 方法。
public class Config {
public String getConfigFile(String filename) {
String os = System.getProperty("os.name").toLowerCase();
if (os.contains("win")) {
return "C:\\" + filename;
}
if (os.contains("mac") || os.contains("linux") || os.contains("unix")) {
return "/usr/local/" + filename;
}
throw new UnsupportedOperationException();
}
}
我们想要测试 getConfigFile()这个方法,就要编写针对两个系统的测试方法:
@Test
@EnabledOnOs(OS.WINDOWS)
void testWindows() {
assertEquals("C:\\test.ini", config.getConfigFile("test.ini"));
}
@Test
@EnabledOnOs({ OS.LINUX, OS.MAC })
void testLinuxAndMac() {
assertEquals("/usr/local/test.cfg", config.getConfigFile("test.cfg"));
}
还有一些条件测试的注解:
@DisabledOnOs(OS.WINDOWS),不在 Windows 平台执行的测试@DisabledOnJre(JRE.JAVA_8),只能在 Java 9 或更高版本执行的测试@EnabledIfSystemProperty(named = "os.arch", matches = ".*64.*"),只能在 64 位操作系统上执行的测试@EnabledIfEnvironmentVariable(named = "DEBUG", matches = "true"),需要传入环境变量 DEBUG=true 才能执行的测试
参数化测试
如果待测试的输入和输出是一组数据,可以把测试数据组织起来,用不同的测试数据调用相同的测试方法,这就是参数化测试。参数化测试和普通测试稍微不同的地方在于,一个测试方法需要接收至少一个参数,然后传入一组参数反复运行。JUnit 提供了一个 @ParameterizedTest 注解,用来进行参数化测试。
假设我们想对 Math.abs() 进行测试,先用一组正数进行测试,再用一组负数进行测试:
@ParameterizedTest
@ValueSource(ints = { 0, 1, 5, 100 })
void testAbs(int x) {
assertEquals(x, Math.abs(x));
}
@ParameterizedTest
@ValueSource(ints = { -1, -5, -100 })
void testAbsNegative(int x) {
assertEquals(-x, Math.abs(x));
}
实际的测试场景往往没有这么简单。假设我们自己编写了一个 StringUtils.capitalize() 方法,它会把字符串的第一个字母变为大写,后续字母变为小写:
public class StringUtils {
public static String capitalize(String s) {
if (s.length() == 0) {
return s;
}
return Character.toUpperCase(s.charAt(0)) + s.substring(1).toLowerCase();
}
}
要用参数化测试的方法来测试,我们不但要给出输入,还要给出预期输出。因此测试方法至少需要接收两个参数:
@ParameterizedTest
void testCapitalize(String input, String result) {
assertEquals(result, StringUtils.capitalize(input));
}
最简单的方法是通过 @MethodSource 注解,它允许我们编写一个同名的静态方法来提供测试参数:
@ParameterizedTest
@MethodSource
void testCapitalize(String input, String result) {
assertEquals(result, StringUtils.capitalize(input));
}
static List<Arguments> testCapitalize() {
return List.of( // arguments:
Arguments.of("abc", "Abc"), //
Arguments.of("APPLE", "Apple"), //
Arguments.of("gooD", "Good"));
}
静态方法 testCapitalize() 返回了一组测试参数,每个参数都包含两个 String,正好作为测试方法的两个参数传入。如果静态方法和测试方法的名称不同,@MethodSource 也允许指定方法名。但使用默认同名方法最方便。
另一种传入测试参数的方法是使用 @CsvSource,它的每一个字符串表示一行,一行包含的若干参数用 , 分隔 :
@ParameterizedTest
@CsvSource({ "abc, Abc", "APPLE, Apple", "gooD, Good" })
void testCapitalize(String input, String result) {
assertEquals(result, StringUtils.capitalize(input));
}
如果测试用例过多,直接写 @CsvSource 就很不方便。这个时候我们可以把测试数据提到一个独立的 CSV 文件中,然后标注上 @CsvFileSource:
@ParameterizedTest
@CsvFileSource(resources = { "/test-capitalize.csv" })
void testCapitalizeUsingCsvFile(String input, String result) {
assertEquals(result, StringUtils.capitalize(input));
}
JUnit 只在 classpath 中查找指定的 CSV 文件,因此 test-capitalize.csv 这个文件要放到 test 目录下。
浙公网安备 33010602011771号