为什么写单测

代码不写测试就像上了厕所不洗手……单元测试是对软件未来的一项必不可少的投资。

单元测试的好处:

  • 更快的发现 BUG
  • 使代码更加可维护,重构

虽然单测有很多的好处,在日常工作中我们的项目中还是有很多的单测或者集成测试是缺失的。常见的原因总结如下:代码逻辑过于复杂;写单元测试时耗费的时间较长;任务重、工期紧,或者干脆就不写了。

Spock

Spock 是一款国外优秀的测试框架,基于BDD(行为驱动开发)思想实现,功能非常强大。Spock 结合 Groovy 动态语言的特点,提供了各种标签,并采用简单、通用、结构化的描述语言,让编写测试代码更加简洁、高效。

简单来说:Spock 是 Groovy 和 Java 的测试框架,学习成本低,入门快速

  • Groovy 动态脚本语言,测试代码简单高效【代码少】
  • BDD 驱动,语义清晰【可读性高】
  • 多标签,givenwhereandthenexpectthrown等帮助我们测试复杂场景
  • 自带 Mock 功能,可以和常用的第三方框架PowerMockMockitoJmock等扩展结合使用
    • JUnit 是常见的单测框架,但是不提供 Mock 功能

spock 入门

  • given:输入条件(前置参数)。
  • when:执行行为(Mock接口、真实调用)。
  • then:输出条件(验证结果)。
  • and:衔接上个标签,补充的作用。
  • with:验证复杂返回对象使用
  • cleanup : 清理必要的资源,一定会执行的 block
1
2
3
4
5
6
7
//所有的测试类需要继承Specification
class MyFirstTest extends Specification {
 // fields
 // fixture methods
 // feature methods
 // helper methods
}

固定方法

  • def setupSpec() {} // 只运行一次 在第一个 Feature 执行前
  • def setup() {} // 每个 Feature 运行前
  • def cleanup() {} //每个 Feature 运行后
  • def cleanupSpec() {} //只运行一次 在最后一个 Feature 执行后

Feature 方法

生命周期

  • Setup
  • Stimulus
  • Response
  • Cleanup

常用注解

  • @RunWith

  • @UnRoll

  • @Shared

  • @ContextConfiguration

    Spring 整合 JUint4 时引入配置文件

    单文件

    @ContextConfiguration(Locations=”classpath:applicationContext.xml”)

    @ContextConfiguration(classes = SimpleConfiguration.class)

    多文件

    @ContextConfiguration(locations = { “classpath:spring1.xml”, “classpath:spring2.xml” })

常见测试场景

多分支场景

expect+where

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
/**
    * 通过身份证号码获取出生日期、性别、年龄
    * @param certificateNo
    * @return 返回的出生日期格式:1990-01-01 性别格式:F-女,M-男
    */
   public static Map<String, String> getBirAgeSex(String certificateNo) {
       String birthday = "";
       String age = "";
       String sex = "";1

       int year = Calendar.getInstance().get(Calendar.YEAR);
       char[] number = certificateNo.toCharArray();
       boolean flag = true;
       if (number.length == 15) {
           for (int x = 0; x < number.length; x++) {
               if (!flag) return new HashMap<>();
               flag = Character.isDigit(number[x]);
          }
      } else if (number.length == 18) {
           for (int x = 0; x < number.length - 1; x++) {
               if (!flag) return new HashMap<>();
               flag = Character.isDigit(number[x]);
          }
      }
       if (flag && certificateNo.length() == 15) {
           birthday = "19" + certificateNo.substring(6, 8) + "-"
                   + certificateNo.substring(8, 10) + "-"
                   + certificateNo.substring(10, 12);
           sex = Integer.parseInt(certificateNo.substring(certificateNo.length() - 3,
                   certificateNo.length())) % 2 == 0 ? "女" : "男";
           age = (year - Integer.parseInt("19" + certificateNo.substring(6, 8))) + "";
      } else if (flag && certificateNo.length() == 18) {
           birthday = certificateNo.substring(6, 10) + "-"
                   + certificateNo.substring(10, 12) + "-"
                   + certificateNo.substring(12, 14);
           sex = Integer.parseInt(certificateNo.substring(certificateNo.length() - 4,
                   certificateNo.length() - 1)) % 2 == 0 ? "女" : "男";
           age = (year - Integer.parseInt(certificateNo.substring(6, 10))) + "";
      }
       Map<String, String> map = new HashMap<>();
       map.put("birthday", birthday);
       map.put("age", age);
       map.put("sex", sex);
       return map;
  }

/**
    * @Unroll注解表示展开where标签下面的每一行测试,作为单独的case跑
    * @return
    */
   @Unroll
   def "多分支测试,id:#idNo,结果:#result"() {
       expect: "expect = 是when + then标签的组合"
       MultiCase.getBirAgeSex(idNo) == result
       where:
       idNo || result
       "310168199809187333" || ["birthday": "1998-09-18", "sex": "男", "age": "24"]
       "320168200212084268" || ["birthday": "2002-12-08", "sex": "女", "age": "20"]
       "330168199301214267" || ["birthday": "1993-01-21", "sex": "女", "age": "29"]
       "411281870628201"    || ["birthday": "1987-06-28", "sex": "男", "age": "35"]
       "427281730307862"    || ["birthday": "1973-03-07", "sex": "女", "age": "49"]
       "479281691111377"    || ["birthday": "1969-11-11", "sex": "男", "age": "53"]
  }

微服务 RPC 依赖场景

微服务下往往会有很多上下游的依赖场景,所以 Mock 的需求是我们在写单测的时候经常会用到的功能。

Mockito 是一种 Java Mock 框架,主要是用来做 Mock 测试,它可以模拟任何 Spring 管理的 Bean、模拟方法的返回值、模拟抛出异常等等,避免你为了测试一个方法,却要自行构建整个 bean 的依赖链。

@MockBean Vs @Mock

@MockBeanSpringBoot 在执行单元测试时,会将该注解的 Bean 替换掉 IOC 容器中原生 Bean。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@RunWith(MockitoJUnitRunner.class)
public class MockTest {

   @Mock
   private UserService userService;

   @Before
   public void setUp() throws Exception {
       System.out.println("Before");
  }

   @Test
   public void saveProjectBase() {

  }

Mock 的限制

  • 不能 Mock 静态方法
  • 不能 Mock private 方法
  • 不能 Mock final class

常见的使用方式

Mockito.when( 对象.方法名() ).thenReturn( 自定义结果 )

PowerMock

PowerMock 去模拟静态方法、final方法、私有方法等。

PowerMock 的PowerMockRunner也是继承自 JUnit,所以使用 PowerMock 的@PowerMockRunnerDelegate()注解,可以指定 Spock 的父类Sputnik去代理运行 PowerMock

如果单元测试代码不需要对静态方法、final方法 Mock,就没必要使用 PowerMock,使用 Spock 自带的Mock()就足够了。因为 PowerMock 的原理是在编译期通过 ASM 字节码修改工具修改代码,然后使用自己的ClassLoader加载,而加载的静态方法越多,测试耗时就会越长。【既要 Mock 静态方法,也要 Mock 对象方法,就必须使用 PowerMock 提供的能力】

Spock

  • Mock() 虚拟类,隔离真实类,为每个方法返回一个默认值,引用类型为 null ,基础类型为 0 或者 false

  • Stub() 只返回预先准备好的数据,不管调用多少次

  • Spy() 间谍包装一个真实对象,默认情况下将调用真实方法,也提供 Mock 的功能。功能很强大但是官网不推荐使用,mock()足够了

    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
    26
    27
    28
    29
    30
    31
    32
    33
    34

    // 被测试代码
    class MyClass {
       int methodA(int i){
           int j = methodB();
           // 其他业务逻辑...
           if (j > 0) {
               return Math.max(i, j);
          } else {
               return Math.min(i, j);
          }
      }

       int methodB(){
           System.out.println("methodB call");
           return 2; // 结果假如是从查询数据库或其他接口获取
      }
    }

    // 单元测试
    class MyClassTest extends Specification {
     // 结果显示是不会输出"methodB call"的,因为methodB()方法已经被mock了,虽然是在同一个类的methodA()方法里有调用methodB(),但也不会被真正被调用。
       def "testMethodA"() {
           given:
           def spy = Spy(MyClass)
           1 * spy.methodB() >> 2

           when:
           def result = spy.methodA(-1)

           then:
           result > 0
      }
    }

异常测试

spock 内置的thrown()方法可以捕获预期异常并校验

特殊场景

  • static

    powermock

  • abstract

抽象方法/父类 super 方法的 mock 我们可以借用 powermock 的能力来实现

PowerMockito.when(child.parentMethod()).thenReturn(parentValue)

  • final

    powermock

  • private

    使用powermockWhitebox.invokeMethod()方法可以调用对象的私有方法

    1
    2
    //第一个参数为对象,第二个参数为该对象的私有方法名,后面的可变参数为传入的参数
           Whitebox.invokeMethod(demoRegisterService, "sendRegisterEvent", organizationDO, userDO, userDO, userDO)
  • void

一般来说无返回值的方法,内部逻辑会修改入参的属性值,比如参数是个对象,那代码里可能会修改它的属性值,虽然没有返回,但还是可以通过校验入参的属性来测试 void 方法

  • db

    对于 DAO 测试有一般最简的方式是直接使用@SpringBootTest注解启动测试环境,通过 Spring 创建 Mybatis、Mapper 实例。但是一般会存在对底层数据的强依赖,更好的测试应该隔离底层依赖,测试用例可以复用

附言

在开发过程中,随手写单测是一个好的习惯,好的单测是比较耗时的,往往大于开发时间。但是好的单测,对于代码的质量提升很大。建议在梳理完流程框架之后,编写相应的测试用例框架。

  • jacoco maven 测试覆盖率插件

参考