项目背景
外呼机器人是基于自动语音识别、文字转语音以及自然语言理解等技术,面向企业客户提供的一款智能客服机器人产品。智能外呼机器人可根据业务场景,自动发起外呼任务,根据客户的意图进行智能应答。
用户在外呼语音智能机器人系统内通过灵活托拉拽的方式完成机器人话术流程编辑,将外呼语音智能机器人按照业务需求配置话术节点。

当话术完成配置之后,需要对完整的对话流程进行测试。用户通过输入文字进行模拟对话,来进行测试,即文本测试功能。

文本测试需要保证对话流转过程和真实外呼保持相同,但是部分功能(如用户无应答、挂机等)处理的业务逻辑又必须要做到不同。
另外对话流程中,依赖比较多的dubbo接口。比如调用三方接口进行放音、拨号等行为,在文本测试中是不能拨出去的。
在实现文本测试这个功能时,我们不希望动到原来的代码,最好是对话流程整体代码一行都不动,避免新加功能影响到原来的业务逻辑。
问题归纳为,在不动原来一行代码的情况下,满足新业务需求针对特定的部分功能进行修改替换。
第一个版本解决方案
需要做到同与不同,第一个想到的就是java的多态和继承。
第一个版本的大致方案如下:
通过继承的方式,针对部分对话流转中的业务类,对其中特定的几个方法重新处理业务逻辑
通过创建本地对象的方式,对部分二方三方的依赖进行替换。像我们项目都是走的dubbo接口,如AService,我只要本地创建一个LocalAServiceImpl,并对持有AService对象的Bean进行替换。
模拟出一套文本测试环境,用继承后的对象替换原来的对象,用Local实现替换掉原来走的dubbo接口
理论上实现起来并不复杂,但是我们还是遇到了以下问题当AService接口有新的方法增加时,必须要修改到LocalAServiceImpl提供一个空实现。尤其是AService业务比较多,经常要加新方法,导致接口加一个方法就有多个实现类要跟着改。而且对话流程中并不需要这个新接口。开发体感直线下降。
第二版解决方案
为了解决一个方法变更导致多个实现要修改的问题,想到了使用代理的方式,生成代理对象。对流程要用到的接口进行一个替换实现,没有用到的接口全部默认处理掉。
这种方式虽然解决了上述问题,但是又引入新的问题。就是代理实现指定方法,需要你指定方法名,方法名如果通过常量字符串定义的话,后续接口变更咋办?由于用的是字符串来指定代理的方法名,编译器也不会有强提醒,很容易这边改了那边没改也不知道。于是继续研究有没有更好的方案。
基于mockito的解决方案
即希望能重新指定方法,又希望之后方法变更了,编译器能够强制提醒。于是想到是否有相关的mock框架能支持这个场景,调研一下发现Mockito的录制参数和自定义doAnswer返回刚好能够满足我们的需求。
Mockito 是一种 Java Mock 框架,主要就是用来做 Mock 测试的,它可以模拟方法的返回值、模拟抛出异常等等。
以下为代码Demo,我们是如何通过Mockito来创建mock类的。
private TaskService mockTaskService() {
TaskService mockBean = mock(TaskService.class); when(mockBean.getTaskById(anyString(), anyLong())) .thenAnswer(invocationOnMock -> { Object[] args = invocationOnMock.getArguments(); String accountKey = (String) args[0]; Long id = (Long) args[1]; // mock TaskVo TaskVo taskVo = new TaskVo(); taskVo.setAccountKey(accountKey); taskVo.setId(id); taskVo.setBotId(id); return taskVo; }); return mockBean;}
至此我们走完了一个需求从提出到不断完善的全部流程。下面再啰嗦两句mockito的原理,以及它是如何进行方法录制的。
mockito原理
Mock本质上是一个Proxy代理模式的应用。
Proxy模式,是在对象提供一个proxy对象,所有对真实对象的调用,都先经过proxy对象,然后由proxy对象根据情况,决定相应的处理,它可以直接做一个自己的处理,也可以再调用真实对象对应的方法。
所以Mockito本质上就是在代理对象调用方法前,用stub的方式设置其返回值,然后在真实调用时,用代理对象返回起预设的返回值。
when部分源码
public <T> OngoingStubbing<T> when(T methodCall) {
MockingProgress mockingProgress = mockingProgress(); mockingProgress.stubbingStarted(); @SuppressWarnings("unchecked") OngoingStubbing<T> stubbing = (OngoingStubbing<T>) mockingProgress.pullOngoingStubbing(); if (stubbing == null) { mockingProgress.reset(); throw missingMethodInvocation(); } return stubbing; }```
所有的methodCall或被转换成OngoingStubbing对象。
跟踪mockingProgress(),可以看到这个对象是ThreadLocal里面取出来的。
查看MockingProgressImpl源码,可以发现OngoingStubbing是通过reportOngoingStubbing设置进去的
```plain
org.mockito.internal.progress.MockingProgressImpl#reportOngoingStubbing
public void reportOngoingStubbing(OngoingStubbing ongoingStubbing) {
this.ongoingStubbing = ongoingStubbing;}
这个方法最终MockHandlerImpl中的handle内被调用。
整理一下完整的调用链路
- 通过mock方法创建一个代理对象,通过MockMethodInterceptor 把MockHandlerImpl埋在代理对象的方法调用前
- 调用代理对象的方法,并传入参数
- 通过打桩的方式,在真正的方法运行之前把入参记录下来,存到ThreadLocal
- 调用when方法,通过ThreadLocal获取到ongoingStubbing
- 设置ongoingStubbing对应的返回结果(或者方法替换)
- 正式调用的时候,在MockHandlerImpl中判断入参是否录制过结果返回,录制过则直接用录制的结果替换掉原本要调用的真实方法
参考
https://zhuanlan.zhihu.com/p/28983008
#java单元测试 #mockito