单元测试

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:提供了持久化层的基础配置,譬如配置H2HSQL这样的内存数据库、初始化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客户端。它自动配置不同的依赖项,例如JacksonGSONJsonb支持,配置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
}

为了执行一些数据库操作,我们需要一些已经在数据库中设置的记录。要设置此数据,我们可以使用TestEntityManagerSpring 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对象的方法就是使用MockitoMock注解:

@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

上一页
下一页