单元测试实践(junit+mockito)
单元测试是保证代码质量和正确性的第一道防线,在开发过程中编写必要的单元测试用例既能保证代码的质量,也有利于项目整体的开发效率。然而在实际开发过程中,存在测试数据难构造,一个方法实现的逻辑较为复杂,有些逻辑对其他模块有依赖等问题,使得编写单测比较困难,有些情况编写单测耗费的精力甚至超过实际的业务逻辑开发。从而让开发人员望而却步,最终放弃单测。
本文介绍基于junit+mockito的单测框架使用,帮助开发人员提升编写单测的效率。
-
Junit4:单元测试框架
-
Mockito:生成模拟对象(造测试数据)
依赖配置
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.12</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>3.2.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>1.4.199</version>
<scope>test</scope>
</dependency>
<!-- mockito static method -->
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-inline</artifactId>
<version>4.3.1</version>
<scope>test</scope>
</dependency>
Junit注解说明
junit中提供了一些注解来辅助单测的编写,通过这些注解可以方便的实现数据模拟,逻辑测试等功能。
方法相关注解
注解 | 描述 |
---|---|
@Test | 将方法标识为测试方法 |
@Before | 在每次测试之前执行。用于准备测试环境(例如,读取输入数据,初始化类) |
@After | 每次测试之后执行。用于清理测试环境(例如,删除临时数据,恢复默认值) |
@BeforeClass | 用于 public static void方法,在所有测试开始之前执行一次。它用于执行耗时的活动,例如:连接数据库 |
@AfterClass | 用于 public static void方法,在完成所有测试之后,执行一次。它用于执行清理活动,例如:与数据库断开连接 |
@Ignore | 指定要忽略的测试 |
@Test(expected = Exception.class) | 如果该方法未引发命名异常,则失败 |
@Test(timeout=100) | 如果该方法花费的时间超过100毫秒,则失败 |
@RunWith(xxx) | 修饰测试类,用于指定运行器,如果不指定则会采用默认的运行器。 |
Mockito相关注解
要使mockito的注解生效,需指定运行器为MockitoJUnitRunner.class
@RunWith(MockitoJUnitRunner.class)
或在初始化方法中使用MockitoAnnotations.initMocks(this)
@BeforeClass
public static void init() {
MockitoAnnotations.initMocks(this);
}
注解 | 描述 |
---|---|
@Mock | 被修饰的对象在对方法进行打桩后,返回打桩后指定的结果。需要先打桩再调用。 |
@Spy | 被修饰的对象默认执行原来的逻辑,在对方法进行打桩后,返回打桩后指定的结果。利用该注解可实现自定义真实对象的部分行为。适用于需要模拟一个对象中的少部分方法,对象的大部分方法需执行实际逻辑的情况。 |
@InjectMocks | 被修饰的对象自动注入被@Spy和@Mock标注的对象。 |
@Spy和@Mock都能用于模拟对象,区别在于:
@Spy:只对打桩过的方法,调用时返回指定的值,对于该对象中其他方法的调用仍将执行实际的函数体。此外,对于打过桩的方法,是否执行实际函数体与打桩方式也有关系。
-
when(...) thenReturn(...)
做了真实调用。只是返回了指定的结果 -
doReturn(...) when(...)
不做真实调用
(推荐使用下面的方式,可调用doReturn()|doThrow()| doAnswer()|doNothing()|doCallRealMethod() 系列方法。上一种方法因为执行了真实的方法调用,其中可能会抛出异常 )
@Mock:被修饰的对象中所有函数的调用都不会执行实际的函数体。对未打桩的方法直接调用将返回null。
junit常用断言
断言用于验证测试结果与期望结果的对比,junit提供以下声明方式。
声明 | 描述 |
---|---|
fail([message]) | 使方法失败。在执行测试代码之前,可用于检查未到达代码的特定部分或测试失败 |
assertTrue([message,]布尔条件) | 检查布尔条件是否为真 |
assertFalse([message,]布尔条件) | 检查布尔条件是否为假 |
assertEquals([message,]预期,实际) | 测试两个值是否相同。注意:对于数组,会检查引用而不是数组的内容 |
assertNull([message,]对象) | 检查对象是否为空 |
assertNotNull([message,]对象) | 检查对象是否不为空 |
assertSame([message,]预期,实际) | 检查两个变量是否引用同一对象 |
assertNotSame([message,]预期,实际) | 检查两个变量是否引用了不同的对象 |
mock应用示例
在编写单测用例时,往往最大的痛点在于如何造出与测试逻辑无关的中间数据。下面介绍几种模拟函数返回值的数据mock方法。
有返回值函数的mock
Class Person {
private Integer id = 1;
private String name = "jack";
private List<String> hobby;
public String getName() {
return this.name;
}
public String getId() {
return this.Id;
}
public void setHobby(List<String> hobby) {
this.hobby = hobby;
}
}
@Spy
Person person;
when(person.getName()).thenReturn("xiaoming");
doReturn("xiaoming").when(person).getName();
String name = person.getName() //name值将为"xiaoming"
Integer id = person.getId() //此时会调用实际的执行逻辑,id值为1
viod函数的mock
对于void函数,无法采用上述打桩方式来模拟函数返回值。可采取以下机制:
-
doNothing() :完全忽略对void方法的调用,这是默认行为
-
doAnswer() :在调用void方法时执行一些运行时或复杂的操作
-
doThrow() : 调用模拟的 void方法时引发异常
-
doCallRealMethod() :不要模拟并调用真实方法
若需对方法输入参数做出模拟修改,可采用doAnswer()模式:
doAnswer(invocation -> {
Object[] args = invocation.getArguments();
List<String> strs = (List<String>) args[0];
strs.add("run");
return null;}).when(person).setHobby(anyList());
经过以上打桩后,在测试中调用person.change(list)方法后,list列表中将多出“run”元素。
静态函数的mock
从3.4.0版本开始,mokito支持mock静态方法。
Mockito提供了mockStatic静态方法mock包含静态方法的类,返回一个MockedStatic对象。MockedStatic使用完后需及时释放,mock逻辑只在MockedStatic对象释放之前有效,一旦被释放,就会返回到它的原始调用逻辑。
class StaticClass {
public static int add(int... args) {
return IntStream.of(args).sum();
}
}
@Test
public void testAdd() {
//实际的方法调用
assertEquals(5, StaticClass.add(2, 3));
try (MockedStatic mockStatic = Mockito.mockStatic(ClassWithStaticMethod.class)) {
mockStatic.when(() -> ClassWithStaticMethod.add(anyInt(), anyInt())).thenReturn(10);
// 返回mock的结果10
assertEquals(10, ClassWithStaticMethod.add(2, 3));
mockStatic.verify(() -> ClassWithStaticMethod.add(2, 3));
}
//实际的方法调用
assertEquals(5, ClassWithStaticMethod.add(2, 3));
}
模拟数据库交互
采用h2内存数据库
private static JdbcTemplate jdbcTemplate;
//xxx.sql为创建数据库的脚本,存放在resource目录下
@BeforeClass
public static void start() {
DataSource dataSource = new EmbeddedDatabaseBuilder()
.setType(EmbeddedDatabaseType.H2)
.addScript("classpath:/H2_TYPE.sql")
.addScript("classpath:/INIT_DATA_ADD_USER.sql")
.build();
jdbcTemplate = new JdbcTemplate(dataSource);
}
H2_TYPE.sql(指定数据库类型)
SET MODE MySQL;
INIT_DATA_ADD_USER.sql(创建库表,初始化数据)
create SCHEMA `mysql`; CREATE TABLE `mysql`.`user` ( `Host` char(60) DEFAULT '', `User` char(32) DEFAULT '', `Select_priv` char(2) DEFAULT 'N', `Insert_priv` char(2) DEFAULT 'N', `Update_priv` char(2) DEFAULT 'N', `Delete_priv` char(2) DEFAULT 'N', `Create_priv` char(2) DEFAULT 'N', `Drop_priv` char(2) DEFAULT 'N', `Reload_priv` char(2) DEFAULT 'N', `Shutdown_priv` char(2) DEFAULT 'N', `Process_priv` char(2) DEFAULT 'N', `File_priv` char(2) DEFAULT 'N', `Grant_priv` char(2) DEFAULT 'N', `References_priv` char(2) DEFAULT 'N', `Index_priv` char(2) DEFAULT 'N', `Alter_priv` char(2) DEFAULT 'N', `Show_db_priv` char(2) DEFAULT 'N', `Super_priv` char(2) DEFAULT 'N', `Create_tmp_table_priv` char(2) DEFAULT 'N', `Lock_tables_priv` char(2) DEFAULT 'N', `Execute_priv` char(2) DEFAULT 'N', `Repl_slave_priv` char(2) DEFAULT 'N', `Repl_client_priv` char(2) DEFAULT 'N', `Create_view_priv` char(2) DEFAULT 'N', `Show_view_priv` char(2) DEFAULT 'N', `Create_routine_priv` char(2) DEFAULT 'N', `Alter_routine_priv` char(2) DEFAULT 'N', `Create_user_priv` char(2) DEFAULT 'N', `Event_priv` char(2) DEFAULT 'N', `Trigger_priv` char(2) DEFAULT 'N', `Create_tablespace_priv` char(2) DEFAULT 'N', PRIMARY KEY (`Host`,`User`) ); insert into `mysql`.`user` values ('%','test_user', 'Y', 'Y','Y','Y','Y','Y','Y','Y','Y','Y','Y','Y','Y','Y','Y','Y','Y','Y','Y','Y','Y','Y','Y','Y','Y','Y','Y','Y','Y');
编写注意点
单元测试的编写与待测试方法的代码结构密切相关,待测试方法实现的方式直接影响到单元测试用例的编写难度。如果发现单元测试很难编写,就要考虑一下待测试方法的实现是不是可以优化。
建议:
1、一个方法中实现的逻辑尽量不要太复杂;
2、与外部模块有交互的部分单独写一个函数实现;
3、不是与待测试方法主要逻辑相关的部分,可以采用mock方法模拟输出。