单元测试

  • 单元测试:针对最小的功能单元编写测试代码

    • 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,此时,我们要么修正实现代码,要么修正测试代码,直到测试通过为止

    • 单元测试规范:

      • 一是单元测试代码本身必须非常简单,能一下看明白,决不能再为测试代码编写测试;
      • 二是每个单元测试应当互相独立,不依赖运行的顺序;
      • 三是测试时不但要覆盖常用测试用例,还要特别注意测试边界条件,例如输入为0null,空字符串""等情况
  • 使用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());
                        }
                    }
                }
            }
        }