单元测试

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());
}

业务逻辑校验

自定义校验器

Links

上一页
下一页