1. 单元测试概述
最小的可测试的单元就是单元测试,可以是一个函数,一个类。
1.1 为什么需要单元测试
- 节省测试时间
测试一个最小单元是否有逻辑问题,无需到测试环境中去(比如创建数据库,创建文件等一些麻烦且耗时的操作)。 - 防止回归
我们写的东西不会破坏已有的功能,确保我们的修改和设计不会破坏已有的功能。比如你修改了一个类,这个类会被很多其他的类使用,这样可能会造成一些问题。所以单元测试在代码重构上相当重要。 - 提高代码质量
- 保证行为的正确性
比如你输入了一个异常的数,就需要抛异常。
1.2 什么是好的单元测试
- 快速:对成熟项目进行数千次单元测试,这很常见。应花非常少的时间来运行单元测试。
- 独立:单元测试是独立的,可以单独运行,并且不依赖文件系统或数据库等任何外部因素。如果真的连数据库我们叫它集成测试。
- 可重复:运行单元測试的结果应该保持一致,也就是说,如果在运行期间不更改任何内容,总是返回相同的结果。
- 自检查:測试应该能够在没有任何人工交互的情况下,自动检测测试是否通过。
1.3 测试用例命名
理想情况下,包括一下三个部分:
- 要测试方法的名称
- 测试的方案
- 调用方案时的预期行为
我们至少包含前两条,比如:
testGetUserInfoByUserIdWithInvalidUserId()
testGetUserInfoByUserId() -> happy case
1.4 AAA(Arrange, Act, Assert) pattern
Arrange-Act-Assert是单元测试的常见模式
包括三个操作:
- Arrange:安排好所有先要条件和输入,根据需要进行创建和设置。
- Act:对要测试的对象或者方法进行调用。
- Assert:断言结果是否按预期进行。
2. 在 SpringBoot中进行单元测试
我们从文档中找到依赖,发现依赖中有exclusion,这是因为使用JUnit 5, 就必须exclude JUnit 4。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
2.1 一些用于快速入门的简单例子
public class AddManager {
public int add(int number) {
return number += 1;
}
}
public class AddManagerTest {
private AddManager addManager = new AddManager();
@Test
void testAdd() {
// Arrange
int number = 100;
// Act
int result = addManager.add(number);
// Assert
assertEquals(101, result);
}
}
如果将101改成80,则会出现以下的报错
org.opentest4j.AssertionFailedError:
Expected :80
Actual :101
如果在每个方法执行之前我们要执行一些操作(比如初始化),就可以使用@BeforeEach注解。同理还有@AfterEach(主要用于一些拆卸操作)。
public class AddManagerTest {
private AddManager addManager;
@BeforeEach
void setup() {
addManager = new AddManager();
}
@AfterEach
void teardown() {
// .....
}
// ......
}
下面是一个真实的例子。我们要去测试Manager层的UserInfoManager类。以下是一些解释。
- UserInfoMapper 是使用 MyBatis 写一个接口,用于查询数据库并返回用户信息,在源码中没有该接口的实现类。
- 其他所有的类都有对应的实现类,可以拿来直接用。
- 如果用户为空,则抛出 ResourceNotFoundException 异常。
该例子的所有类依赖关系如下所示。
UserInfoManage //有实现类 UserInfoManageImpl
└── UserInfoDao //有实现类 UserInfoDaoImpl
└── userInfoMapper //无实现类,只是个接口
└── UserInfoP2CConverter //有实现类 UserInfoP2CConverter
那么问题来了,UserInfoMapper是个接口,无法直接生成实例,而 UserInfoDao 又依赖 UserInfoMapper,怎么去创造这个实例呢?方法其实很简单我们在test/java/com/.../utils下创建一些测试会使用的到的工具类。因为 UserInfoMapper 的作用就是返回用户信息,我们可以直接返回一些假的数。具体地,我们创建 UserInfoMapperTestImpl 类去继承 UserInfoMapper 作为它的一个实现类,完成它的功能。
public class UserInfoMapperTestImpl implements UserInfoMapper {
@Override
public UserInfo getUserInfoById(long id) {
return id > 0 ? UserInfo.builder()
.username("admin")
.password("admin")
.createTime(LocalDate.now())
.id(1L)
.build() : null;
}
}
如下是单元测试的代码。在 Assert 阶段我们分别使用了 JUnit 5 和 AssertJ 中的不同方法去实现,AssertJ看其来更加清晰。一般地,如果测试一些会抛出异常的函数,我们将 Act 和 Assert 写在一起,用 assertThrows 方法。
public class UserInfoManagerTest2 {
private UserInfoManager userInfoManager;
@BeforeEach
void setup() {
UserInfoP2CConverter userInfoP2CConverter = new UserInfoP2CConverter();
UserInfoMapper userInfoMapper = new UserInfoMapperTestImpl();
UserInfoDao userInfoDao = new UserInfoDaoImpl(userInfoMapper);
userInfoManager = new UserInfoManagerImpl(userInfoDao, userInfoP2CConverter);
}
@Test
void testGetUserInfoById() {
// Arrange
long userId = 1L;
// Act
UserInfo userInfo = userInfoManager.getUserInfoById(userId);
// Assert with JUnit 5
assertEquals("admin", userInfo.getUsername());
assertEquals("admin", userInfo.getPassword());
assertEquals(userId, userInfo.getId());
// Assert With AssertJ
assertThat(userInfo).isNotNull()
.hasFieldOrPropertyWithValue("id", userId)
.hasFieldOrPropertyWithValue("username", "admin")
.hasFieldOrPropertyWithValue("password", "admin");
}
@Test
void testGetUserInfoByIdWithInvalidUserId() {
// Arrange
long userId = -1L;
// Act & Assert
assertThrows(ResourceNotFoundException.class, () -> userInfoManager.getUserInfoById(userId));
}
}
2.2 引入 Mockito 完善单元测试
上文的依赖还不够复杂,如果依赖非常的复杂,我们难道要一个个造 testImp(test place holder) 吗?是否可以直接模拟这些复杂函数的行为呢?比如:
when xxx case
call UserInfoDao.getUserInfoById()
return xxx value or throw xxx exception
2.2.1 Mockito的简单使用
Mockito 就是完成以上需求的,以下是 Mockito 的简单使用。
LinkedList mockedList = mock(LinkedList.class);
//stubbing
when(mockedList.get(0)).thenReturn("first");
when(mockedList.get(1)).thenThrow(new RuntimeException());
//following prints "first"
System.out.println(mockedList.get(0));
//following throws runtime exception
System.out.println(mockedList.get(1));
//following prints "null" because get(999) was not stubbed
System.out.println(mockedList.get(999));
//Although it is possible to verify a stubbed invocation, usually it's just redundant
//If your code cares what get(0) returns, then something else breaks (often even before verify() gets executed).
//If your code doesn't care what get(0) returns, then it should not be stubbed.
verify(mockedList).get(0);
Mockito 以 equals() 方法验证参数。有时,当需要额外的灵活性时,可以使用参数匹配器。参数匹配器,只有两种形式 anyX() 或是 eq()。
//可以用anyInt(),表示任何int
when(mockedList.get(anyInt())).thenReturn("element");
//following prints "element"
System.out.println(mockedList.get(999));
//我们也可以在verify的时候用参数匹配
verify(mockedList).get(anyInt());
//如果你正在使用的是参数匹配器,所有参数都必须由匹配器提供。
verify(mock).someMethod(anyInt(), anyString(), eq("third argument"));
//above is correct - eq() is also an argument matcher
verify(mock).someMethod(anyInt(), anyString(), "third argument");
//above is incorrect - exception will be thrown because third argument is given without an argument matcher.
还可以验证多次调用的函数
verify(mockedList, times(3)).add("three times");
verify(mockedList, never()).add("never happened");
verify(mockedList, atMostOnce()).add("once");
verify(mockedList, atLeastOnce()).add("three times");
verify(mockedList, atLeast(2)).add("three times");
verify(mockedList, atMost(5)).add("three times");
subbing 一些抛出异常的函数
doThrow(new RuntimeException()).when(mockedList).clear();
subbing 的两种写法
// doNothing doThrow 只能这样写
doReturn("Hello World").when(mockList).get(1)
when(mockedList.get(1)).thenReturn("Hello World");
2.2.2 使用 Mockito 改写例子
除了用 mock() 的方式外,我们还可以使用注解 @Mock 的方式进行mock,值得注意的是使用注解的方式必须初始化MockitoAnnotations.initMocks(this)
(这是老版本的写法,目前已经废弃,这里提供新的写法)。可以看到使用了 Mockito 后我们无需再为一些接口创建实现类了,我们更加注重需要 mock 的这个函数到底该完成了什么样的功能。
public class UserInfoManagerTest {
private UserInfoManager userInfoManager;
@Mock
private UserInfoDao userInfoDao;
@BeforeEach
void setup() {
MockitoAnnotations.initMocks(this);
userInfoManager = new UserInfoManagerImpl(userInfoDao, new UserInfoP2CConverter());
}
@Test
public void testGetUserInfoById() {
long userId = 1L;
String username = "admin";
String password = "admin";
LocalDate createTime = LocalDate.now();
val userInfo = UserInfo.builder()
.id(userId)
.username(username)
.password(password)
.createTime(createTime)
.build();
doReturn(userInfo).when(userInfoDao).getUserInfoById(userId);
val result = userInfoManager.getUserInfoById(userId);
assertEquals(userId, result.getId());
assertEquals("admin", result.getUsername());
assertEquals("admin", result.getPassword());
verify(userInfoDao).getUserInfoById(eq(userId));
}
@Test
public void testGetUserInfoByIdWithInvalidParameter() {
long userId = -1L;
doReturn(null).when(userInfoDao).getUserInfoById(userId);
assertThrows(ResourceNotFoundException.class, () -> userInfoManager.getUserInfoById(userId));
verify(userInfoDao).getUserInfoById(eq(userId));
}
}
controller层的测试有些不一样。按我们前面的做法,在arrange的时候,我们直接 new 一个 controller 的实例用于测试,这样是不能测试 MVC 的一些特性(返回的一些东西)。这里我们先举两个例子。
例1. 假设有这样一个GreetingController。
@RestController
public class GreetingController {
@GetMapping("/greeting")
public String greeting(@RequestParam("name") String name) {
return "Hello " + name;
}
}
Spring 自带了用于测试 MVC 的 MockMvc 类。生成 MockMvc 需要用MockMvcBuilders 的 standaloneSetup 方法。MockMvc 的 perform 方法用于执行一个请求并返回一个 ResultActions 类,该类型允许对结果链式操作操作,例如断言期望。考虑到 java 中有很多的 get、status、content 方法,这里给出具体的方法(千万不要 import 错了):
MockMvcRequestBuilders.get()
MockMvcResultMatchers.status()
MockMvcResultMatchers.content()
public class GreetingControllerTest {
private MockMvc mockMvc;
@BeforeEach
public void setup() {
mockMvc = MockMvcBuilders.standaloneSetup(new GreetingController()).build();
}
@Test
void testGreeting() throws Exception {
mockMvc.perform(get("/greeting").param("name", "admin"))
.andExpect(status().isOk())
.andExpect(content().string("Hello admin"));
}
}
例2. 另有一个 UserController,如果接受到的 userId 小于等于0则抛出 InvalidParameterException,而我们用 @RestControllerAdvice 对异常进行了统一处理(在 SpringBoot 中的异常处理)。我们在 MockMvcBuilders 创建MockMvc 时设置Controller的增强(setControllerAdvice)。
@RestController
@Slf4j
public class UserController {
private final UserInfoManager userInfoManager;
private final UserInfoC2SConverter userInfoC2SConverter;
@Autowired
public UserController(UserInfoManager userInfoManager, UserInfoC2SConverter userInfoC2SConverter) {
this.userInfoManager = userInfoManager;
this.userInfoC2SConverter = userInfoC2SConverter;
}
@GetMapping("/{id}")
public ResponseEntity<?> getUserInfoById(@PathVariable("id") long id) {
if (id <= 0L) {
throw new InvalidParameterException(String.format("User id %s is invalid", id));
}
val userInfo = userInfoManager.getUserInfoById(id);
return ResponseEntity.ok(userInfoC2SConverter.convert(userInfo));
}
}
具体写法如下所示,大同小异。亲测,如果不设置Conroller增强,则会报奇怪的错误。
public class UserControllerTest {
private MockMvc mockMvc;
@Mock
public UserInfoManager userInfoManager;
@BeforeEach
void setup() {
MockitoAnnotations.initMocks(this);
mockMvc = MockMvcBuilders.standaloneSetup(new UserController(userInfoManager, new UserInfoC2SConverter()))
.setControllerAdvice(new GlobalExceptionHandler())
.build();
}
@AfterEach
void teardown() {
reset(userInfoManager);
}
@Test
void testGetUserInfoById() throws Exception {
// Arrange
val userId = 100L;
val username = "admin";
val password = "admin";
val userInfoInCommon = com.lazyben.accounting.model.common.UserInfo.builder()
.id(userId)
.username(username)
.password(password)
.build();
doReturn(userInfoInCommon).when(userInfoManager).getUserInfoById(userId);
// Act & Assert
mockMvc.perform(MockMvcRequestBuilders.get("/" + userId))
.andExpect(content().string("{\"id\":100,\"username\":\"admin\",\"password\":null}"))
.andExpect(content().contentType("application/json"))
.andExpect(status().isOk());
verify(userInfoManager).getUserInfoById(anyLong());
}
@Test
void testGetUserInfoByIdWithInvalidUserId() throws Exception {
// Arrange
val userId = -1L;
doThrow(new InvalidParameterException(String.format("User %s is not found", userId)))
.when(userInfoManager)
.getUserInfoById(anyLong());
// Act & Assert {"code":"INVALID_PARAMETER","message":"User id -1 is invalid","statusCode":400,"errorType":"Client"}
mockMvc.perform(MockMvcRequestBuilders.get("/" + userId))
.andExpect(status().is4xxClientError())
.andExpect(content().contentType("application/json"))
.andExpect(content().string("{\"code\":\"INVALID_PARAMETER\",\"message\":\"User id -1 is invalid\",\"statusCode\":400,\"errorType\":\"Client\"}"));
}
}