规范的测试类编写

Posted by 孙继峰 on December 4, 2019

断言

【强制】单元测试应该是全自动执行的,并且非交互式的。测试用例通常是被定期执行的,执行过程必须完全自动化才有意义。输出结果需要人工检查的测试不是一个好的单元测试。单元测试中不准使用 System.out 来进行人肉验证,必须使用 assert 来验证。

断言的实现就是判断是否符合预期, 不符合就抛异常.

Java 中有提供 assert (断言)关键字, 但是默认是不生效的, 需要配置虚拟机参数 -enablesystemassertions / -esa 启用断言

JUnit 中有提供此工具类: Assert 其中部分方法:

  • static public void assertNotNull(Object object)
  • static public void assertEquals(Object expected, Object actual)
  • static public void assertTrue(boolean condition)

在这里我安利一个测试框架 AssertJ, 详细的我就不介绍了, 总之 AssertJ 有非常友好的断言 API.


GIVEN - WHEN - THEN

一个标准测试用例的三段式结构:   编写时,我们会精心准备一组输入数据 (Given),然后在调用行为后 (When),断言返回的结果与预期相符 (Then)。


标准原则

【推荐】编写单元测试代码遵守BCDE原则,以保证被测试模块的交付质量。 B:Border,边界值测试,包括循环边界、特殊取值、特殊时间点、数据顺序等。 C:Correct,正确的输入,并得到预期的结果。 D:Design,与设计文档相结合,来编写单元测试。 E:Error,强制错误信息输入(如:非法数据、异常流程、业务允许外等),并得到预期的结果。

举个栗子, 对下面这个方法进行测试

    public List<Person> handle(Person person) {
        if (person.getAge() < 0) {
            person.setAge(0);
        }
        if (person.getBirth().after(new Date())) {
            throw new RuntimeException("生日不能晚于当前");
        }
        return Collections.singletonList(person);
    }

边界测试:

    @Test
    public void testHandle1() {
        // GIVEN 年龄小于 0
        Person person = new Person(-1, "Sun", new Date());
        // WHEN
        List<Person> list = handle(person);
        // THEN
        Assert.assertEquals(list.get(0).getAge(), 0);
        Assert.assertEquals(list.get(0).getName(), "Sun");
    }

    @Test
    public void testHandle2() {
        // GIVEN: 对象的生日晚于当前时间
        Person person = new Person(0, "Sun", new Date(System.currentTimeMillis() + 10000));
        RuntimeException exception = null;
        try {
            // WHEN: 调用被测试方法
            List<Person> list = handle(person);
        } catch (RuntimeException e) {
            exception = e;
        }
        // THEN: 会抛出异常, 且异常信息为: 生日不能晚于当前
        Assert.assertEquals("生日不能晚于当前", exception.getMessage());
    }

正确的输入:

    @Test
    public void testHandle3() {
        // GIVEN
        Person person = new Person(21, "Sun", new Date(889804800000L));
        // WHEN
        List<Person> list = handle(person);
        // THEN
        Assert.assertEquals(list.get(0).getAge(), 21);
        Assert.assertEquals(list.get(0).getName(), "Sun");
        Assert.assertEquals(list.get(0).getBirth(), new Date(889804800000L));
    }

错误的输入:

    @Test
    public void testHandle3() {
        NullPointerException exception = null;
        // GIVEN
        Person person = null;
        try {
            // WHEN
            List<Person> list = handle(null);
        } catch (NullPointerException e) {
            exception = e;
        }
        // THEN
        Assert.assertNotNull(exception);
    }

对 DAO 层的测试 / 数据库集成测试

【推荐】对于数据库相关的查询,更新,删除等操作,不能假设数据库里的数据是存在的,或者直接操作数据库把数据插入进去,请使用程序插入或者导入数据的方式来准备数据。 【推荐】和数据库相关的单元测试,可以设定自动回滚机制,不给数据库造成脏数据。或者对单元测试产生的数据有明确的前后缀标识。

比如要测试一个根据 id 查询用户信息的方法, 不能假设用户是存在的, 直接用库里的 id 去查询, 因为这条数据是不稳定, 你也不知道谁什么时候就把这条数据删了, 这个测试类就跑不通了. 在测试之前要用代码插入一条数据, 测试完成后将数据删除. 如果是 SpringBoot 的测试类的话直接加上 @Transactional 注解就帮你完成了回滚操作.

举个🌰, 测试一个下架商品的方法

@SpringBootTest
@RunWith(SpringRunner.class)
@Transactional
public class GoodsMapperTest {
    @Autowired
    private GoodsMapper goodsMapper;
    /**
     * 正确的下架
     */
    @Test
    public void testOffShelves1() {
        // GIVEN: 存入一个上架的商品
        GoodsDO goods = new GoodsDO("90012", "鞋子", "42", "上架");
        goodsMapper.save(goods);
        // WHEN: 执行下架方法
        goodsMapper.offShelves(goods.getId());
        // THEN: 此商品状态会变成下架
        GoodsDO dbGoods = goodsMapper.getById(goods.getId);
        Assert.assertEquals(dbGoods.getStatus(), "下架");
    }
    /**
     * 下架商品 id 不存在
     */
    @Test
    public void testOffShelves2() {
        // GIVEN
        int id = 123456;
        // WHEN
        int affected = goodsMapper.offShelves(id);
        // THEN
        Assert.assertEquals(affected, 0);
    }
    /**
     * 参数为 null
     .....
}

对 Service 层的测试

在 Service 层中大多会有对 DAO 层的调用, 这里如果真的调用了 DAO 层, 那就不是单元测试了, 就变成了集成测试, 单元测试类应该不依赖于其他模块的. 在测试 Service 的时候要把对 DAO 层的调用 mock 掉, 这样在 Service 里就专注测试 Service 中的代码, 把 DAO 调用理解成单纯的数据输入就行了.


对 Controller 层的测试

其实 Controller 里基本没有什么代码, 但是 Spring 的执行流程Controller 前面有 Interceptor, 如果仅通过注入 Controller 来测试会漏掉自定义的 Interceptor 和 @Valid 参数验证. MockMVC 解决了这个问题.
举个🌰 ,保存用户

    @Test
    public void testAddUser() throws Exception {
        User user = new User();
        user.setId(1L);
        user.setName("jamie");
        user.setAge(20);
        mockMvc.perform(post("/add")
            .contentType(MediaType.APPLICATION_JSON)
            .content(JSON.toJSONString(user)))
            .andExpect(status().isOk())
            .andDo(print())
            .andReturn().getResponse().getContentAsString();
    } 

个人总结

  • 写测试类的目的不仅仅是为了自动化测试, 也是为了以后的重构, 重构前测试类能全跑通, 重构后测试类也要全部能跑通, 这也是 TDD 的思想(Red - Green - Refactor).
  • 测试类很难维护: 测试类即使写出了花来了, 也不一定会好维护, 稍稍改一下代码, 就需要再维护一下那个方法影响的全部测试用例, 如果真正以 TDD 的方式进行开发也就没有维护测试用例这一说了
  • 编写的代码最好能够符合 SOLID 原则, 使测试写起来简单一些