单元测试
单元测试:针对最小的功能单元编写测试代码
Java程序最小的功能单元是方法,对Java程序进行单元测试就是针对单个方法测试
测试驱动开发
- 先编写接口,紧接着编写测试。编写完测试后,我们才开始真正编写实现代码
举例子
public class Factorial { public static long fact(long n) { long r = 1; for (long i = 1; i <= n; i++) { r = r * i; } return r; } }测试这方法,一个很自然的想法是编写一个
main()方法,然后运行一些测试代码:public class Test { public static void main(String[] args) { if (fact(10) == 3628800) { System.out.println("pass"); } else { System.out.println("fail"); } } }- 只能有一个
main()方法,不能把测试代码分离 - 是没有打印出测试结果和期望结果,例如,
expected: 3628800, but actual: 123456 - 很难编写一组通用的测试代码
- 只能有一个
编写JUnit测试
JUnit是一个开源的Java语言的单元测试框架,专门针对Java设计
- 好处:
- 可以非常简单地组织测试代码,并随时运行它们
- 给出成功的测试和失败的测试,还可以生成测试报告
- 好处:
使用操作
package com.itranswarp.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)); } }还有其他断言操作,以下错误结果
org.opentest4j.AssertionFailedError: expected: <3628800> but was: <362880> at org.junit.jupiter.api.AssertionUtils.fail(AssertionUtils.java:55) at org.junit.jupiter.api.AssertEquals.failNotEqual(AssertEquals.java:195) at org.junit.jupiter.api.AssertEquals.assertEquals(AssertEquals.java:168) at org.junit.jupiter.api.AssertEquals.assertEquals(AssertEquals.java:163) at org.junit.jupiter.api.Assertions.assertEquals(Assertions.java:611) at com.itranswarp.learnjava.FactorialTest.testFact(FactorialTest.java:14) at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) at ...失败信息的意思是期待结果
3628800但是实际返回是362880,此时,我们要么修正实现代码,要么修正测试代码,直到测试通过为止单元测试规范:
- 一是单元测试代码本身必须非常简单,能一下看明白,决不能再为测试代码编写测试;
- 二是每个单元测试应当互相独立,不依赖运行的顺序;
- 三是测试时不但要覆盖常用测试用例,还要特别注意测试边界条件,例如输入为
0,null,空字符串""等情况
使用Fixture
一个单元测试中,我们经常编写多个
@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中运行顺序如下:
invokeBeforeAll(CalculatorTest.class); for (Method testMethod : findTestMethods(CalculatorTest.class)) { var test = new CalculatorTest(); // 创建Test实例 invokeBeforeEach(test); invokeTestMethod(test, testMethod); invokeAfterEach(test); } invokeAfterAll(CalculatorTest.class);一些资源初始化和清理可能更加繁琐,而且会耗费较长的时间,例如初始化数据库。JUnit还提供了
@BeforeAll和@AfterAll@BeforeAll和@AfterAll在所有@Test方法运行前后仅运行一次,因此,它们只能初始化静态变量,例如: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方法
- 对于实例变量,在
异常测试
编写JUnit测试,除了正常的输入输出,还要特别针对可能导致异常的情况进行测试
Factorial举例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; } }对异常进行测试。JUnit测试中,我们可以编写一个
@Test方法专门测试异常:@Test void testNegative() { assertThrows(IllegalArgumentException.class, new Executable() { @Override public void execute() throws Throwable { Factorial.fact(-1); } }); }assertThrows()来期望捕获一个指定的异常。第二个参数Executable封装要执行的会产生异常的代码
条件测试
JUnit根据不同条件注解,决定是否运行当前
@Test方法。类似@Disabled这种注解@Disabled使用需要排出某些
@Test方法,不要让它运行,就可标记一个@Disabled@Disabled @Test void testBug101() { // 这个测试不会运行 }加上
@Disabled,JUnit仍然识别是测试方法,只是暂时不运行。测试结果中显示:Tests run: 68, Failures: 2, Errors: 0, Skipped: 5
举例子
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()这方法,但是Windows上跑,和Linux上跑的代码路径不同@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")); }条件测试是根据某些注解在运行期让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(); } }参数化测试的方法来测试,不但要给出输入,还要给出预期输出:
- 参数传入方法通过
@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")); }- 参数传入方法是使用
@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)); }在classpath中查找指定的CSV文件,内容如下:
apple, Apple HELLO, Hello JUnit, Junit reSource, Resource- 参数传入方法通过
注解Annotation
使用注解
注解:放在Java源码的类、方法、字段、参数前的一种特殊“注释”:
// this is a component: @Resource("hello") public class Hello { @Inject int n; @PostConstruct public void hello(@Param String name) { System.out.println(name); } @Override public String toString() { return "Hello"; } }JVM的角度看,注解本身对代码逻辑没有任何影响,如何使用注解完全由工具决定
注解作用:
- 第一类是由编译器使用的注解,例如:
@Override:让编译器检查该方法是否正确地实现了覆写@SuppressWarnings:告诉编译器忽略此处代码产生的警告
- 第二类是由工具处理
.class文件使用的注解,比如有些工具会在加载class的时候,对class做动态修改,实现一些特殊的功能 - 第三类是在程序运行期能够读取的注解,它们在加载后一直存在于JVM中,这也是最常用的注解。例如,一个配置了
@PostConstruct的方法会在调用构造方法后自动被调用
- 第一类是由编译器使用的注解,例如:
举个栗子,对以下代码:
public class Hello { @Check(min=0, max=100, value=55) public int n; @Check(value=99) public int p; @Check(99) // @Check(value=99) public int x; @Check public int y; }定义一个注解时,还可以定义配置参数。配置参数可以包括:
- 所有基本类型;
- String;
- 枚举类型;
- 基本类型、String、Class以及枚举的数组
大部分注解会有一个名为
value的配置参数,对此参数赋值,可以只写常量,相当于省略了value参数;如只写注解,相当于全部使用默认值
定义注解
Java语言使用
@interface语法来定义注解(Annotation):public @interface Report { int type() default 0; String level() default "info"; String value() default ""; }元注解
有一些注解可以修饰其他注解,这些注解称为元注解
@Target使用
@Target可以定义Annotation能够被应用于源码的哪些位置:- 类或接口:
ElementType.TYPE; - 字段:
ElementType.FIELD; - 方法:
ElementType.METHOD; - 构造方法:
ElementType.CONSTRUCTOR; - 方法参数:
ElementType.PARAMETER
定义注解
@Report可用在方法或字段上,可以把@Target注解参数变为数组{ ElementType.METHOD, ElementType.FIELD }:@Target({ ElementType.METHOD, ElementType.FIELD }) public @interface Report { ... }- 类或接口:
@Repeatable使用
@Repeatable这个元注解可以定义Annotation是否可重复。这个注解应用不是特别广泛。@Repeatable(Reports.class) @Target(ElementType.TYPE) public @interface Report { int type() default 0; String level() default "info"; String value() default ""; } @Target(ElementType.TYPE) public @interface Reports { Report[] value(); }经过
@Repeatable修饰后,在某个类型声明处,就可以添加多个@Report注解:@Report(type=1, level="debug") @Report(type=2, level="warning") public class Hello { }lnherited@Inherited定义子类是否可继承父类定义的Annotation。@Inherited仅针对@Target(ElementType.TYPE)类型的annotation有效,并且仅针对class的继承,对interface的继承无效:@Inherited @Target(ElementType.TYPE) public @interface Report { int type() default 0; String level() default "info"; String value() default ""; }在使用的时候,如果一个类用到了
@Report:@Report(type=1) public class Person { }则它的子类默认也定义了该注解:
public class Student extends Person { }
总结方法
第一步,用
@interface定义注解:public @interface Report { }第二步,添加参数、默认值:
public @interface Report { int type() default 0; String level() default "info"; String value() default ""; }把最常用的参数定义为
value(),推荐所有参数都尽量设置默认值。第三步,用元注解配置注解:
@Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) public @interface Report { int type() default 0; String level() default "info"; String value() default ""; }必须设置
@Target来指定Annotation可以应用的范围;应当设置
@Retention(RetentionPolicy.RUNTIME)便于运行期读取该Annotation
处理注解
// 判断@Report是否存在于Person类: Person.class.isAnnotationPresent(Report.class);使用反射API读取Annotation:
- Class.getAnnotation(Class)
- Field.getAnnotation(Class)
- Method.getAnnotation(Class)
- Constructor.getAnnotation(Class)
例如:
// 获取Person定义的@Report注解: Report report = Person.class.getAnnotation(Report.class); int type = report.type(); String level = report.level();使用反射API读取
Annotation有两种方法。- 方法一是先判断
Annotation是否存在,如果存在,就直接读取:
Class cls = Person.class; if (cls.isAnnotationPresent(Report.class)) { Report report = cls.getAnnotation(Report.class); ... }第二种方法是直接读取
Annotation,如果Annotation不存在,将返回null:Class cls = Person.class; Report report = cls.getAnnotation(Report.class); if (report != null) { ... }
要读取方法参数的注解,我们先用反射获取
Method实例,然后读取方法参数的所有注解:// 获取Method实例: Method m = ... // 获取所有参数的Annotation: Annotation[][] annos = m.getParameterAnnotations(); // 第一个参数(索引为0)的所有Annotation: Annotation[] annosOfName = annos[0]; for (Annotation anno : annosOfName) { if (anno instanceof Range r) { // @Range注解 r.max(); } if (anno instanceof NotNull n) { // @NotNull注解 // } }使用注解
看一个
@Range注解,希望用来定义一个String字段的规则:字段长度满足@Range的参数定义:@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.FIELD) public @interface Range { int min() default 0; int max() default 255; }在某个JavaBean中,我们可以使用该注解:
public class Person { @Range(min=1, max=20) public String name; @Range(max=10) public String city; }编写一个
Person实例的检查方法,它可以检查Person实例的String字段长度是否满足@Range的定义:void check(Person person) throws IllegalArgumentException, ReflectiveOperationException { // 遍历所有Field: for (Field field : person.getClass().getFields()) { // 获取Field定义的@Range: Range range = field.getAnnotation(Range.class); // 如果@Range存在: if (range != null) { // 获取Field的值: Object value = field.get(person); // 如果值是String: if (value instanceof String s) { // 判断值是否满足@Range的min/max: if (s.length() < range.min() || s.length() > range.max()) { throw new IllegalArgumentException("Invalid field: " + field.getName()); } } } } }