「内容简介」测试过程中,对那些不容易构建的对象用一个虚拟对象来代替测试的方法就叫mock测试,今天来了解一下如何在Spring Boot中基于JUnit和Mockito进行单元测试。
提前创建测试; TDD(测试驱动开发)
如果你创建了一个Mock那么你就可以在service接口创建之前写Service Tests了,这样你就能在开发过程中把测试添加到你的自动化测试环境中了。换句话说,模拟使你能够使用测试驱动开发。
团队可以并行工作
这类似于上面的那点;为不存在的代码创建测试。但前面讲的是开发人员编写测试程序,这里说的是测试团队来创建。当还没有任何东西要测的时候测试团队如何来创建测试呢?模拟并针对模拟测试!这意味着当service借口需要测试时,实际上QA团队已经有了一套完整的测试组件;没有出现一个团队等待另一个团队完成的情况。这使得模拟的效益型尤为突出了。
你可以创建一个验证或者演示程序。
为无法访问的资源编写测试
这个好处不属于实际效益的一种,而是作为一个必要时的“救生圈”。有没有遇到这样的情况?当你想要测试一个service接口,但service需要经过防火墙访问,防火墙不能为你打开或者你需要认证才能访问。遇到这样情况时,你可以在你能访问的地方使用MockService替代,这就是一个“救生圈”功能。
Mock 可以分发给用户
隔离系统
知道什么是mock测试后,那么我们就来认识一下mock框架---Mockito。
Mockito区别于其他模拟框架的地方主要是允许开发者在没有建立“预期”时验证被测系统的行为。
Maven依赖:
Xml代码
org.mockito mockito-all 1.8.5 test
首先,需要在@Before注解的setUp()中进行初始化(下面这个是个测试类的基类)
Java代码
public abstract class MockitoBasedTest { @Before public void setUp() throws Exception { // 初始化测试用例类中由Mockito的注解标注的所有模拟对象 MockitoAnnotations.initMocks(this); } }
Java代码
import static org.mockito.Mockito.*; import java.util.List; import org.junit.Assert; import org.junit.Test; public class SimpleTest { @Test public void simpleTest(){ //创建mock对象,参数可以是类,也可以是接口 Listlist = mock(List.class); //设置方法的预期返回值 when(list.get(0)).thenReturn("helloworld"); String result = list.get(0); //验证方法调用(是否调用了get(0)) verify(list).get(0); //junit测试 Assert.assertEquals("helloworld", result); } }
创建mock对象不能对final,Anonymous ,primitive类进行mock。
可对方法设定返回异常
Java代码
when(list.get(1)).thenThrow(new RuntimeException("test excpetion"));
stubbing另一种语法(设置预期值的方法),可读性不如前者
Java代码
doReturn("secondhello").when(list).get(1);
没有返回值的void方法与其设定(支持迭代风格,第一次调用donothing,第二次dothrow抛出runtime异常)
Java代码
doNothing().doThrow(new RuntimeException("void exception")).when(list).clear(); list.clear(); list.clear(); verify(list,times(2)).clear();
参数匹配器(Argument Matcher)
Matchers类内加你有很多参数匹配器 anyInt、anyString、anyMap.....Mockito类继承于Matchers,Stubbing时使用内建参数匹配器,下例:
Java代码
@Test public void argumentMatcherTest(){ Listlist = mock(List.class); when(list.get(anyInt())).thenReturn("hello","world"); String result = list.get(0)+list.get(1); verify(list,times(2)).get(anyInt()); Assert.assertEquals("helloworld", result); }
需要注意的是:如果使用参数匹配器,那么所有的参数都要使用参数匹配器,不管是stubbing还是verify的时候都一样。
在众多的Java覆盖率测试工具中,开源的Emma是最著名的一个,而EclEmma相当于是它在Eclipse上的图形化界面插件。它使用简单,结果直观。
安装很简单,打开Eclipse,点击Help → Install New Software →输入update.eclemma.org,安装软件即可。
首先,我们可以建立一个HelloWorld类,然后通过Coverage来运行它。
执行完毕之后,我们正在编辑 HelloWorld.java 的窗口将会变成如下所示:
EclEmma 用不同的色彩标示了源代码的测试情况。其中,绿色的行表示该行代码被完整的执行,红色部分表示该行代码根本没有被执行,而黄色的行表明该行代码部分被执行。黄色的行通常出现在单行代码包含分支的情况。
EclEmma 还提供了一个单独的视图来统计程序的覆盖测试率。可以选择行覆盖(Line),分支(Branch)覆盖等多种覆盖率检测标准。
(更多资料,请参考:这里 ,这里 ,和这里 )。
首先在maven中加载以下的库,尤其是第三个。
[库] junit :4.12
[库] mockito-core :1.10.19
[库] spring-boot-starter-test :1.3.1
Pom.xml (节选)
org.springframework.boot spring-boot-starter-test junit junit 4.12 org.springframework.boot spring-boot-starter-test
接下来,如果你要用到一些Spring自带的注解,比如@Autowired
的话,最好是在测试类的基类中,加入如下注解,这样会使得测试时先将SpringBoot运行起来。
@RunWith(SpringJUnit4ClassRunner.class) @WebAppConfiguration @SpringApplicationConfiguration(classes = Application.class)
接下来需要在@Before注解的setUp()中进行初始化
@RunWith(SpringJUnit4ClassRunner.class) @WebAppConfiguration @SpringApplicationConfiguration(classes = Application.class) public abstract class MockitoBasedTest { @Before public void setUp() throws Exception { // 初始化测试用例类中由Mockito的注解标注的所有模拟对象 MockitoAnnotations.initMocks(this); } }
由于Eclipse对于import static
的支持很差,你可能还需要记得加上
import static org.junit.Assert.*; import static org.mockito.Mockito.*;
接下来我们为每个类创建测试用例,在比如Service的一个类上面右键-新建-JUnit Test Case,注意要把测试类的目录改到src/test/java
。
至于其他的部分,与上文中提到的mockito的测试步骤基本相同。至于涉及到@Autowired这种,涉及Spring框架的注解而导致测试无法顺利进行的问题,请看下一节的讲解。
测试中遇到的问题和解决办法
我们以WeChatServiceImpl
类和它的测试类为例,WeChatServiceImpl的代码不变,为了达到上图的效果,我们把测试函数中内容删掉:
@Test public void testIsOutOfTime() { ; }
因此可以看出,
100%是代表测试函数的每一行都成功执行了,比如我只输入一个分号;
但是,4.2%才代表的是被测函数的实际覆盖率。
所以不要被测试类覆盖率的100%骗了~
public class GameHelper { @Autowired private PointRepository pointRepository; public boolean checkLineItem(final Line line) { Point fromPoint = pointRepository.findById(line.getFromPointId()); //如何控制这个repository的返回? Point toPoint = pointRepository.findById(line.getToPointId()); return fromPoint.getID().equals(toPoint.getID());//简化了原函数 } ... }
因为在目前的单元测试中,Spring一个很特殊的注解是@Autowired
。(@Autowired
可以对成员变量、方法和构造函数进行标注,来完成自动装配的工作。)
如果我们要写个testCheckLineItem()函数的话,我们怎么控制fromPoint和toPoint呢?
因为不能改变被测类,因此我曾经尝试在测试类中使用过以下方法:
在测试函数中,使用new PointRepository().tostory
对pointRepository使用@Mock,@Spy,@InjectMocks等
对pointRepository加@Autowired注解,然后发现注解无效。于是在所有测试类的基类中,增加如下注解以启用@Autowired,但是依然有问题。(不过如果想使用@Autowired一类的注解,下面这个代码是必须加的。)
@RunWith(SpringJUnit4ClassRunner.class) @WebAppConfiguration @SpringApplicationConfiguration(classes = Application.class)
后来与@Autowired一起加了@Spy也不行!!
以及对@Mock,@Spy,@InjectMocks的各种使用组合...
最后,经过朋友的提示以及查找,我查到了这个问题的英文解释 和中文的解答 。
正确答案是:对被测类中@Autowired
的对象,用@Mocks
标注;对被测类自己,用@InjectMocks
标注。代码如下:
public class GameHelperTest { @Mock private PointRepository pointRepository; @InjectMocks private GameHelper gamehelper; //pointRepository作为mock对象被注入到gamehelper中,gamehelper其他成员变量不变 public void testCheckLineItem() { Line line = new Line(***); when(pointRepository.findById(123L)).thenReturn(new Point(***)); when(pointRepository.findById(456L)).thenReturn(new Point(***)); assertTrue(gamehelper.checkLineItem(line)); } ... }
至于原因,我们回到mockito的官方文档 中去看关于@InjectMocks的解释。
@InjectMocks - injects mock or spy fields into tested object automatically.
换言之,被@Mock
标注的对象会自动注入到被@InjectMocks
标注的对象中。比如在本例中,GameHelper中的成员变量pointRepository(的函数),就会被我们用在测试用例中改写过返回值的pointRepository对象替换掉。
另外,经测试,thenReturn返回的是对象引用而不是深复制了对象本身(所以可以减少写thenReturn()的次数)。
比如在CreateGameServiceImpl
这个类中,有这样一段函数
public class CreateGameServiceImpl implements CreateGameService { ...//省略成员变量 public FullGame createGame(String name, Long creatorId, Listpoints, List selections, List lines) { Game gameItem = createBlankGame(name, creatorId); //createBlankGame()为CreateGameServiceImpl中另一个函数
那么,如果我还没实现createBlackGame(),我在测试函数里应该怎么控制它呢?这次用2)中的方法@Mock + @InjectMocks
就不行了,因为他们属于同一个类。
(这个问题@Xander 觉得应该实现了被调用的函数才好,但是既然mock的存在很多时候是为了在函数都没实现的情况下编写测试,因此我觉得继续研究。)
后来自己通过查阅**官方的文档 ,解决办法**是使用spy()
命令,结合doReturn()
:
public class CreateGameServiceImplTest { //这部分不需要改。省略其他成员变量 @Mock private GameHelper gameHelper; @InjectMocks CreateGameServiceImpl serviceimpl; @Test public void testCreateGameStringLongListOfPointListOfSelectionListOfLine() { serviceimpl = spy(serviceimpl); //将serviceimpl部分mock化 doReturn(***).when(serviceimpl).createBlankGame(a, b); //这里必须用doReturn()而不能是when().thenReturn() ... } }
原因我们在最后解释。
首先我们来看文档中对于Spy()的解释:
You can create spies of real objects. When you use the spy then the methods are called (unless a method was stubbed).
Spying on real objects can be associated with "partial mocking" concept.(重点是,spy与"部分mock"相关。)
对于Spy,官方有个Sample:
List list = new LinkedList(); List spy = spy(list); //optionally, you can stub out some methods: when(spy.size()).thenReturn(100); //using the spy calls real methods spy.add("one"); spy.add("two"); //prints "one" - 这个函数还是真实的 System.out.println(spy.get(0)); //100 is printed - size()函数被替换了 System.out.println(spy.size());
通俗来讲,在我个人理解,Spy()可以使一个对象的一部分方法被用户替换。
在我们的例子中,CreateGameServiceImpl
中的函数createGame()
调用了createBlankGame()
,而后者可能是未实现的。
但是此时CreateGameServiceImpl
类的注解是@InjectMocks
而不是@Mock
,只能接收@Mock对象的注入,而自己的方法无法被mock(stub)。
因此我们通过spy()
,将CreateGameServiceImpl部分mock化,从而将createBlankGame()函数替换掉。
不过这里如果遇到private的被调函数就没办法了。
对于单元测试,一个重要的衡量指标就是覆盖率。
覆盖率分为:
行覆盖(Line Coverage,又叫段覆盖/语句覆盖(Statement Coverage)等)
分支覆盖(Branch Coverage,又叫判定覆盖Decision Coverage等)
条件覆盖(Condition Coverage)
路径覆盖(Path Coverage)等等
据了解 ,所有这些覆盖中行覆盖(Line coverage)是最简单的,也是最常用的、最有效的覆盖率。
在EclEmma中可以选择任意一种覆盖率,以下是我的项目中行覆盖率的截图。
由于单元测试常常需要用到assertEquals()
方法,而对于很多自定义的数据结构(比如Point.java)要重写equals()方法,否则调用equals()只会比较两个对象的引用,这带来非常多的麻烦事。
鉴于Web应用中使用的自定义的数据结构(Model)通常是JavaBean规范的(这些类的成员属性通常是Java的基本数据类型或String,Collection等常见类型),因此我希望通过只对比两个对象的成员变量的变量类型、变量名、变量的值(如果是集合和数组就深入进去判断),来判断两个对象是否“相等”。
我查了好久,除了发现貌似还真没人写,只有apache.commons.beanutils
和apache.commons.collections.comparator
有类似的方法,但是看了他们的源码觉得跟我要的不是一个东西。
这个工具类,因为有些细节上写起来很困难,大概写了好几个小时吧,打算以后如果真的没人做这个,就把它做成一个开源的小工具。
在这里我同时提供了两种方式,第一种比较取巧,直接比较两个对象对应的JSON字符串,这种方法很方便,不容易出错,但是可能适用范围上略小一点。
我使用的是JackJson的库(其他也可以),代码如下:
static public boolean compareByJson(Object a,Object b){ try { ObjectMapper objectMapper = new ObjectMapper(); String jsona =objectMapper.writeValueAsString(a); String jsonb =objectMapper.writeValueAsString(b); System.out.println(jsona); System.out.println(jsonb); return jsona.equals(jsonb); } catch (IOException e) { e.printStackTrace(); return false; } }
第二种,则是通过Java的反射机制,通过getClass(),getDeclaredFields(),setAccessible(true)
等方法来取得任意对象的成员变量,按顺序分析两者中的两个变量的变量名、变量类型、变量值是否相等,是否是重写了equals()方法的常见类型、是否是集合等方面来比较和判断,也借用了LeetCode上一道难度很低的题 的算法,代码如下(某处仍有bug):
static public boolean compare(Object obj_a, Object obj_b) { if(obj_a == null && obj_b == null) return true; else if (obj_a == null || obj_b == null) return false; else { Field[] fields_a = obj_a.getClass().getDeclaredFields(); Field[] fields_b = obj_b.getClass().getDeclaredFields(); if (fields_a.length != fields_b.length) return false; else for (int i = 0; i < fields_a.length; i++) { fields_a[i].setAccessible(true); fields_b[i].setAccessible(true); Object obj_a_innerobj_i = null, obj_b_innerobj_i = null; try { obj_a_innerobj_i = fields_a[i].get(obj_a); obj_b_innerobj_i = fields_b[i].get(obj_b); } catch (IllegalArgumentException e) { e.printStackTrace(); } catch (IllegalAccessException e) { e.printStackTrace(); } if (fields_a[i].getName() != fields_b[i].getName()) return false; else if (!fields_a[i].getGenericType().equals(fields_b[i].getGenericType())) return false; else if (!SupportedClassesList.contains(obj_a_innerobj_i.getClass())) if (compare(obj_a_innerobj_i, obj_b_innerobj_i) == false) return false; else if (obj_a_innerobj_i instanceof Collection){ //TODO 仍有bug if(!(((Collection) obj_a_innerobj_i).containsAll((Collection)(obj_b_innerobj_i))&&((Collection)obj_b_innerobj_i).containsAll((Collection)(obj_a_innerobj_i)))) return false; else if (!obj_a_innerobj_i.equals(obj_b_innerobj_i)) return false; } } return true; }