作者:苏国庆
单元测试是指对软件中的最小可测试单元进行检查和验证。单元在质量保证中是非常重要的环节,根据测试金字塔原理,越往上层的测试,所需的测试投入比例越大,效果也越差,而单元测试的成本要小的多,也更容易发现问题。
以有赞中台某应用为例,应用部署是微服务架构,对外提供 dubbo 服务,当前的单元测试,采用了分层测试框架,根据代码的分层,分为 Service 层测试,Biz 层测试,外部服务访问层测试,DAO 测试,Redis 访问层测试,每一层均使用 mock 框架屏蔽下层的具体实现。
单元测试的编写,主要包含以下几个阶段:
<?xml version='1.0' encoding='UTF-8'?>
<dataset>
<employee employee_uid='1'
start_date='2001-11-01'
first_name='Andrew'
ssn='xxx-xx-xxxx'
last_name='Glover' />
</dataset>
12345678
其中 employee 是要构造数据的表名,后面的键值对是列名及对应的值,需要注意的是,第一行必须包含完整的字段名,否则加载的数据中全部会缺失某些字段。
非常适合在测试程序中使用,程序关闭时自动清理数据,H2 数据库的表结构初始化是通过 jdbc:initialize-database
标签实现的,单元测试中使用 h2 数据库非常简单,仅需修改 jdbc 连接即可。 引入依赖:
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>1.4.191</version>
<scope>test</scope>
</dependency>
123456
数据源连接:
spring.datasource.url=jdbc:h2:mem:test
spring.datasource.driver-class-name=org.hsqldb.jdbcDriver
spring.datasource.username=root
spring.datasource.password=
1234
schema 初始化:
<jdbc:initialize-database data-source="dataSource" ignore-failures="NONE">
<jdbc:script location="classpath:h2/schema.sql" encoding="UTF-8"/>
</jdbc:initialize-database>
123
它简化了在集成测试的相关上下文 XML 文件中创建 mockito mocks 的方法。
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
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.0.xsd
http://www.mockito.org/spring/mockito http://www.mockito.org/spring/mockito.xsd">
...
<mockito:mock id="accountService" class="org.kubek2k.account.DefaultAccountService" />
..
</beans>
123456789
目前主流的开发框架都在使用 spring 框架管理 bean,在测试代码中,我们通用期望能够使用 spring 框架,spring-test 框架帮助我们解决 bean 的注入问题。
@ContextConfiguration(locations = "/test-context.xml",
loader = SpringockitoContextLoader.class)
public class CustomLoaderXmlApplicationContextTests {
// class body...
}
12345
"/test-context.xml"
指定了测试类运行需要加载的 spring 配置文件路径,SpringockitoContextLoader
指定了加载配置的类,这两个一起用可以支持在使用 spring xml 配置的同时可以将 mockito 生成的 mock 对象 bean 注入 spring 上下文中。
支持静态方法 mock,同时兼容 mockito,powermock 示例:
@RunWith(PowerMockRunner.class)
@PrepareForTest( { YourClassWithEgStaticMethod.class })
public class YourTestCase {
...
}
12345
有赞单元测试框架,数据库层使用h2
数据库代替测试库,隔离单元测试数据与测试库数据,在单元测试结束后自动清理数据,避免污染测试库数据及被测试库数据影响,基于DbUnit
可以通过 xml 构造 DB 层初始化数据,实现测试代码与测试数据分离,依赖spring jdbc
的初始化脚本初始化 h2 数据库的表结构。
单测依赖的 Db 数据,通过添加测试方法监听器,在 Junit 执行前通过 DbUnit 工具类,加载初始化文件,写入 H2 数据库;单测的入参,通过 param.json 文件,以 json 格式编写入参数据,利用工具类读取文件并 json 反序列化为目标 Class 实例。
H2 数据库的表结构,则是通过上文提到的 jdbc:initialize-database
初始化的,开发同学必须保证此 schema 与线上结构的一致性,否则会导致单测失败。
添加方法监听器@TestExecutionListeners({ JunitMethodListener.class})
这是自定义的监听器,在执行前后执行自定义逻辑,包括数据准备、验证和清理。
public class JunitMethodListener extends AbstractTestExecutionListener {
@Override
public void beforeTestMethod(TestContext testContext) throws Exception {
Method jdkMethod = testContext.getTestMethod();
if (jdkMethod == null) {
return;
}
Object classInstance = testContext.getTestInstance();
if (!(classInstance instanceof JunitRunner)) {
return;
}
TestMethod testMethod = jdkMethod.getAnnotation(TestMethod.class);
if (testMethod == null) {
return;
}
JunitRunner runner = (JunitRunner) classInstance;
runner.init();
if (testMethod.enablePrepare()) {
TestRunnerTool.prepare(testMethod, runner);
}
}
@Override
public void afterTestMethod(TestContext testContext) throws Exception {
boolean hasException = (testContext.getTestException() != null) ? true : false;
Method jdkMethod = testContext.getTestMethod();
if (jdkMethod == null) {
return;
}
Object classInstance = testContext.getTestInstance();
if (!(classInstance instanceof JunitRunner)) {
return;
}
TestMethod testMethod = jdkMethod.getAnnotation(TestMethod.class);
if (testMethod == null) {
return;
}
JunitRunner runner = (JunitRunner) classInstance;
if (!hasException && testMethod.enableCheck()) {
TestRunnerTool.check(testMethod, runner);
}
if (testMethod.enablePrepare()) {
//清理数据
TestRunnerTool.clean(testMethod, runner);
}
}
}
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657
以下是单元测试代码示例,enablePrepare 声明需要准备数据,prepareDateConfig 声明数据准备的文件路径,prepareDateType 是数据准备的类型,xml -> DB,当然也支持更多的文件类型,如 csv,xls。
@TestMethod(
enablePrepare = true,
prepareDateType = PrepareDataType.XML2DB,
prepareDateConfig = {PREPARE_XML_FILE_USER}
)
@Test
public void test_updateUser(){
... 具体代码省略
}
123456789
为了使被测代码能够独立运行、并控制被测代码的执行路径,我们需要对外部依赖(包括中间件、静态函数、外部服务)进行 mock,mock 框架依赖的是PowerMock
及mockito
,利用spring-test
集成springockito
将 mock 的 bean 注入到 Spring 上下文中。
使用 PowerMock 运行 Junit 单元测试
@RunWith(PowerMockRunner.class)
@PowerMockIgnore({ "javax.management.*", "javax.net.ssl.*"})
12
PowerMock 集成 Spring TestContext 框架
@PowerMockRunnerDelegate(SpringJUnit4ClassRunner.class)
@ContextConfiguration(
loader = SpringockitoContextLoader.class,
locations = {
"classpath:applicationContext-test.xml"})
12345
结果验证,包括两部分,一个是被测函数的返回值,这个需要编写者自行验证,另一个是写入数据库的值,这部分是通过在方法上添加注解,告诉单元测试框架要验证的语句,执行验证语句并与期望值比较。
单元测试方法示例:
@TestMethod(
enablePrepare = true,
prepareDateType = PrepareDataType.XML2DB,
prepareDateConfig = { PREPARE_XML_FILE_USER},
enableCheck = true,
checkConfigFiles = {"/saveUserCheck.json"}
)
@Test
public void test_updateUser() throws IOException {
UserParam param = MockUtil.fromFile(
"/param.json",
UserParam.class);
...
}
123456789101112131415
saveUserCheck.json 文件内容示例
{
"check.type": "DB_CHECK",
"check.desc": "检查 更新结果正确性",
"check.sql.query": "select status from user where user_id=1",
"check.expected.data": [
{
"status": 1
}
]
}
12345678910
第二部分提到的几个痛点,通过我们的 zantest 测试组件,我们完美的解决这几个问题,通过注解方式,实现了配置数据与测试代码的分离,简化测试代码编写,隔离测试环境数据库,并编写了一套测试示例进行推广。
在单元测试 1.0 版本时,我们分别对 Service,innerBeanA,innerBeanB, UserDAO 写单元测试,当 Service 层输入输出不变,内部重构时,这几个类的单元测试都要重构,而在单元测试 2.0 版本时,由于被测函数只有 Service,通过桩代码控制 Service 对 innerBeanA,innerBeanB, UserDAO 的调用,从而覆盖 inner 层和 DAO 层,重构时只需要改写 Service 层代码即可。
数据准备不再依赖测试库,而是通过文件构造测试数据,例如上文的 xml 格式,为方便测试数据的构造,同时也支持更多的数据格式,例如 csv,可以方便的将线上数据导出作为测试用例。
一方面开发仍然需要自行校验函数的返回值,校验 mock 函数是否被执行,另一方面对数据库数据更改的验证可以直接通过注解声明校验的 sql 文件路径即可。
欢迎关注我们的公众号