单元测试实践背景
-
测试环境定位bug时,需要测试同学协助手动发起相关业务URL请求,开发进行远程调试
问题:
1、远程调试影响测试环境数据正常获取,影响测试同学测试进度
2、远程调试代码有时并非最新代码,与本地不一致增加调试难度,往往需要发最新的包再调试
3、controller层请求参数依赖特定客户端版本发起,其他版本回归验证,增加模拟操作成本 依赖第三方系统,第三方系统请求不稳定或希望第三方接口返回特定数据
为什么需要单测
编写单元测试代码并不是一件容易的事情,那为什么还需要去话费时间和精力来编写单元测试呢?
减少Bug:如今的项目大多都是多人分模块协同开发,当各个模块集成时再去发现问题,定位和沟通成本是非常高的,通过单元测试来保证各个模块的正确性,可以尽早的发现问题,而不时等到集成时再发现
问题。
放心重构:如今持续型的项目越来越多,代码不断的在变化和重构,通过单元测试,开发可以放心的修改重构代码,减少改代码时心理负担,提高重构的成功率。
改进设计:越是良好设计的代码,一般越容易编写单元测试,多个小的方法的单测一般比大方法(成百上千行代码)的单测代码要简单、
要稳定,一个依赖接口的类一般比依赖具体实现的类容易测试,所以
在编写单测的过程中,如果发现单测代码非常难写,一般表明被测试
的代码包含了太多的依赖或职责,需要反思代码的合理性,进而推进
代码设计的优化,形成正向循环。
个人感受,将controller层请求参数抽取管理后,debug不依赖客户端与测试环境,能够迅速在本地执行定位问题;同时,单元测试提供测试数据准备与模拟特定测试数据返回,对业务测试起辅助作用。
单元测试需要理解的几个概念
被测系统:SUT(System Under Test)
被测系统(System under test,SUT)表示正在被测试的系统,目的是测试系统能否正确操作。这一词语常用于软件测试中。软件系统测
试的一个特例是对应用软件的测试,称为被测应用程序(application under test,AUT)。
SUT也表明软件已经到了成熟期,因为系统测试在测试周期中是集成测试的后一阶段。测试替身:Test Double
在单元测试时,使用Test Double减少对被测对象的依赖,使得测试
更加单一。同时,让测试案例执行的时间更短,运行更加稳定,同时
能对SUT内部的输入输出进行验证,让测试更加彻底深入。但是,Test Double也不是万能的,Test Double不能被过度使用,因为实际交付的产品是使用实际对象的,过度使用Test Double会让测试变得越来越脱离实际。
要理解测试替身,需要了解一下Dummy Objects、Test Stub、Test Spy、Fake Object 这几个概念,下面我们对这些概念分别进行说明。Dummy Objects
Dummy Objects泛指在测试中必须传入的对象,而传入的这些对象
实际上并不会产生任何作用,仅仅是为了能够调用被测对象而必须传
入的一个东西。Test Stub
测试桩是用来接受SUT内部的间接输入(indirect inputs),并返回特定的值给SUT。可以理解Test Stub是在SUT内部打的一个桩,可以按照我们的要求返回特定的内容给SUT,Test Stub的交互完全在SUT内部,因此,它不会返回内容给测试案例,也不会对SUT内部的输入进行验证。
Test Spy
Test Spy像一个间谍,安插在了SUT内部,专门负责将SUT内部的间接输出(indirect outputs)传到外部。它的特点是将内部的间接输出返回给测试案例,由测试案例进行验证,Test Spy只负责获取内部情报,并把情报发出去,不负责验证情报的正确性。
Mock Object
Mock Object和Test Spy有类似的地方,它也是安插在SUT内部,获取到SUT内部的间接输出(indirect outputs),不同的是,Mock Object还负责对情报(intelligence)进行验证,总部(外部的测试案例)信任Mock Object的验证结果。
Fake Object
经常,我们会把Fake Object和Test Stub搞混,因为它们都和外部没有交互,对内部的输入输出也不进行验证。不同的是,Fake Object并不关注SUT内部的间接输入(indirect inputs)或间接输出(indirect outputs),它仅仅是用来替代一个实际的对象,并且拥有几乎和实际对象一样的功能,保证SUT能够正常工作。实际对象过分依赖外部环境,Fake Object可以减少这样的依赖。
看完Test Double这几个概念后,是不是一头雾水?以下通俗解释,Dummy Objects就不做解释了。
Test Stub
系统测试需要某一指定数据返回时,开发将获取数据逻辑代码替换成指定数据,发包测试完再替换回原来逻辑。替换代码返回指定数据,这就是测试桩。Test Spy
Test Stub只返回指定内容给SUT,并没有指定返回测试案例,所以我们引入单元测试,在单元测试用例调用引用该插桩的方法。
这时我们能获测试桩间接输出内容,甚至是报错信息,再也不用到服务器查找错误日志了,这就是Test Spy。Mock Object
Mock Object就是在Test Spy的基础上,加入验证机制。调用引用该插桩的方法,我们要确保这个插桩正常被执行或指定执行n次,得到的结果是不是我们期望的结果,mock就以此为生。Fake Object
Fake Object相对Test Stub,是一个面向对象概念。我们只希望替换掉一个实际被引用对象里面的一个方法返回值,被替换某个方法返回值的对象就叫Fake Oject,它与实际对象一样的功能。Mock Object也囊括Fake Object概念,可以看出Test Stub < Fake Object < Mock Object。
Mock框架模型
测试验证过程,我们不可能每次都修改代码stub一个方法,发包验证完后再改回,发布外网回归验证阶段这种操作根本不被允许。Mock框架应运而生,我们在单元测试用例stub一个方法后,将之注入被测系统SUT,这个注入只会在test spy阶段产生影响。
市面上很多mock框架,Jmockit、Mockito、PowerMock、EasyMock等,大体遵循record-replay-verify模型设计,有些地方称之为expect-run-verify模式(期望--运行--验证),有些地方称之(AAA阶段)Arrange 、Act、Assert,大体一个意思。很明显,Mock框架的应用过程,我们先需要指定stub,然后运行被测方法,然后在验证stub的正确性,这个过程就称之为mock。
单元测试框架选择
Testng
TestNG与Junit很相似, 但testng更加灵活,以下为两者对比。
[图片上传失败...(image-93566-1513052813178)]
参考 JUnit 4 Vs TestNG比较
- Testng支持分组测试
- Testng参数化测试支持复杂类型参数,而junit只支持基本类型
- Testng提供XML灵活配置测试运行套件
- Testng支持依赖测试
- Testng支持并发测试,上面文章未讲到的,补充下。如@Test(threadPoolSize=3,invocationCount=6,timeout=500),而Junit的话可以引入JunitPref框架。
Jmockit
Jmockit是一个功能很强大的框架,可以mock静态方法、final类、抽象类、接口、构造函数等,几乎无所不能,但编程语言不够简洁。
Jmockit的介绍和使用
这里需要补充的点:
注解@Tested,标识的被测对象实例, @Injectable的实例会自动注入到@Tested中,有时候在事件过程中实在无法注入,可以借助spring的反射工具ReflectionTestUtils进行注入。
Expectations:期望,指定的方法必须被调用,且方法默认次数为1。如果指定打桩的方法在test用例不被调用,或者调用次数超过1,则会报错,建议使用NonStrictExpectations配合Verifications使用。
Expectations(T)/NonStrictExpectations(T),Expectations(.class){}这种方式只会模拟区域中包含的方法,这个类的其它方法将按照正常的业务逻辑运行,T就变成了一个Fake Object。
MockUp(T)中,未mock的函数不受影响,T也是一个Fake Object。通常rpc接口(接口无具体实现方法)、构造函数通过MockUp进行局部方法mock。
以下主要演示一个rpc接口的mock。
public class ColumnArticlesControllerTest2 extends BaseContorllerMockTest {
private MockMvc mockMvc;
@Autowired
private ConfigService configService;
@Autowired
private ICpDataKievHandler cpDataKievHandler;
@Autowired
private IndexArticlesDaoCacheImpl indexArticlesDao;
@Autowired
private ColumnArticlesController columnArticlesController;
@BeforeMethod()
public void setUp() throws Exception {
mockMvc = MockMvcBuilders.standaloneSetup(columnArticlesController).build();
}
// CSV最好使用gbk格式,目前不支持默认路径,CSV文件位于到dataprovider目录下
@Test(description = "测试list.do接口", dataProvider = "genData", dataProviderClass = CommonDataProvider.class)
@Csv("/dataprovider/ColumnArticlesControllerTest/testGetColumnArticleList.csv")
public void testGetColumnArticleList(String cpChannelId, long columnId, String ucParam, Integer v, String flymeuid,
String nt, String vn, String deviceinfo, String deviceType, String os, Integer supportSDK, Integer cpType)
throws Exception {
String imei = deviceinfo.substring(deviceinfo.indexOf("imei="), deviceinfo.indexOf("&"));
ArticleView params = new ArticleView();
params.setCpChannelId(cpChannelId);
params.setColumnId(columnId);
params.setUcparam(ucParam);
params.setClientReqId(System.currentTimeMillis() + imei);
CommonParams commonParams = new CommonParams();
commonParams.setV(v);
commonParams.setFlymeuid(flymeuid);
commonParams.setNt(nt);
commonParams.setVn(vn);
commonParams.setDeviceinfo(DeviceUtil.deviceToEncrypt(deviceinfo));
commonParams.setDeviceType(deviceType);
commonParams.setOs(os);
System.out.println(configService.getConfigValue(ConfigKeyEnum.UC_VIDEO_PER));
// jmock静态方法mock掉ip,防止http请求获取Ip报错
new NonStrictExpectations(WebUtils.class, configService) {
{
WebUtils.getClientIp();
result = "172.17.132.66";
}
{
// 后台控制百分比,返回0则过滤掉类型为27的视频,返回100则放开下发该视频“XXX键盘”
configService.getConfigValue(ConfigKeyEnum.UC_VIDEO_PER);
result = "100";
}
};
final ICpDataKievHandler cpDataKievHandler2 = cpDataKievHandler;
try {
String video27Articles = FileUtils
.getFileText(FileUtils.getCurrentProjectPath() + "/src/test/resources/afdata/video27Articles.json");
final CpDataResult value = JSON.parseObject(video27Articles, CpDataResult.class);
cpDataKievHandler = new MockUp<ICpDataKievHandler>() {
@mockit.Mock
CpDataResult getUCArticleList(String imei, long channelId, String method, String recoid, long ftime,
String cityCode, String cityName, int pageSize) {
return value;
}
}.getMockInstance();
ReflectionTestUtils.setField(indexArticlesDao, "cpDataKievHandler", cpDataKievHandler);
System.out.println(JSON
.toJSON(columnArticlesController.getColumnArticleList(params, supportSDK, cpType, commonParams)));
} finally {
//mock完还原接口方法取值,避免影响其他用例
ReflectionTestUtils.setField(indexArticlesDao, "cpDataKievHandler", cpDataKievHandler2);
}
}
Mockito
Mockito区别于其他模拟框架的地方允许开发者在没有建立“预期”时验证被测系统的行为,编码设计简洁优美,使用简单快捷,成本低。同时Mockito提供@Spy注解实例,这个注解是将实例对象的指定方法返回值给stub掉,而不是将方法内部处理逻辑给跳过。注意,@Spy监视的是一个真实对象。@Spy录制期望,调用真实的方法,这个对我们测试来说很重要,因为这样我们才能保证对stub方法输入的合理性,对stub方法内部调用正确性,Mockito的@Mock注解包括前的JMockit对一个对象的Mock,都是直接跳过调用真实方法而返回录制期望值,如果没录制则返回null,而@Spy对未stub的方法,返回真实的调用逻辑值。
Mockito的缺点是不能stub静态方法、final类、构造函数、匿名类,所以最好配合Jmockit使用。
学习参考 Mockito 初探
- 允许开发者在没有建立“预期”时验证被测系统的行为,如下实例不建立期望,只验证交互
// 模拟的创建,对接口进行模拟
List mockedList = mock(List.class);
// 使用模拟对象
mockedList.add("one");
mockedList.clear();
// 选择性地和显式地验证
verify(mockedList).add("one");
verify(mockedList).clear();
- 与spring组合的简单示例:
public class SearchControllerTest extends BaseContorllerMockTest {
private MockMvc mockMvc;
private static final Logger ILOG = LoggerFactory.getLogger(SearchControllerTest.class);
@Autowired
private IRedisClient redisClient;
@Spy
@Autowired
private SearchService searchService;
@InjectMocks
@Autowired
private SearchController searchController;
@BeforeMethod()
public void setUp() throws Exception {
mockMvc = MockMvcBuilders.standaloneSetup(searchController).build();
MockitoAnnotations.initMocks(this);
}
@Test
public void testGetHotWords(){
Mockito.when(searchService.getHotWords(Mockito.anyInt(), Mockito.anyInt())).thenReturn(Arrays.asList("周杰伦","林俊杰"));
System.out.println(JSON.toJSON(searchController.getHotWords(0, 30)));//输出{"value":{"words":["周杰伦","林俊杰"]},"message":"","redirect":"","code":200}
}
}
MockMvc
相信眼尖的你通过上面的示例发现了MockMvc,参考学习 SpringMVC 测试 mockMVC
为什么使用MockMvc呢?
从学习参考示例看MockMvc URL调用是不是很贴近接口自动化,MockMvc让我们能测试完整的Spring MVC流程。我们前面的mock示例中直接调用controller层方法要自行构建参数,得到的函数方法结果要经过fastjson进行转换才是是最终下发给客户端的结果,这中间其实绕过了spring mvc拦截器和转换器,通过MockMvc就跟模拟接口请求一样,请求经过拦截器验证、参数自行绑定与转换等。
MockMvc提供诸如MockHttpServletRequest、MockHttpServletResponse、MockHttpSession重量级对象mock,分别对应HttpServletRequest、HttpServletResponse、HttpSession。
下面示例restful结果通过MockHttpServletResponse输出,即是返回给客户端的最终结果。
@Test(description = "头条get.do接口,通过模拟请求链接")
public void testGetMethodThroughMockRequestUrl() throws Exception {
MvcResult result = mockMvc
.perform(get("/android/unauth/settings/get.do").param("v", "3021000").param("flymeuid", "113516747")
.param("nt", "wifi").param("deviceType", "mx5").param("os", "5.1-1505319080_stable")
.param("vn", "3.21.0").param("deviceinfo",
"v6FBm9zBUDEtahUN942%2Fyg9SrkQPmTvaFwvgfujjfk%2BxjcNQL0fr1Knx9TMeqzZVAQVBqkdzfe9b9ZM8P2p%2BucjGohlhGn0MvEKrSJ1XbUYOEBTUJG%2Bjvvf1c2v0qXhfqkx37mT%2Ffii1KgiQ6zGNhOLjjN9QxC1Lsx2D6jDPqcQ%3D"))
.andReturn();
MockHttpServletResponse mockHttpServletResponse = result.getResponse();
String s = mockHttpServletResponse.getContentAsString();
System.out.println(s);
}
纸上得来终觉浅,觉知此事要躬行,在实践过程中总会发现很多跟网上教案冲突的地方,这时候就要多尝试多思考多验证。这里只介绍了单元测试的冰山一角,单元测试还有PowerMock、DbUnit等。以上是个人拙见,如有不对的地方欢迎大家指正。