如何正确写单元测试
- 单元测试重要性
- 写单元测试时存在的问题
- 1、如何命名测试类&方法
- 2、测试类的要求
- 3、选择测试框架
- 4、如何获得测试覆盖率
- [5、关于 PowerMockito 工具的简单 demo](#5、关于 PowerMockito 工具的简单 demo)
单元测试重要性
微软公司之前有这样一个统计:bug在单元测试阶段被发现的平均耗时是3.25小时,如果遗漏到系统测试则需要11.5个小时。由此可见单元测试的重要性。
写单元测试时存在的问题
虽然单元测试很重要,但是在工作中还是会发现不少同学在书写单元测试时,存在许多问题,我将常见的问题总结如下:
- 依赖了SpringBootTest框架,由于过长的启动耗时,导致代码单测的代价很大,这就极大限制了把单测作为一个日常态运行。
- 不可重复测试,常见于写数据接口,往往写入后由于数据的唯一性检验,导致测试用例在每次测试前都需要该。
- 覆盖度不够,往往只测试自己想验证的代码分支,而忽略了其他重要的的代码逻辑。
- 无从下手写,有些类有着比较复杂的静态和环境的依赖,无从测起。
- 缺乏断言,执行完测试用例无法明确是否验证了逻辑
下面我写一个单元测试的demo,并介绍常用的powermock框架。
一个测试用例的demo:
java
@RunWith(PowerMockRunner.class)
@PrepareForTest(PrivatePartialMockingExample.class)
public class PrivatePartialMockingExampleTest{
@Test
public void demoPrivateMethodMocking() throws Exception {
final String expected = "TEST VALUE";
final String nameOfMethodToMock = "methodToMock";
final String input = "input";
PrivatePartialMockingExample underTest = PowerMockito.spy(new PrivatePartialMockingExample());
PowerMockito.when(underTest, nameOfMethodToMock, input).thenReturn(expected);
String actual = underTest.methodToTest();
assertEquals(expected, actual);
verifyPrivate(underTest).invoke(nameOfMethodToMock, input);
}
}
1、如何命名测试类&方法
1.1、测试类命名规范
- 测试类必须和被测试类在同一个包内
- 测试类名字必须由被测试类类目拼接"Test"构成,比如com.test.Dummy.class,它的测试类是com.test.DummyTest.class
1.2、测试方法命名规范
当一个方法的使用场景比较复杂,为遵循单一职责原则,应考虑一个方法对应多个测试用例方法,这个时候测试方法的命名需要和被测试方法不同。
被测方法+期待行为+触发条件
例子:
boolean isChild() -------->isChildFalseAgeBiggerThan18()
int getMoney()---------->getMoneyThrowExceptionlfUserNotExist()
2、测试类的要求
2.1测试行覆盖率100%
这个是最重要的要求,既然写了测试类,它的行覆盖率要求就是 100%,没有达到==没有写测试。
另外,应该明确认知到,行覆盖率不等于测试覆盖率。根据经验,刚刚好做到 100% 行覆盖基本上整体的测试覆盖率在 10%~30%,还是远远不够的,提升质量还需要在行覆盖率的基础上,尽量做到更多的测试覆盖。
2.2、单一职责
- 每个测试方法只针对一个方法测试,不要测试多个方法。多个测试方法之间不要有任何依赖,比如测试查询用户的方法依赖了插入用户的方法。
- 每个测试方法的长度应做控制,不建议超过 50 行。不同的场景应尽量分拆测试方法。
2.3、可重复
在被测试方法未变化的情况下,测试用例要做到可以重复无限次调用。
Badcase:
java
@Test
public void dummy(){
Assert.assertTrue(System.currentTimeMillis() %2==0);
}
2.4、外部隔离,无任何外部依赖
单测中应不与任何外部环境交互,不应有任何的 IO 交互,这样才能保证测试用例的成功率。
- 如 redis 或者 db 或者外部系统 RPC 的依赖应尽量使用本地的 db 或者 mock 的 bean 模拟,避免跑测试用例的时候因为外部系统不稳定或者网络不通无法测试。
- 数据库可以使用 in memory db 模拟,java 可以使用 h2database。
2.5、正确的使用断言
- 没有断言等于没有写测试。每个测试方法必须有至少一个断言语句。
- 即使没有返回的方法,可以来校验日志打印是否被正确打印了,或者方法是否没有出异常。
- 应多使用非 expect true 的方式,这样便于查看出错信息。
2.6、不应该为了测试方便修改线上代码
Badcase:
java
//...something upper
if(!TestContext.isUnitTest()){
}
//...something down
DummyService.doRealThing();
2.7、线上bug应该沉淀为测试用例
每个 bug 修复上线后,应有避免出现类似问题的单元测试,确保下次不会出同样问题。
2.8、快速原则
每个单元测试类的耗时不应大于 100ms,为了能快速对整个项目测试,应控制测试类的依赖、复杂度,提升运行速度,这样才能做到测试常态化。
3、选择测试框架
3.1、基础测试框架
单元测试框架最常用的是 JUnit4/5。但在现实场景中,JUnit 往往力不从心,因为要满足外部隔离和快速测试的要求。对于代码中的静态依赖、final、spring bean 依赖等情况,在不真实启动容器或者面对巨大静态依赖的前提下,快速地将待测试的逻辑充分测试,这就要用到一个比较好用的工具:PowerMock
- PowerMock 扩展自 Mockito,通过 Java 反射机制解决了 Mockito 的一些问题,并通过提供定制的类加载器以及一些字节码改写技巧的应用。PowerMock 在 Mockito 基础上实现了对静态方法、构造方法、私有方法以及 Final 方法的模拟支持,对静态初始化过程的移除等强大功能,是实现外部隔离和快速原则的利器。
3.2、如何在外部隔离的前提下测试DAO层
由于 mybatis 集成度较高,需基于 SpringBootTest 前提下测试,但真实数据库可用 H2 Database 做替代,避免远程 IO 交互,不违背外部隔离原则。
- 新建test-application.properties文件,配置 H2 Database 的基本信息和 MyBatis 的 mapper location:
java
# mysql 驱动:h2
spring.datasource.driver-class-name=org.h2.Driver
# h2 内存数据库库名:test
spring.datasource.url=jdbc:h2:mem:test
#初始化数据表
spring.datasource.schema=classpath:init_table.sql
spring.datasource.username=
spring.datasource.password=
# 打印 SQL 语句, Mapper 所处的包
logging.level.com.hawkingfoo.dao=debug
#放 mapper 的地方
mybatis.mapper-locations=classpath:/sqlmaps/*.xml
- 新建init_table.sql文件,文件名与test-application.properties文件中的spring.datasource.schema行的值一致。注意创建表语句中应去除最后一行ENGINE=XXX,否则 H2 Database 执行时会报符号错误。例如创建student表的语句:
sql
DROP TABLE IF EXISTS 'student';
CREATE TABLE'student'( )ENGINE=InoDB DEFAULT CHARSET=utf8mb4; PRIMARY KEY('id') id' int(10) unsigned NOT NULL AUTO_INCREMENT, name' varchar(1024) NOT NULL, sex'tinyint(1) NOT NULL, addr' varchar(1024) NOT NULL,
- 在测试目录下增加 Spring Boot 的启动类DaoTestSpringBootAppication,注意配置scanBasePackages,只扫描 DAO 相关类,避免初始化无关的类。
java
@SpringBootApplication(scanBasePackages="com.onx.buyerhome.service.infra.db")
@PropertySource("classpath:test-application.properties")
public class DaoTestSpringBootApplication{
public static void main(String[] args){
SpringApplication.run(DaoTestSpringBootApplication.class, args);
}
}
- 新建Base Test类,用于test类继承使用
java
@RunWith(SpringRunner.class)
public class DaoTestBase {
}
- 最后可以愉快的写Dao的测试用例了
java
public class BuyerPlanMapperTest extends DaoTestBase {
@Resource
BuyerPlanMapper buyerPlanMapper;
@Test
public void queryByIds(){
List<Plano> plans=buyerPlanMapper.querybyIds(Lists.newArrayList(101L));
Assert.assertTrue(plans.size()>0);
Assert.assertEquals(Long.valueOf(101L), plans.get(0).getId());
}
}
4、如何获得测试覆盖率
4.1、使用idea工具获取测试覆盖率情况
可以直接使用idea的覆盖率工具来查看测试用例的覆盖情况
- 在测试类名上弹开右键菜单,选择使用覆盖率运行。
- 查看单个类的总体覆盖情况。
- 查看类里面具体行的覆盖情况,左边显示为绿色的即为覆盖的行,为红的即为没有覆盖到。
5、关于 PowerMockito 工具的简单 demo
列举了一些常见情况下 PowerMockito 的用法,实际使用中若有其他疑惑可自行搜索工具检索。
5.1、使用powermockito,在test类名上使用
@RunWith(PowerMockRunner.class)
java
@RunWith(PowerMockRunner.class)
public class RpcClientAopLogTest{
}
5.2、普通对象的mock
java
public class RpcClientAopLogTest{
CommonsConfigHolder commonsConfigHolder;
@Before
public void setUp(){
commonsConfigHolder = PowerMockito.mock(CommonsConfigHolder.class);
PowerMockito.when(commonsConfigHolder.getCommonsConfig()).thenReturn(new Comm());
}
}
5.3、静态方法的mock
java
public void setUp(){
PowerMockito.mockStatic(ProfilerUtil.class);
PowerMockito.when(ProfilerUtil.getCurrentUid()).thenReturn(1L);
}
5.4、静态方法的void方法mock,模拟抛出异常
java
public void setUp(){
PowerMockito.mockStatic(ProfilerUtil.class);
doThrow(new RuntimeException()).when(ProfilerUtil.class);
ProfilerUtil.start("exception test");
}
5.5、模拟测试构造函数
java
public class User{
private String username;
private String password;
public User(String username, String password) {
this.username = username;
this.password = password;
}
public void insert(){
throw new UnsupportedOperationException();
}
}
public class UserService {
public void saveUser(String username, String password) {
User user = new User(username, password);
user.insert();
}
}
@RunWith(PowerMockRunner.class)
@PrepareForTest(UserService.class)
public class UserServiceTest {
@Mock
private User user;
@Test
public void saveUser() throws Exception {
String username = "user1";
String password = "aaa";
// 在构造函数被调用的使用返回了我们构造的类
PowerMockito.whenNew(User.class).withArguments(username, password).thenReturn(user);
PowerMockito.doNothing().when(user).insert();
UserService userService = new UserService();
userService.saveUser(username, password);
Mockito.verify(user).insert();
}
}
5.6、如何测试是否打印了日志
java
public class Dummy{
private static LogCaptor logCaptor;
private static final String EXPECTED_INFO_MESSAGE = "Keyboard not responding. Pre";
@BeforeAll
public static void setupLogCaptor() {
logCaptor = LogCaptor.forClass(FooService.class);
}
@AfterEach
public void clearLogs() {
logCaptor.clearLogs();
}
@AfterAll
public static void tearDown() {
logCaptor.close();
}
@Test
public void logMethod() {
// do something that triggers logging in FooService
// Assuming there is a method in FooService that logs the expected message.
assertThat(logCaptor.getInfoLogs()).containsExactly(EXPECTED_INFO_MESSAGE);
}
}
5.7、测试预期抛出异常
java
@Test(expected = IllegalStateException.class)
public void dummy() {
// do something
throw new IllegalStateException();
}
5.8、注入依赖
java
/**
* 这个测试类展示了如何使用 PowerMock 和 Mockito 进行测试。
* @RunWith(PowerMockRunner.class) 告诉 JUnit 使用 PowerMockRunner 进行测试。
* @PrepareForTest({MockUtil.class}) 表示要为指定的类准备测试环境,这里是 MockUtil 类,
* 适用于模拟 final 类或有 final、private、static、native 方法的类。
*/
@RunWith(PowerMockRunner.class)
@PrepareForTest({MockUtil.class})
public class MockExample{
/**
* @InjectMocks 注解用于将被测试类的依赖自动注入到该实例中。
* 这里会将模拟的依赖注入到 MockServiceImpl 实例中。
*/
@InjectMocks
private MockServiceImpl mockService;
/**
* @Mock 注解用于创建模拟对象。这里创建了一个 MockMapper 的模拟对象。
*/
@Mock
private MockMapper mockMapper;
/**
* 测试方法,用于测试某个特定的功能。
* 在这个方法中,首先创建了一个 MockModel 对象,然后使用 PowerMockito 模拟了 mockMapper 的 count 方法的返回值为 2。
* 最后,使用 assertEquals 断言来验证 mockService 的 count 方法的返回值是否与预期一致。
*/
@Test
public void testSomething(){
MockModel model = new MockModel();
PowerMockito.when(mockMapper.count(model)).thenReturn(2);
assertEquals(2, mockService.count(model));
}
}