单元测试方法论(终篇)

单元测试方法论(下)icon-default.png?t=N7T8https://blog.csdn.net/sugelachao/article/details/131422020


11.典型案例分析

这里,只收集了几个经典案例,解决了特定环境下的特定问题。

11.1.测试框架特性导致问题

在编写单元测试用例时,或多或少会遇到一些问题,大多数是由于对测试框架特性不熟悉导致,比如:

  1. Mockito不支持对静态方法、构造方法、final方法、私有方法的模拟;

  2. Mockito的any相关的参数匹配方法并不支持可空参数和空参数;

  3. 如果为Mock方法或Mock方法参数不匹配时,会返回默认值(基础类型为 0,对象类型为 null);

  4. 采用Mockito的参数匹配方法或Argument的captor方法时,其它参数不能直接用常量或变量,必须使用Mockito的eq方法包装;

  5. 使用when-then语句模拟Spy对象方法会先执行真实方法,应该使用do-when语句;

  6. PowerMock对静态方法、构造方法、final方法、私有方法的模拟需要把对应的类添加到@PrepareForTest注解中;

  7. PowerMock模拟JDK的静态方法、构造方法、final方法、私有方法时,需要把使用这些方法的类加入到@PrepareForTest注解中,从而导致单元测试覆盖率不被统计;

  8. PowerMock使用自定义的类加载器来加载类,可能导致系统类加载器认为有类型转化问题;需要加上@PowerMockIgnore({"javax.crypto.*"})注解,来告诉PowerMock这个包不要用PowerMock的类加载器加载,需要采用系统类加载器来加载;

  9. 如果遇到Mock对象静态常量初始化失败,可以利用注解@SuppressStaticInitializationFor抑制静态常量初始化。

……

对于这些问题,可以根据提示信息查阅相关资料解决,这里就不再累述了。

11.2.捕获参数值已变更问题

在编写单元测试用例时,通常采用ArgumentCaptor进行参数捕获,然后对参数对象值进行验证。如果参数对象值没有变更,这个步骤就没有任何问题。但是,如果参数对象值在后续流程中发生变更,就会导致验证参数值失败。

原始代码:

public <T> void readData(RecordReader recordReader, int batchSize, Function<Record, T> dataParser, Predicate<List<T>> dataStorage) {
    try {
        // 依次读取数据
        Record record;
        boolean isContinue = true;
        List<T> dataList = new ArrayList<>(batchSize);
        while (Objects.nonNull(record = recordReader.read()) && isContinue) {
            // 解析添加数据
            T data = dataParser.apply(record);
            if (Objects.nonNull(data)) {
                dataList.add(data);
            }

            // 批量存储数据
            if (dataList.size() == batchSize) {
                isContinue = dataStorage.test(dataList);
                dataList.clear();
            }
        }

        // 存储剩余数据
        if (CollectionUtils.isNotEmpty(dataList)) {
            dataStorage.test(dataList);
            dataList.clear();
        }
    } catch (IOException e) {
        String message = READ_DATA_EXCEPTION;
        log.warn(message, e);
        throw new ExampleException(message, e);
    }
}

测试用例:

@Test
public void testReadData() throws Exception {
    // 模拟依赖方法
    // 模拟依赖方法: recordReader.read
    Record record0 = Mockito.mock(Record.class);
    Record record1 = Mockito.mock(Record.class);
    Record record2 = Mockito.mock(Record.class);
    TunnelRecordReader recordReader = Mockito.mock(TunnelRecordReader.class);
    Mockito.doReturn(record0, record1, record2, null).when(recordReader).read();
    // 模拟依赖方法: dataParser.apply
    Object object0 = new Object();
    Object object1 = new Object();
    Object object2 = new Object();
    Function<Record, Object> dataParser = Mockito.mock(Function.class);
    Mockito.doReturn(object0).when(dataParser).apply(record0);
    Mockito.doReturn(object1).when(dataParser).apply(record1);
    Mockito.doReturn(object2).when(dataParser).apply(record2);
    // 模拟依赖方法: dataStorage.test
    Predicate<List<Object>> dataStorage = Mockito.mock(Predicate.class);
    Mockito.doReturn(true).when(dataStorage).test(Mockito.anyList());

    // 调用测试方法
    odpsService.readData(recordReader, 2, dataParser, dataStorage);

    // 验证依赖方法
    // 模拟依赖方法: recordReader.read
    Mockito.verify(recordReader, Mockito.times(4)).read();
    // 模拟依赖方法: dataParser.apply
    Mockito.verify(dataParser, Mockito.times(3)).apply(Mockito.any(Record.class));
    // 验证依赖方法: dataStorage.test
    ArgumentCaptor<List<Object>> recordListCaptor = ArgumentCaptor.forClass(List.class);
    Mockito.verify(dataStorage, Mockito.times(2)).test(recordListCaptor.capture());
    Assert.assertEquals("数据列表不一致", Arrays.asList(Arrays.asList(object0, object1), Arrays.asList(object2)), recordListCaptor.getAllValues());
}

问题现象:

执行单元测试用例失败,抛出以下异常信息:

java.lang.AssertionError: 数据列表不一致 expected:<[[java.lang.Object@1e3469df, java.lang.Object@79499fa], [java.lang.Object@48531d5]]> but was:<[[], []]>

问题原因:

由于参数dataList在调用dataStorage.test方法后,都被主动调用dataList.clear方法进行清空。由于ArgumentCaptor捕获的是对象引用,所以最后捕获到了同一个空列表。

解决方案:

可以在模拟依赖方法dataStorage.test时,保存传入参数的当前值进行验证。代码如下:

@Test
public void testReadData() throws Exception {
    // 模拟依赖方法
    ...
    // 模拟依赖方法: dataStorage.test
    List<Object> dataList = new ArrayList<>();
    Predicate<List<Object>> dataStorage = Mockito.mock(Predicate.class);
    Mockito.doAnswer(invocation -> dataList.addAll((List<Object>)invocation.getArgument(0)))
        .when(dataStorage).test(Mockito.anyList());

    // 调用测试方法
    odpsService.readData(recordReader, 2, dataParser, dataStorage);

    // 验证依赖方法
    ...
    // 验证依赖方法: dataStorage.test
    Mockito.verify(dataStorage, Mockito.times(2)).test(Mockito.anyList());
    Assert.assertEquals("数据列表不一致", Arrays.asList(object0, object1, object2), dataList);
}

11.3.模拟Lombok的log对象问题

Lombok的@Slf4j注解,广泛地应用于Java项目中。在某些代码分支里,可能只有log记录日志的操作,为了验证这个分支逻辑被正确执行,需要在单元测试用例中对log记录日志的操作进行验证。

原始方法:

@Slf4j
@Service
public class ExampleService {
    public void recordLog(int code) {
        if (code == 1) {
            log.info("执行分支1");
            return;
        }
        if (code == 2) {
            log.info("执行分支2");
            return;
        }
        log.info("执行默认分支");
    }
    ...
}

测试用例:

@RunWith(PowerMockRunner.class)
public class ExampleServiceTest {
    @Mock
    private Logger log;
    @InjectMocks
    private ExampleService exampleService;
    @Test
    public void testRecordLog1() {
        exampleService.recordLog(1);
        Mockito.verify(log).info("执行分支1");
    }
}

问题现象:

执行单元测试用例失败,抛出以下异常信息:

Wanted but not invoked:
logger.info("执行分支1");

原因分析:

经过调式跟踪,发现ExampleService中的log对象并没有被注入。通过编译发现,Lombok的@Slf4j注解在ExampleService类中生成了一个静态常量log,而@InjectMocks注解并不支持静态常量的注入。

解决方案:

采用作者实现的FieldHelper.setStaticFinalField方法,可以实现对静态常量的注入模拟对象。

@RunWith(PowerMockRunner.class)
public class ExampleServiceTest {
    @Mock
    private Logger log;
    @InjectMocks
    private ExampleService exampleService;
    @Before
    public void beforeTest() throws Exception {
        FieldHelper.setStaticFinalField(ExampleService.class, "log", log);
    }
    @Test
    public void testRecordLog1() {
        exampleService.recordLog(1);
        Mockito.verify(log).info("执行分支1");
    }
}

11.4.兼容Pandora等容器问题

阿里巴巴的很多中间件,都是基于Pandora容器的,在编写单元测试用例时,可能会遇到一些坑。

原始方法:

@Slf4j
public class MetaqMessageSender {
    @Autowired
    private MetaProducer metaProducer;
    public String sendMetaqMessage(String topicName, String tagName, String messageKey, String messageBody) {
        try {
            // 组装消息内容
            Message message = new Message();
            message.setTopic(topicName);
            message.setTags(tagName);
            message.setKeys(messageKey);
            message.setBody(messageBody.getBytes(StandardCharsets.UTF_8));

            // 发送消息请求
            SendResult sendResult = metaProducer.send(message);
            if (sendResult.getSendStatus() != SendStatus.SEND_OK) {
                String msg = String.format("发送标签(%s)消息(%s)状态错误(%s)", tagName, messageKey, sendResult.getSendStatus());
                log.warn(msg);
                throw new ReconsException(msg);
            }
            log.info(String.format("发送标签(%s)消息(%s)状态成功:%s", tagName, messageKey, sendResult.getMsgId()));

            // 返回消息标识
            return sendResult.getMsgId();
        } catch (MQClientException | RemotingException | MQBrokerException | InterruptedException e) {
            // 记录消息异常
            Thread.currentThread().interrupt();
            String message = String.format("发送标签(%s)消息(%s)状态异常:%s", tagName, messageKey, e.getMessage());
            log.warn(message, e);
            throw new ReconsException(message, e);
        }
    }
}

测试用例:

@RunWith(PowerMockRunner.class)
public class MetaqMessageSenderTest {
    @Mock
    private MetaProducer metaProducer;
    @InjectMocks
    private MetaqMessageSender metaqMessageSender;
    @Test
    public void testSendMetaqMessage() throws Exception {
        // 模拟依赖方法
        SendResult sendResult = new SendResult();
        sendResult.setMsgId("msgId");
        sendResult.setSendStatus(SendStatus.SEND_OK);
        Mockito.doReturn(sendResult).when(metaProducer).send(Mockito.any(Message.class));

        // 调用测试方法
        String topicName = "topicName";
        String tagName = "tagName";
        String messageKey = "messageKey";
        String messageBody = "messageBody";
        String messageId = metaqMessageSender.sendMetaqMessage(topicName, tagName, messageKey, messageBody);
        Assert.assertEquals("messageId不一致", sendResult.getMsgId(), messageId);

        // 验证依赖方法
        ArgumentCaptor<Message> messageCaptor = ArgumentCaptor.forClass(Message.class);
        Mockito.verify(metaProducer).send(messageCaptor.capture());
        Message message = messageCaptor.getValue();
        Assert.assertEquals("topicName不一致", topicName, message.getTopic());
        Assert.assertEquals("tagName不一致", tagName, message.getTags());
        Assert.assertEquals("messageKey不一致", messageKey, message.getKeys());
        Assert.assertEquals("messageBody不一致", messageBody, new String(message.getBody()));
    }
}

问题现象:

执行单元测试用例失败,抛出以下异常信息:

java.lang.RuntimeException: com.alibaba.rocketmq.client.producer.SendResult was loaded by org.powermock.core.classloader.javassist.JavassistMockClassLoader@5d43661b, it should be loaded by Pandora Container. Can not load this fake sdk class.

原因分析:

基于Pandora容器的中间件,需要使用Pandora容器加载。在上面测试用例中,使用了PowerMock容器加载,从而导致抛出类加载异常。

解决方案:

首先,把PowerMockRunner替换为PandoraBootRunner。其次,为了使@Mock、@InjectMocks等Mockito注解生效,需要加上注解@DelegateTo(MockitoJUnitRunner.class)进行初始化。

@RunWith(PandoraBootRunner.class)
@DelegateTo(MockitoJUnitRunner.class)
public class MetaqMessageSenderTest {
	...
}

12.消除类型转换警告

在编写测试用例时,特别是泛型类型转换时,很容易产生类型转换警告。常见类型转换警告如下:

Type safety: Unchecked cast from Object to List<Object>
Type safety: Unchecked invocation forClass(Class<Map>) of the generic method forClass(Class<S>) of type ArgumentCaptor
Type safety: The expression of type ArgumentCaptor needs unchecked conversion to conform to ArgumentCaptor<Map<String,Object>>

作为一个有代码洁癖的轻微强迫症程序员,是绝对不容许这些类型转换警告产生的。于是,总结了以下方法来解决这些类型转换警告。

12.1.利用注解初始化

Mockito提供@Mock注解来模拟类实例,提供@Captor注解来初始化参数捕获器。由于这些注解实例是通过测试框架进行初始化的,所以不会产生类型转换警告。

问题代码:

Map<Long, String> resultMap = Mockito.mock(Map.class);
ArgumentCaptor<Map<String, Object>> parameterMapCaptor = ArgumentCaptor.forClass(Map.class);

建议代码:

@Mock
private Map<Long, String> resultMap;
@Captor
private ArgumentCaptor<Map<String, Object>> parameterMapCaptor;

12.2.利用临时类或接口

我们无法获取泛型类或接口的class实例,但是很容易获取具体类的class实例。这个解决方案的思路是——先定义继承泛型类的具体子类,然后mock、spy、forClass以及any出这个具体子类的实例,然后把具体子类实例转换为父类泛型实例。

问题代码:

Function<Record, Object> dataParser = Mockito.mock(Function.class);
AbstractDynamicValue<Long, Integer> dynamicValue = Mockito.spy(AbstractDynamicValue.class);
ArgumentCaptor<ActionRequest<Void>> requestCaptor = ArgumentCaptor.forClass(ActionRequest.class);

建议代码:

/** 定义临时类或接口 */
private interface DataParser extends Function<Record, Object> {};
private static abstract class AbstractTemporaryDynamicValue extends AbstractDynamicValue<Long, Integer> {};
private static class VoidActionRequest extends ActionRequest<Void> {};
​
/** 使用临时类或接口 */
Function<Record, Object> dataParser = Mockito.mock(DataParser.class);
AbstractDynamicValue<Long, Integer> dynamicValue = Mockito.spy(AbstractTemporaryDynamicValue.class);
ArgumentCaptor<ActionRequest<Void>> requestCaptor = ArgumentCaptor.forClass(VoidActionRequest.class);

12.3.利用CastUtils.cast方法

SpringData包中提供一个CastUtils.cast方法,可以用于类型的强制转换。这个解决方案的思路是——利用CastUtils.cast方法屏蔽类型转换警告。

问题代码:

Function<Record, Object> dataParser = Mockito.mock(Function.class);
ArgumentCaptor<ActionRequest<Void>> requestCaptor = ArgumentCaptor.forClass(ActionRequest.class);
Map<Long, Double> scoreMap = (Map<Long, Double>)method.invoke(userService);

建议代码:

Function<Record, Object> dataParser = CastUtils.cast(Mockito.mock(Function.class));
ArgumentCaptor<ActionRequest<Void>> requestCaptor = CastUtils.cast(ArgumentCaptor.forClass(ActionRequest.class));
Map<Long, Double> scoreMap = CastUtils.cast(method.invoke(userService));

这个解决方案,不需要定义注解,也不需要定义临时类或接口,能够让测试用例代码更为精简,所以作者重点推荐使用。如果不愿意引入SpringData包,也可以自己参考实现该方法,只是该方法会产生类型转换警告。

注意:CastUtils.cast方法本质是——先转换为Object类型,再强制转换对应类型,本身不会对类型进行校验。所以,CastUtils.cast方法好用,但是不要乱用,否则就是大坑(只有执行时才能发现问题)。

12.4.利用类型自动转换方法

在Mockito中,提供形式如下的方法——泛型类型只跟返回值有关,而跟输入参数无关。这样的方法,可以根据调用方法的参数类型自动转换,而无需手动强制类型转换。如果手动强制类型转换,反而会产生类型转换警告。

<T> T getArgument(int index);
public static <T> T any();
public static synchronized <T> T invokeMethod(Object instance, String methodToExecute, Object... arguments) throws Exception;

问题代码:

Mockito.doAnswer(invocation -> dataList.addAll((List<Object>)invocation.getArgument(0)))
    .when(dataStorage).test(Mockito.anyList());
Mockito.doThrow(e).when(workflow).beginToPrepare((ActionRequest<Void>)Mockito.any());
Map<Long, Double> scoreMap = (Map<Long, Double>)Whitebox.invokeMethod(userService, "getScoreMap");

建议代码:

Mockito.doAnswer(invocation -> dataList.addAll(invocation.getArgument(0)))
    .when(dataStorage).test(Mockito.anyList());
Mockito.doThrow(e).when(workflow).beginToPrepare(Mockito.any());
Map<Long, Double> scoreMap = Whitebox.invokeMethod(userService, "getScoreMap");

其实,SpringData的CastUtils.cast方法之所以这么强悍,也是采用了类型自动转化方法。

12.5.利用doReturn-when语句代替when-thenReturn语句

Mockito的when-thenReturn语句需要对返回类型强制校验,而doReturn-when语句不会对返回类型强制校验。利用这个特性,可以利用doReturn-when语句代替when-thenReturn语句解决类型转换警告。

问题代码:

List<String> valueList = Mockito.mock(List.class);
Mockito.when(listOperations.range(KEY, start, end)).thenReturn(valueList);

建议代码:

List<?> valueList = Mockito.mock(List.class);
Mockito.doReturn(valueList).when(listOperations).range(KEY, start, end);

12.6.利用Whitebox.invokeMethod方法代替Method.invoke方法

JDK提供的Method.invoke方法返回的是Object类型,转化为具体类型时需要强制转换,会产生类型转换警告。而PowerMock提供的Whitebox.invokeMethod方法返回类型可以自动转化,不会产生类型转换警告

问题代码:

Method method = PowerMockito.method(UserService.class, "getScoreMap");
Map<Long, Double> scoreMap = (Map<Long, Double>)method.invokeMethod(userService);

建议代码:

Map<Long, Double> scoreMap = Whitebox.invokeMethod(userService, "getScoreMap");

12.7.利用instanceof关键字

在具体类型强制转换时,建议利用instanceof关键字先判断类型,否则会产生类型转换警告。

问题代码:

JSONArray jsonArray = (JSONArray)object;
...

建议代码:

if (object instanceof JSONArray) {
    JSONArray jsonArray = (JSONArray)object;
    ...
}

12.8.利用Class.cast方法

在泛型类型强制转换时,会产生类型转换警告。可以采用泛型类的cast方法转换,从而避免产生类型转换警告。

问题代码:

public static <V> V parseValue(String text, Class<V> clazz) {
    if (Objects.equals(clazz, String.class)) {
        return (V)text;
    }
    return JSON.parseObject(text, clazz);
}

建议代码:

public static <V> V parseValue(String text, Class<V> clazz) {
    if (Objects.equals(clazz, String.class)) {
        return clazz.cast(text);
    }
    return JSON.parseObject(text, clazz);
}

12.9.避免不必要的类型转换

有时候,没有必要进行类型转换,就尽量避免类型转换。比如:把Object类型转换为具体类型,但又把具体类型当Object类型使用,就没有必要进行类型转换。像这种情况,可以利用连写表达式或定义基类变量,从而避免不必要的类型转化。

问题代码:

Boolean isSupper = (Boolean)method.invokeMethod(userService, userId);
Assert.assertEquals("期望值不为真", Boolean.TRUE, isSupper);
​
List<UserVO> userList = (Map<Long, Double>)method.invokeMethod(userService, companyId);
Assert.assertEquals("期望值不一致", expectedJson, JSON.toJSONString(userList));

建议代码:

Assert.assertEquals("期望值不为真", Boolean.TRUE, method.invokeMethod(userService, userId));
​
Object userList = method.invokeMethod(userService, companyId);
Assert.assertEquals("期望值不一致", expectedJson, JSON.toJSONString(userList));

后记

登妙峰山记

山高路远车难骑,

精疲力尽人易弃。

多少妙峰登顶者,

又练心境又练力!

骑行的人,一定要沉得住气、要吃得了苦、要耐得住寂寞、要意志坚定不移、要体力够猛够持久……恰好,这也正是技术人所要具备的精神。只要技术人做到了这些,练就了好的“心境”和“体力”,才有可能登上技术的“妙峰山”。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值