基于Spring Test和Mockito进行单元测试
本文引入一个简单的银行业务场景,用来阐述如何集成Spring Test、Junit、Mockito,以简化单元测试工作。该场景主要的业务代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
/** * 人员 */ public class Person { private int id; private String name; private Account defaultAccount; } /** * 账户 */ public class Account { private int id; private int balance; private Person person; } /** * 人员服务接口 * */ public interface PersonService { /** * 查询人员用户 */ Person getPerson( int id ); /** * 得到人员默认账户 */ Account getDefaultAccount( Person p ); } /** * 账户服务接口 * */ public interface AccountService { /** * 查询人员默认账户余额 */ int queryBalanceOfDefaultAccount( int personId ); } |
假设你已经实现了服务AccountService:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
@Service ( "accountService" ) public class AccountServiceImpl implements AccountService { private Map<Integer, Object[]> accountDatabase; @Inject private PersonService personService; @PostConstruct public void init() { accountDatabase = new HashMap<Integer, Object[]>(); //字段:账号,余额 accountDatabase.put( 100, new Object[] { "6225100", 68861 } ); accountDatabase.put( 101, new Object[] { "6225101", 1851 } ); accountDatabase.put( 102, new Object[] { "6225102", 845 } ); accountDatabase.put( 103, new Object[] { "6225103", 16598 } ); } @Override public int queryBalanceOfDefaultAccount( int personId ) { Person person = personService.getPerson( personId ); Account defaultAccount = person.getDefaultAccount(); return (Integer) accountDatabase.get( defaultAccount.getId() )[1]; } } |
而你的搭档负责的PersonService还没有开发完毕,如何方便的进行单元测试呢?
你可能会觉得,我们不需要在单元测试中引入Spring。对于上面的例子的确可以这么说,它太简单了,AccountServiceImpl 依赖的PersonService完全可以通过setter手工注入。但是实际的开发场景要比这个例子复杂的多,待测试类可能和Spring管理的Beans存在很多关联,它可能依赖于Spring提供的数据源、事务管理器,等等。这些Bean如果都手工管理,将是相当繁琐无味的工作。
使用JUnit 4.x提供的注解 @RunWith ,可以指定单元测试的“运行类”,运行类必须继承自 org.junit.runner.Runner 并实现 run 方法。Spring Test框架提供的运行类是 SpringJUnit4ClassRunner ,使用该类可以轻松的将Spring和JUnit进行集成。该类的用法示例如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
@RunWith ( SpringJUnit4ClassRunner.class ) //指定单元测试运行类 @ContextConfiguration ( locations = { "applicationContext.xml" } ) //指定Spring配置文件的位置 //很多情况下单元测试离不开事务,下面的注解指明使用的事务管理器 //如果defaultRollback为true,测试运行结束后,默认回滚事务,不影响数据库 @TransactionConfiguration ( transactionManager = "txManager", defaultRollback = true ) @Transactional //指定默认所有测试方法的事务特性 public class AccountServiceTest { @Inject private SpringManagedBean bean; //任何Spring管理的Bean都可以注入到单元测试类 @BeforeClass public static void setUpBeforeClass() throws Exception { } @AfterClass public static void tearDownAfterClass() throws Exception { } @Before public void setUp() throws Exception { } @After public void tearDown() throws Exception { } @Repeat ( 10 )//重复测试10次 //该测试期望抛出IllegalArgumentException,测试超时1秒 @Test ( expected = IllegalArgumentException.class, timeout = 1000 ) @Rollback ( true ) //测试完毕后回滚 public void test() { } } |
依据上一节的知识,我们编写集成Spring Test的测试用例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
import static org.junit.Assert.*; @RunWith ( SpringJUnit4ClassRunner.class ) @ContextConfiguration ( locations = { "/applicationContext.xml" } ) public class AccountServiceTest { @Inject private AccountService accountService; @Test public void test() { assertEquals( 68861, accountService.queryBalanceOfDefaultAccount( 100 ) ); } } |
引入Spring后,运行单元测试AccountServiceTest,会得到一个NoSuchBeanDefinitionException,这是因为AccountServiceImpl依赖的PersonService没有在Spring中注册。前面我们提到过,PersonService由搭档开发且尚未完成,这个时候要想单独测试AccountServiceImpl,那么就需要开发一个模拟的PersonService。最直接的模拟就是实现PersonService接口,但是不方便、工作量大,因此我们引入Mock框架:Mockito。
本文不去讨论Mockito的API细节,有兴趣的同学可以参考:使用Mockito进行单元测试
可以参考如下方式,单独将Mocketo和JUnit集成:
1 2 3 4 5 6 7 8 9 10 |
@RunWith ( MockitoJUnitRunner.class ) //运行类 public class AccountServiceTest { //AccountService所依赖的其它对象,会使用Mock注入,因此它引用的PersonService将是一个Mock @InjectMocks private AccountService accountService = new AccountServiceImpl(); //自动生成一个PersonService的Mock实现 @Mock private PersonService personService; } |
下面的代码则示例了如何把Spring也集成进来:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
@RunWith ( SpringJUnit4ClassRunner.class ) //使用Spring提供的运行类 @ContextConfiguration ( locations = { "/applicationContext.xml" } ) public class AccountServiceTest { @InjectMocks //该字段依赖的其它对象(PersonService),将使用仿冒注入 @Inject //提示该字段本身由Spring自动注入 private AccountService accountService; @Mock //由Mockito仿冒 private PersonService personService; @Before public void setUp() { //使得Mockito的注解生效 MockitoAnnotations.initMocks( this ); } @Test public void test() { //这里断点可以看到accountService.personService的类型是: //PersonService$$EnhancerByMockitoWithCGLIB$$61056d67 //这是Mockito生成的仿冒类 assertEquals( 68861, accountService.queryBalanceOfDefaultAccount( 100 ) ); } } |
注意:上面的集成并没有解决AccountServiceImpl对PersonService的依赖性,NoSuchBeanDefinitionException还会出现,除非使用Spring提供的“可选”依赖注入:
1 2 |
@Autowired ( required = false ) private PersonService personService; |
但这种变通方式改变了应用语义,不应该使用。因此,到目前为止我们只能做到:在单元测试中用仿冒代替一个既有的Bean。
Springockito是针对Spring的一个小扩展,它可以简化Mockito仿冒的创建和管理,让Spring与之更无缝的集成:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
<?xml version="1.0" encoding="UTF-8"?> <beans xmlns="http://www.springframework.org/schema/beans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:context="http://www.springframework.org/schema/context" xmlns:mockito="http://www.mockito.org/spring/mockito" xsi:schemaLocation=" http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-3.2.xsd http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context-3.2.xsd http://www.mockito.org/spring/mockito https://cdn.gmem.cc/schema/spring-mockito.xsd "> <!-- 创建一个受Spring管理的PersonService仿冒,其它Bean很自然的可以获得注入 --> <mockito:mock id="personService" class="cc.gmem.study.sam.service.PersonService" /> <!-- 可以监控(Spying)一个Bean,但不影响它的任何行为,注意beanName必须与@Service指定的名称一致 --> <mockito:spy beanName="accountService" /> </beans> |
现在可以把PersonService作为一个普通的Spring管理的Bean来看待,下面是最终的测试用例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
@Inject private AccountService accountService; @Inject private PersonService personService; //注入mock,就像注入普通Bean一样 @Test public void test() { int id = 0; Person alex = new Person( id, "Alex", new Account( 100 ) ); //对作为Bean的Mock进行打桩,设定后续方法调用的行为,就像为普通Mock打桩一样 when( personService.getPerson( 0 ) ).thenReturn( alex ); //验证结果 assertEquals( 68861, accountService.queryBalanceOfDefaultAccount( id ) ); //验证queryBalanceOfDefaultAccount方法被调用了一次 verify( accountService ).queryBalanceOfDefaultAccount( id ); //Mockito要求verify的入参必须是Mock,Springockito解除了这一限制 } |
我们使用了PersonService的Mock,而不要求它已经被实现、注册到Spring; 同时,我们可以对既有的Bean进行监控,而不要求它是一个Mock。
Springockito也提供了与XML配置等价的注解方式:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
@RunWith ( SpringJUnit4ClassRunner.class ) //注意:必须修改loader为SpringockitoContextLoader.class @ContextConfiguration ( loader = SpringockitoContextLoader.class, locations = { "/applicationContext.xml" } ) public class AccountServiceTest { @WrapWithSpy //mockito:spy @Inject private AccountService accountService; @ReplaceWithMock ( beanName = "personService" ) //mockito:mock @Inject private PersonService personService; } |
Spring 3.2的Test子项目提供了类MockMvc,调用其 perform() 方法,可以触发一次“请求”,该调用会返回一个 ResultActions 接口。可以针对ResultActions执行一系列的动作和断言,或者返回处理结果 MvcResult ,该接口提供以下方法:
方法 | 说明 | ||
andExpect(ResultMatcher) | 执行期望(断言),一般会配合静态导入:MockMvcRequestBuilders.*、MockMvcResultMatchers.*使用。举例:
|
||
andDo(ResultHandler) | 执行一个动作,一般会配合静态导入:MockMvcResultHandlers.*。举例:
|
||
MvcResult andReturn() | 得到Mvc处理结果,从中可以HttpServletRequest、HttpServletResponse、MVC处理器、处理器抛出的异常、HandlerInterceptor、ModelAndView等内容 |
我们开发一个简单的Controller类,它能够接收查询默认账户余额的请求,并返回一个包含人员、余额信息的映射:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
@Controller ( "accountController" ) @RequestMapping ( "/account" ) public class AccountController { @Inject private AccountService accountService; @RequestMapping ( "/{personId}/defacct/balance" ) @ResponseBody public Map<String, Integer> queryBalanceOfDefaultAccount( @PathVariable int personId, @RequestParam long timestamp ) { int balance = accountService.queryBalanceOfDefaultAccount( personId ); Map<String, Integer> ret = new LinkedHashMap<String, Integer>(); ret.put( "personId", personId ); ret.put( "balance", balance ); return ret; } } |
假设我们的客户端需要JSON格式的数据,我们可以利用MockMvc来模拟客户端并验证。下面是一个简单的示例(包含JUnit测试类和Spring配置文件):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
<?xml version="1.0" encoding="UTF-8"?> <beans xmlns="http://www.springframework.org/schema/beans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:context="http://www.springframework.org/schema/context" xmlns:mockito="http://www.mockito.org/spring/mockito" xsi:schemaLocation=" http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-3.2.xsd http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context-3.2.xsd http://www.mockito.org/spring/mockito https://cdn.gmem.cc/schema/spring-mockito.xsd "> <context:annotation-config /> <!-- 该接口尚未实现,必须仿冒 --> <mockito:mock id="personService" class="cc.gmem.study.sam.service.PersonService" /> <!-- 该接口虽已实现,但是为了隔离依赖单独测试MVC部分,我们这里使用仿冒 --> <mockito:mock id="accountService" class="cc.gmem.study.sam.service.AccountService" /> </beans> |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
<?xml version="1.0" encoding="UTF-8"?> <beans xmlns="http://www.springframework.org/schema/beans" xmlns:mvc="http://www.springframework.org/schema/mvc" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:context="http://www.springframework.org/schema/context" xmlns:mockito="http://www.mockito.org/spring/mockito" xsi:schemaLocation=" http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context-3.2.xsd http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-3.2.xsd http://www.springframework.org/schema/mvc http://www.springframework.org/schema/mvc/spring-mvc-3.2.xsd http://www.mockito.org/spring/mockito https://cdn.gmem.cc/schema/spring-mockito.xsd "> <!-- 我们需要监控此Bean被调用的情况 --> <mockito:spy beanName="accountController" /> <context:component-scan base-package="cc.gmem.study.sam.ctrl" /> <mvc:annotation-driven /> <bean id="viewResolver" class="org.springframework.web.servlet.view.UrlBasedViewResolver"> <property name="viewClass" value="org.springframework.web.servlet.view.JstlView" /> <property name="prefix" value="/WEB-INF/jsp/" /> <property name="suffix" value=".jsp" /> <property name="order" value="0" /> </bean> </beans> |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 |
import static org.junit.Assert.*; import static org.mockito.Mockito.*; import static org.springframework.http.MediaType.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @RunWith ( SpringJUnit4ClassRunner.class ) //提示该测试使用WebApplicationContext,需要3.2版本 //该注解的value用来说明不带Spring前缀的Resource(例如classpath:)的寻找路径 @WebAppConfiguration ( "src/main/webapp" ) //定义WebApplicationContext的层次,需要3.2.2版本 @ContextHierarchy ( { //这里目前不能使用load:SpringockitoContextLoader,可能Springockito注解尚不兼容3.2.2 //因此我们使用XML方式配置Springockito //父上下文 @ContextConfiguration ( locations = "classpath:applicationContext.xml" ), //子上下文 @ContextConfiguration ( locations = "classpath:applicationContext-mvc.xml" ) } ) public class AccountControllerTest { private MockMvc mockMvc; //需要3.2版本 @Inject private WebApplicationContext wac; //最低层次的上下文被注入 @Inject private AccountController accountController; //被测试类 @Inject private AccountService accountService; @Before public void setUp() throws Exception { //初始化MockMvc mockMvc = MockMvcBuilders.webAppContextSetup( wac ).build(); } @Test public void test() throws Exception { int personId = 0; int balance = 1000; long timestamp = System.currentTimeMillis(); /*仿冒打桩*/ when(accountService.queryBalanceOfDefaultAccount( personId )).thenReturn( balance); /*HTTP请求模拟以及结果验证*/ mockMvc.perform( get( "/account/{personId}/defacct/balance", personId ) .accept( APPLICATION_JSON ) //请求返回JSON格式的响应 //设置请求头 .header( "JSESSIONID", new Object[]{"aue60a2p2m8fe5s0t2m1am78t4"} ) //设置请求参数 .param( "timestamp", String.valueOf( timestamp ) ) ) .andExpect( status().isOk() ) .andExpect( content().contentTypeCompatibleWith( APPLICATION_JSON ) ) //期望返回JSON格式的响应 .andExpect( jsonPath( "$.balance" ).value( balance ) ) //JSONPath验证 .andDo( print() ); //打印请求、响应和处理过程的详细信息,以便核查 /*Mockito验证*/ //验证accountController恰好被调用了一次 verify( accountController ).queryBalanceOfDefaultAccount( personId, timestamp ); //验证AccountService至少被调用了一次 verify( accountService,atLeast( 1 ) ).queryBalanceOfDefaultAccount( personId ); /*Junit验证*/ assertTrue( true ); } } |
可以看到,我们在测试用例中基于MockMvc提供的丰富API,来构建仿冒的请求,并验证Spring MVC的响应。同时,我们使用Mockito来隔离AccountService服务,简化了依赖管理。
本文使用的Maven POM依赖配置如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 |
<dependencies> <dependency> <groupId>javax.annotation</groupId> <artifactId>jsr250-api</artifactId> <version>1.0</version> </dependency> <dependency> <groupId>javax.inject</groupId> <artifactId>javax.inject</artifactId> <version>1</version> </dependency> <dependency> <groupId>javax.servlet</groupId> <artifactId>servlet-api</artifactId> <version>2.4</version> <scope>provided</scope> </dependency> <dependency> <groupId>javax.servlet.jsp</groupId> <artifactId>jsp-api</artifactId> <version>2.0</version> <scope>provided</scope> </dependency> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency> <dependency> <groupId>taglibs</groupId> <artifactId>standard</artifactId> <version>1.1.2</version> <scope>provided</scope> </dependency> <dependency> <groupId>javax.el</groupId> <artifactId>el-api</artifactId> <version>2.2</version> <scope>provided</scope> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-core</artifactId> <version>3.2.3.RELEASE</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-jdbc</artifactId> <version>3.2.3.RELEASE</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-beans</artifactId> <version>3.2.3.RELEASE</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-orm</artifactId> <version>3.2.3.RELEASE</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>3.2.3.RELEASE</version> </dependency> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-core</artifactId> <version>2.0.4</version> </dependency> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.0.4</version> </dependency> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-annotations</artifactId> <version>2.0.4</version> </dependency> <dependency> <groupId>log4j</groupId> <artifactId>log4j</artifactId> <version>1.2.15</version> </dependency> <dependency> <groupId>org.slf4j</groupId> <artifactId>slf4j-log4j12</artifactId> <version>1.6.1</version> </dependency> <dependency> <groupId>junit</groupId> <artifactId>junit</artifactId> <version>4.11</version> <scope>test</scope> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-test</artifactId> <version>3.2.3.RELEASE</version> <scope>test</scope> </dependency> <dependency> <groupId>org.mockito</groupId> <artifactId>mockito-all</artifactId> <version>1.9.0</version> <scope>test</scope> </dependency> <dependency> <groupId>org.kubek2k</groupId> <artifactId>springockito</artifactId> <version>1.0.9</version> <scope>test</scope> </dependency> <dependency> <groupId>org.kubek2k</groupId> <artifactId>springockito-annotations</artifactId> <version>1.0.9</version> <scope>test</scope> </dependency> <dependency> <groupId>com.jayway.jsonpath</groupId> <artifactId>json-path-assert</artifactId> <version>0.9.1</version> <scope>test</scope> </dependency> </dependencies> |
写得非常好,很详尽。给我很大的帮助! 十分感谢!
客气了~~共同进步
@ReplaceWithMock ( beanName = "personService" ) //mockito:mock
@Inject
private PersonService personService;
请问楼主,这个不报错么? personService 没在xml 定义也没有实现类用 service 注解的情况下?我这边直接就报错不编译了
刚试了一下,以下代码不报错:
但是我使用IntelliJ IDEA作为开发工具,此工具的Inspection功能会报Could not autowire. No beans of 'PersonService' type found。这个对运行没有影响。
另外,Springockito目前不怎么维护了。建议使用Spring Boot,可以零XML配置的注入Mock到单元测试用例,可以参考:
https://docs.spring.io/spring-boot/docs/current/reference/html/boot-features-testing.html#boot-features-testing-spring-boot-applications-mocking-beans
谢了,我之前是把这个@RunWith( SpringJUnit4ClassRunner.class )加在测试类的父类上了,加在测试类上就行了