Java 单元测试最佳实践
in Java with 0 comment

Java 单元测试最佳实践

in Java with 0 comment

近期在项目中有写一些单元测试,在这里总结一些比较好的实践,本文主要从代码方面来讲解。读者需要有单测一些理论的前置知识,比如说几个比较常见的原则,AIR 原则,边界,异常,空测试。

目前觉得比较有实践价值的有:

1. 不要为了提升单测覆盖率去测bean

有很多公司要求单测覆盖率,bean 有很多 get set 方法也被统计进去,不要为了覆盖率去给这些 get set 方法写单测,这样虽然单元测试的覆盖率上去了,但是程序仍然没有得到正确的测试,善用屏蔽。

IDEA:
image20201024171611206.png

屏蔽掉即可。

image20201024171704863.png

2. 隔离外部环境依赖

好的测试不需要依赖外部环境,依赖外部网络等环境可能会因为网络原因导致单元测试失败。

一般后端与外部系统交互有缓存,服务调用,数据库等。

用缓存举例,外部调用等建议抽象出一个统一的接口,通过 Spring 的 profile 来注入不同的 bean 这里拿缓存举例。

Pixelbook.png

这里的 fake cache 可能是一个使用 Map 实现的本地缓存,不需要与外部系统进行交互。

比如说我们有一个系统需要与外部 Redis 做交互,Redis 只需要简单的 get set 操作,这时候我们抽象出一个接口:

/**
 * 只提供简单的String,之间的get set操作 在不同的环境中注入不同的实现
 *
 * @author zjmeow
 */
public interface Cache {

    /**
     * 参考map中的put方法
     *
     * @param key 键
     * @param value 值
     */
    void put(String key, String value);

    /**
     * 参考map中的get方法
     *
     * @param key 键
     * @return 值
     */
    String get(String key);
}

然后实现一套用于测试的本地缓存

/**
 * 实现一个本地缓存,本地运行以及单元测试使用这个缓存避免单测对网络环境的依赖
 *
 * @author zjmeow
 */
@Component
@Profile("test")
public class ProxyLocalCache implements Cache {

    private Map<String, String> cache = new ConcurrentHashMap<>();

    @Override
    public void put(String key, String value) {
        cache.put(key, value);
    }

    @Override
    public String get(String key) {
        return cache.get(key);
    }

}

再实现一套与外部系统交互的缓存

/**
 * 实现一个本地缓存,本地运行以及单元测试使用这个缓存避免单测对网络环境的依赖
 *
 * @author zjmeow
 */
@Component
@Profile("dev")
public class ProxyLocalCache implements Cache {

    private Jedis jedis = new Jedis("...");

    @Override
    public void put(String key, String value) {
        jedis.set(key, value);
    }

    @Override
    public String get(String key) {
        return jedis.get(key);
    }

}

这样缓存就能避免外部环境的依赖了。

当然数据库最好使用 h2 内存数据库来避免外部环境的依赖。

3. 尽量使用 IOC

使用 IOC 可以解耦对象,使得测试更加方便。经常有这样的情况,在某个 service 中使用到某个工具类,这个工具类内的方法都是 static 的,这样的话,测试 service 的时候就会连着工具类一起测试了。如果使用 IOC 的话可以注入自己的实现。

假如说我们有一个工具类 TokenUtil,用于生成指定规则的随机字符串,如果直接在 Service 调用这个 Util 的静态方法,代码如下。

/**
 * token 生成器
 *
 * @author zjmeow
 */
public class TokenUtil {

    public static String getToken() {
        // 虚假的token生成器
        return Instant.now().getEpochSecond() + "";
    }
}
/**
 * token service
 *
 * @author zjmeow
 */
@Service
public class TokenService {

    public String login(String username, String password) {
        // 虚假的查询一下password
        if (password.equals(queryPassword(username))) {
            return TokenUtil.getToken();
        }
        return null;
    }
}

当我们需要单元测试的时候,由于耦合度过高,就没办法单独的测试 TokenService 了。

而且当我们测试需要某个固定的 token 也是难以实现的,假如使用了依赖注入,那么事情就变得不一样了。

/**
 * token 生成器
 *
 * @author zjmeow
 */
@Component
public class TokenUtil {

    public String getToken() {
        // 虚假的token生成器
        return Instant.now().getEpochSecond() + "";
    }
}

/**
 * token service
 *
 * @author zjmeow
 */
@Service
public class TokenService {
  
    private final TokenUtil tokenUtil;

    @Autowired
    public TokenService(TokenUtil tokenUtil) {
        this.tokenUtil = tokenUtil;
    }

    public String login(String username, String password) {
        // 虚假的查询一下password
        if (password.equals(queryPassword(username))) {
            return tokenUtil.getToken();
        }
        return null;
    }
}

这时候想要单独的测试 TokenService 只需要很简单的 mock 一个 TokenUtil 就可以了,不仅隔离了 TokenUtil 和 TokenService,而且还能很方便的指定 TokenUtil 返回的对象。

/**
 * token service test
 *
 * @author zjmeow
 */
class TokenServiceTest {
    private TokenService tokenService;
    private static final String FAKE_TOKEN = "123456789";
    private static final String USERNAME = "username";
    private static final String PASSWORD = "password";

    @BeforeEach
    void setUp() {
        TokenUtil util = mock(TokenUtil.class);
        when(util.getToken()).thenReturn(FAKE_TOKEN);
        tokenService = new TokenService(util);
    }

    @Test
    void login() {
        assertEquals(FAKE_TOKEN, tokenService.login(USERNAME, PASSWORD));
    }
}

4. 测试数据

需要频繁使用到的数据可以考虑用一个包来统一放测试数据,这样能避免手滑导致的单测失败,同时也减少了维护成本,比如说我们需要一个测试用户,可以单独的写一个类出来。

package com.xxx.constant;
public class TestUser {
    public static final String USERNAME = "test_user_name";
    public static final String PASSWORD = "test_password";
}

这样就能方便在多个测试类中使用了。

像 Flink 等一些需要大量测试数据的地方,最好能单独写一个类到文件中读取数据流,方便在多个类中复用。

5. ParameterizedTest 的使用

灵活的使用 csv 可以减少很大一部分代码,可以在一套数据中同时测试到边界,空值等,假如说我们想要测试一个检测 ip 是否合规的函数,就可以用一组 CsvSource 来测试对空字符串,null 等进行测试

class IpUtilTest {
    private IpUtil ipUtil = new IpUtil();

    @ParameterizedTest
    @CsvSource({
            "192.168.1.1,true", 
            "aaaaaaa,false",// invalid ip
            ",false",// null
            "'',false",// empty string
    })
    void check(String ip, boolean except) {
        assertEquals(except, ipUtil.check(ip));
    }
}