单元测试
Spring Boot 中进行单元测试
在本文中,我们将介绍使用 Spring Boot 中的框架支持编写测试。我们将介绍可以独立运行的单元测试,以及将在执行测试之前引导 Spring 上下文的集成测试。我们需要在项目中添加 spring-boot-starter-test 依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<version>2.1.6.RELEASE</version>
</dependency>
或者添加 Gradle 依赖:
dependencies{
compileOnly('org.projectlombok:lombok')
testCompile('org.springframework.boot:spring-boot-starter-test')
testCompile 'org.junit.jupiter:junit-jupiter-engine:5.2.0'
testCompile('org.mockito:mockito-junit-jupiter:2.23.0')
}
测试相关的注解
在编写 Spring 测试用例时,我们常常会使用 SpringBootTest 编写如下的测试用例:
@ExtendWith(SpringExtension.class)
@SpringBootTest
class RegisterUseCaseTest {
@Autowired
private RegisterUseCase registerUseCase;
@Test
void savedUserHasRegistrationDate() {
User user = new User("root", "root@test.com");
User savedUser = registerUseCase.registerUser(user);
assertThat(savedUser.getRegistrationDate()).isNotNull();
}
}
实际上 SpringBootTest 为我们提供了完整的 Spring 上下文运行环境,这也就意味着这样的集成测试会花费远多于单元测试的运行时间,因此我们首先会讨论如何编写独立地单元测试。另外,值得注意的是,像 @EnableAutoConfiguration 这样的注解会默认扫描本地以及依赖中的 Configuration 相关的配置,而会生成许多额外的 Bean,在编写单元测试的过程中我们也需要避免这些。@ExtendWith 会告诉 JUnit 启用与 Spring 相关的插件,RunWith 则提供 Spring Boot 测试功能和 JUnit 之间的桥梁;每当我们在 JUnit 测试中使用任何 Spring Boot 测试功能时,都将需要此注解(最新的版本中 Spring Boot 已经会自动启用该注解)。
@RunWith(SpringRunner.class)
@RunWith(SpringJUnit4ClassRunner.class)
其他的注解还包括:
-
@DataJpaTest:提供了持久化层的基础配置,譬如配置 H2、HSQL 这样的内存数据库、初始化 Hibernate、Spring Data、DataSource,执行 EntityScan,启用 SQL 日志等特性。
-
@WebFluxTest: 我们可以使用@WebFluxTest 注解来测试 Spring Webflux 控制器。它通常与@MockBean 一起使用,以提供所需依赖项的模拟实现。
-
@JdbcTest: 我们可以使用@JdbcTest 注解来测试 JPA 应用程序,但这仅用于需要数据源的测试。注解配置内存中的嵌入式数据库和 JdbcTemplate。
-
@JooqTest: 要测试与 jOOQ 相关的测试,我们可以使用 @JooqTest 注解,该注解会自动配置 DSLContext。
-
@DataMongoTest: 测试 MongoDB 应用程序;默认情况下,如果驱动程序可通过依赖项获得,它将配置内存嵌入式 MongoDB,配置 MongoTemplate,扫描 @Document 类,并配置 Spring Data MongoDB 存储库。
-
@DataRedisTest: 使测试 Redis 应用程序更加容易。它扫描@RedisHash 类并默认配置 Spring Data Redis 存储库。
-
@DataLdapTest: 默认情况下,配置内存嵌入式 LDAP(如果可用),配置 LdapTemplate,扫描@Entry 类,并配置 Spring Data LDAP 存储库
-
@RestClientTest: 我们通常使用 @RestClientTest 注解来测试 REST 客户端。它自动配置不同的依赖项,例如 Jackson,GSON 和 Jsonb 支持,配置 RestTemplateBuilder,并默认添加对 MockRestServiceServer 的支持。
可被测试的 Bean
避免使用 @Autowired
@Service
public class RegisterUseCase {
@Autowired
private UserRepository userRepository;
public User registerUser(User user) {
return userRepository.save(user);
}
}
我们无法对此类进行单元测试,因为它无法传递 UserRepository 实例。相反,我们需要让 Spring 创建 UserRepository 实例并将其注入到@Autowired 注解的字段中,才能够进行测试。我们可以改写为如下方式:
@Service
public class RegisterUseCase {
private final UserRepository userRepository;
public RegisterUseCase(UserRepository userRepository) {
this.userRepository = userRepository;
}
public User registerUser(User user) {
return userRepository.save(user);
}
}
此版本通过提供允许传入 UserRepository 实例的构造函数来允许构造函数注入。在单元测试中,我们现在可以创建这样的实例并将其传递给构造函数。在创建生产应用程序上下文时,Spring 将自动使用此构造函数来实例化 RegisterUseCase 对象。userRepository 字段现在是 final 标记的,因为在应用程序的生命周期内,该值永远不会改变。当前,并不是使用了 Autowired 就一定需要启动完整的上下文,下面的介绍中我们也会使用 TestConfiguration 注解来手动创建 Bean。
减少模板代码
这里就是推荐使用 Lombok 来减少重复的模板代码:
@Service
@RequiredArgsConstructor
public class RegisterUseCase {
private final UserRepository userRepository;
public User registerUser(User user) {
user.setRegistrationDate(LocalDateTime.now());
return userRepository.save(user);
}
}
然后我们可以针对真正有意义地方法进行测试:
class RegisterUseCaseTest {
private UserRepository userRepository = ...;
private RegisterUseCase registerUseCase;
@BeforeEach
void initUseCase() {
registerUseCase = new RegisterUseCase(userRepository);
}
@Test
void savedUserHasRegistrationDate() {
User user = new User("zaphod", "zaphod@mail.com");
User savedUser = registerUseCase.registerUser(user);
assertThat(savedUser.getRegistrationDate()).isNotNull();
}
}
DataJpaTest
在单元测试中,我们往往是利用内存数据库进行测试,这里可以参考 Spring 内存数据库 相关章节。首先我们创建关联的实体对象:
@Entity
@Table(name = "person")
public class Employee {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
@Size(min = 3, max = 20)
private String name;
// standard getters and setters, constructors
}
以及数据持久化层:
@Repository
public interface EmployeeRepository extends JpaRepository<Employee, Long> {
public Employee findByName(String name);
}
最后我们的单元测试如下所示:
@RunWith(SpringRunner.class)
@DataJpaTest
public class EmployeeRepositoryIntegrationTest {
@Autowired
private TestEntityManager entityManager;
@Autowired
private EmployeeRepository employeeRepository;
// write test cases here
}
为了执行一些数据库操作,我们需要一些已经在数据库中设置的记录。要设置此数据,我们可以使用 TestEntityManager。Spring Boot 提供的 TestEntityManager 是标准 JPA EntityManager 的替代,它提供编写测试时常用的方法。EmployeeRepository 是我们要测试的组件。现在让我们编写第一个测试用例:
@Test
public void whenFindByName_thenReturnEmployee() {
// given
Employee alex = new Employee("alex");
entityManager.persist(alex);
entityManager.flush();
// when
Employee found = employeeRepository.findByName(alex.getName());
// then
assertThat(found.getName())
.isEqualTo(alex.getName());
}
Mock
很多时候我们的 Service 层代码会依赖于 Repository 层的实现,但是我们在测试的时候并不需要在意实际的业务数据流转,本小节我们就讨论下如何利用 Mock 来减少测试的复杂性。
Mockito
Mockito 是标准的 Mock 库:
private UserRepository userRepository = Mockito.mock(UserRepository.class);
这将从外部创建一个看起来像 UserRepository 的对象。默认情况下,在调用方法时它将不执行任何操作,如果该方法具有返回值,则返回 null。我们的测试在 assertThat(savedUser.getRegistrationDate()).isNotNull()
处出现 NullPointerException,因此,我们必须告诉 Mockito 在调用 userRepository.save()
时返回某些内容。我们使用 when 方法执行此操作:
@Test
void savedUserHasRegistrationDate() {
User user = new User("zaphod", "zaphod@mail.com");
when(userRepository.save(any(User.class))).then(returnsFirstArg());
User savedUser = registerUseCase.registerUser(user);
assertThat(savedUser.getRegistrationDate()).isNotNull();
}
另一个创建 Mock 对象的方法就是使用 Mockito 的 Mock 注解:
@ExtendWith(MockitoExtension.class)
class RegisterUseCaseTest {
@Mock
private UserRepository userRepository;
private RegisterUseCase registerUseCase;
@BeforeEach
void initUseCase() {
registerUseCase = new RegisterUseCase(userRepository);
}
@Test
void savedUserHasRegistrationDate() {
// ...
}
}
@Mock 注解指定 Mockito 应该在其中注入模拟对象的字段。@MockitoExtension 告诉 Mockito 执行那些 @Mock 注解,因为 JUnit 不会自动执行此操作。结果与手动调用 Mockito.mock()
相同,这取决于使用哪种方式。但是请注意,通过使用 MockitoExtension,我们的测试将绑定到测试框架。除了手动构造 RegisterUseCase 对象外,我们还可以在 registerUseCase 字段上使用@ InjectMocks 批注。然后,Mockito 将按照指定的算法为我们创建一个实例:
@ExtendWith(MockitoExtension.class)
class RegisterUseCaseTest {
@Mock
private UserRepository userRepository;
@InjectMocks
private RegisterUseCase registerUseCase;
@Test
void savedUserHasRegistrationDate() {
// ...
}
}
MockBean
Spring 也为我们提供了 MockBean 注解,来便于构建 Mock 对象。首先,我们被测试的服务代码如下所示:
@Service
public class EmployeeServiceImpl implements EmployeeService {
@Autowired
private EmployeeRepository employeeRepository;
@Override
public Employee getEmployeeByName(String name) {
return employeeRepository.findByName(name);
}
}
然后使用 Spring Boot Test 提供的 MockBean 注解:
@RunWith(SpringRunner.class)
public class EmployeeServiceImplIntegrationTest {
@TestConfiguration
static class EmployeeServiceImplTestContextConfiguration {
@Bean
public EmployeeService employeeService() {
return new EmployeeServiceImpl();
}
}
@Autowired
private EmployeeService employeeService;
@MockBean
private EmployeeRepository employeeRepository;
// write test cases here
}
这里的 TestConfiguration 能够指明在 test 文件夹中声明的类并不会被扫描到。然后,我们需要去构建 Mockito 对象:
@Before
public void setUp() {
Employee alex = new Employee("alex");
Mockito.when(employeeRepository.findByName(alex.getName()))
.thenReturn(alex);
}
并且设置实际的测试用例:
@Test
public void whenValidName_thenEmployeeShouldBeFound() {
String name = "alex";
Employee found = employeeService.getEmployeeByName(name);
assertThat(found.getName())
.isEqualTo(name);
}
MVC 测试
典型的 Controller 如下所示:
@RestController
@RequiredArgsConstructor
class RegisterRestController {
private final RegisterUseCase registerUseCase;
@PostMapping("/forums/{forumId}/register")
UserResource register(
@PathVariable("forumId") Long forumId,
@Valid @RequestBody UserResource userResource,
@RequestParam("sendWelcomeMail") boolean sendWelcomeMail
) {
User user = new User(userResource.getName(), userResource.getEmail());
Long userId = registerUseCase.registerUser(user, sendWelcomeMail);
return new UserResource(userId, user.getName(), user.getEmail());
}
}
Spring Boot 提供了@WebMvcTest 注释,以启动仅包含测试 Web 控制器所需的 Bean 的应用程序上下文:
@ExtendWith(SpringExtension.class)
@WebMvcTest(controllers = RegisterRestController.class)
class RegisterRestControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockBean
private RegisterUseCase registerUseCase;
@Test
void whenValidInput_thenReturns200() throws Exception {
mockMvc.perform(...);
}
}
请求验证
// Verifying HTTP Request Matching
mockMvc.perform(post("/forums/42/register")
.contentType("application/json"))
.andExpect(status().isOk());
// Verifying Input Serialization
@Test
void whenValidInput_thenReturns200() throws Exception {
UserResource user = new UserResource("Zaphod", "zaphod@galaxy.net");
mockMvc.perform(post("/forums/{forumId}/register", 42L)
.contentType("application/json")
.param("sendWelcomeMail", "true")
.content(objectMapper.writeValueAsString(user)))
.andExpect(status().isOk());
}
// Verifying Input Validation
@Value
public class UserResource {
@NotNull
private final String name;
@NotNull
private final String email;
}
@Test
void whenNullValue_thenReturns400() throws Exception {
UserResource user = new UserResource(null, "zaphod@galaxy.net");
mockMvc.perform(post("/forums/{forumId}/register", 42L)
...
.content(objectMapper.writeValueAsString(user)))
.andExpect(status().isBadRequest());
}