最近编写单元测试用例的一些总结。在一个web项目内,简单划分可以把单元测试编写化为四个常见的层次。分别为Controller层、Service层、DAO层及API层(调用restful API服务)。其实真正编写单元测试时会发现和编写业务代码一样,单元测试编写最终也应当是和具体的业务结合起来的。UT的每个测试方法都体现了对应用某一层次代码在给定输入下的期望,可能是对于参数边界的校验期望,可能是对远程服务调用异常处理的期望,故以下只是归纳出各个层次下编写一般UT示例所用到的方法,总而言之,“具体问题具体分析”。
Controller
作为MVC架构内的控制层,这一层主要为各对外暴露的数据接口。因此可以关注到在接口输入参数边界内,边界上,及边界外时的接口响应。
@RunWith(SpringRunner.class)
@WebMvcTest(HelloWorldController.class)
public class HelloWordControllerTests {
@MockBean
private HelloWorldService helloWorldService;
@Autowired
private Mockmvc mockMvc;
@Test
public void sayHelloRightInput() throws Exception {
// mock HelloWorldController的依赖HelloWorldService的sayHello方法
BDDMockito.given(helloWorldService.sayHello("Tony")).willReturn("{\"content\"} : \"Hello,Tony!\"");
// 按照接口规范传参,此处验证了响应的一致性
mockMvc.perform(get("/hello").param("name", "Tony"))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON_UTF8))
.andExpect(content().json("{\"content\"} : \"Hello,Tony!\""));
}
}
Service
作为体现业务逻辑的层次,Service层内编写UT主要验证在MOCK出指定条件下方法所给出的结果亦或是异常处理是否满足预期的业务逻辑处理。
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE, classes = HelloWorldService.class)
public class HelloWorldServiceTests {
@Autowired
private HelloWorldService helloWorldService;
@MockBean
private HelloWorldDao helloWorldDao;
@Test
public void sayHelloByName() throws IOException, JSONException {
// mock HelloWorldDao在任何输入下给出用户不存在的响应
BDDMockito.given(helloWorldDao.existUser(anyString())).willReturn(true);
// 在当前条件下调用sayHello()方法
String response = helloWorldService.sayHello("Tony");
// 断言响应"判断用户不存在时不输出hello"
JSONAssert.assertEquals("\"content\" : \"\"", response, true);
}
}
注: 当断言较为复杂或方法名无法直接体现断言含义时,最好将断言逻辑封装出一个方法名直接体验断言意图的方法。
DAO
DAO层为数据操作的层次,模块内多是操作数据库的方法,因此涉及到外部的依赖DB。故此层次为了DB数据的完整稳定和UT执行高效性及稳定性,DB也应当MOCK,因为连接的是真实的外部DB时UT的执行便存在着一个网络连接的外部因素,换句话说UT执行是否成功还受到网络因素的影响,这显然时不符合UT编写稳定性的原则。
较为常见的是拿内存数据库MOCK真实数据库,常用的为H2,可用于MOCK关系型的SQL DB,如MYSQL;假如是MOCK文档型的NOSQL DB,例如MONGODB,Spring有提供该DB MOCK的依赖,这里以H2作为例子展示MOCK过程。
首先引入H2 DB的依赖,并指明依赖的生命周期。
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
声明需要用到的Bean,这里以应用内连接的是MYSQL,再加上使用了Hikari连接池管理为例。另外,为了保证每个UT用例执行时面对是同一个初始化的DB环境,每执行完一个UT用例后应当做事务的回滚,即保证UT间的执行互不干扰。因此还需要注入一个事务管理器。
@TestConfiguration
public class DataSourceConfig {
/**
* 替换数据源及连接池
**/
@Bean
@Primary
public DataSource dataSource() {
EmbeddedDatabase h2Database = new EmbeddedDatabaseBuilder()
.generateUniqueName(true).setType(H2) // 选择DB类型
.setScriptEncoding("UTF-8")
.ignoreFailedDrops(true)
.addScript("init_user.sql") // 指定运行的DB初始化SQL脚本
.build();
// 配置数据连接池,应当与真实配置保持一致,以提供DAO操作环境的一致性
HikariConfig hikariConfig = new HikariConfig();
hikariConfig.setDataSource(h2Database);
hikariConfig.setMaximumPoolSize(30);
hikariConfig.setMaxLifetime(1765000);
hikariConfig.setMinimumIdle(5);
return new HikariDataSource(hikariConfig);
}
/**
* 提供JDBCTemplate用于断言校验
**/
@Bean
@Primary
public JdbcTemplate jdbcTemplate(DataSource dataSource) {
return new JdbcTemplate(dataSource);
}
/**
* 给对应的DAO注入使用的JDBCTemplate
**/
@Bean
@Primary
public HelloWorldDao helloWorldDao(JdbcTemplate jdbcTemplate) {
return new helloWorldDao(jdbcTemplate);
}
/**
* 通过数据源构造出数据源管理器
**/
@Bean
@Primary
public DataSourceTransactionManager dataSourceTransactionManager(DataSource dataSource) {
return new DataSourceTransactionManager(dataSource);
}
}
注入需要使用的Bean,编写UT。
@RunWith(SpringRunner.class)
@ContextConfiguration(classes = DataSourceConfig.class)
@Transactional
public class UserDaoTests {
@Autowired
private UserDao UserDao;
@Autowired
private JdbcTemplate jdbcTemplate;
/**
* 验证user表的插入操作
*/
@Rollback
@Test
public void insertUser() {
// 获取断言前的uer表的行数
int initCount = countRowsInTable("user");
User user = new User("Tony", 18);
//插入数据并验证是否行数符合+1的预期
userDao.insert(user);
assertNumUser(initCount + 1);
}
private int countRowsInTable(String tableName) {
return JdbcTestUtils.countRowsInTable(this.jdbcTemplate, tableName);
}
private void assertNumUser(int expected) {
assertEquals("Number of rows in the [user] table", expected, countRowsInTable("user"));
}
}
API
API层为调用外部web服务的集合方法,例如微信公众号应用中与微信服务器的交互API,该层次主要为Mock出MockRestServiceServer的API服务调用规范,并验证API类内调用是否符合规范,包括方法POST或者GET、Basic Auth认证,或固定的header插入等。
@RunWith(SpringRunner.class)
@RestClientTest(OrderApi.class)
public class OrderApiTests {
@Autowired
private RestTemplate restTemplate;
@Autowired
private OrderApi orderApi;
/**
* 构造MockRestServiceServer
**/
@Before
public void prepareServer() throws IOException {
mockServer = MockRestServiceServer.createServer(restTemplate);
}
/**
* 验证API服务是否规范调用
* @throws JSONException
*/
@Test
public void recognizeSuccess() throws JSONException {
// MOCK API SERVER,验证orderApi方法是否按预期调用了服务,包括方法POST,固定的header等预期。
mockServer.expect(requestTo("http://shop.example.com/order"))
.andExpect(method(POST))
.andExpect(content().contentType(APPLICATION_FORM_URLENCODED))
.andRespond(withSuccess(EXPECTED_RESPONSE, APPLICATION_JSON_UTF8));
Order responseOrder = orderApi.addOrder(new Order("mobile phone", 2, 80));
// 验证响应一致性
JSONAssert.assertEquals(EXPECTED_RESPONSE, new ObjectMapper().writeValueAsString(responseOrder), true);
// 验证API是否有被调用
mockServer.verify();
}
总结
编写UT应该是与应用内具体业务结合的。假如应用的业务逻辑本身就很薄,其实UT的断言也就相应比较简单,但编写的一般思路还是一致的,即准备
——>执行
——>断言
,上述的测试方法也是遵循了这个基本思路,展示了每个层次上编写UT使用的一般方法。另外,UT用例的可读性也是很重要,有助于团队协作和DEBUG调试。