测试 SpringBoot 应用程序
SpringBoot是构建基于 Java 的 REST API 的最流行的技术栈。
在本教程中,我们将学习如何为 SpringBoot 应用程序编写测试。
- 创建 SpringBoot 应用程序
- 使用JUnit 5和Mockito进行单元测试
- 使用TestContainers进行集成测试
- 使用MockServer测试微服务集成
众所周知,我们编写单元测试来测试单个组件(一个类)的行为,
而我们编写集成测试来测试可能涉及与多个组件交互的功能。
大多数情况下,一个组件会依赖于其他组件,因此在实现单元测试时,我们应该使用Mockito
等框架模拟所需行为的依赖关系。
那么,问题是如何在 SpringBoot 应用程序中实现单元测试和集成测试?继续阅读 :-)
本文的示例应用程序代码可以在https://github.com/sivaprasadreddy/spring-boot-tutorials/tree/master/testing/springboot-testing-demo找到
创建 SpringBoot 应用程序
让我们考虑一个场景,我们正在构建一个 REST API 来管理用户。
如果我们遵循典型的三层架构,我们可能有 JPA 实体User,Spring Data JPA 存储库UserRepository,
UserService和UserController实现如下 CRUD 操作:
首先,创建一个SpringBoot应用程序,具有以下依赖项:
pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.sivalabs</groupId>
<artifactId>springboot-testing-demo</artifactId>
<packaging>jar</packaging>
<version>1.0-SNAPSHOT</version>
<name>springboot-testing-demo</name>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.8.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<java.version>1.8</java.version>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<artifactId>maven-failsafe-plugin</artifactId>
</plugin>
</plugins>
</build>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
</project>
spring-boot-starter-test默认使用JUnit 4作为测试框架。
我们可以排除 JUnit4并添加 JUnit 5依赖项,如下所示:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-params</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
让我们为用户创建 JPA 实体、存储库、服务和控制器,如下所示:
用户.java
package com.sivalabs.myservice.entities;
import lombok.*;
import javax.persistence.*;
import javax.validation.constraints.NotEmpty;
import java.io.Serializable;
@Entity
@Table(name = "users")
@Setter
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class User implements Serializable
{
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotEmpty(message = "Email should not be empty")
@Column(nullable = false, unique = true, length = 100)
private String email;
@Column(nullable = false, length = 100)
private String password;
@Column(nullable = false, length = 100)
private String name;
}
用户存储库.java
package com.sivalabs.myservice.repositories;
import org.springframework.data.jpa.repository.JpaRepository;
import com.sivalabs.myservice.entities.User;
import org.springframework.data.jpa.repository.Query;
import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long>
{
@Query("select u from User u where u.email=?1 and u.password=?2")
Optional<User> login(String email, String password);
Optional<User> findByEmail(String email);
}
用户服务.java
package com.sivalabs.myservice.services;
import com.sivalabs.myservice.exception.UserRegistrationException;
import com.sivalabs.myservice.repositories.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.sivalabs.myservice.entities.User;
import java.util.List;
import java.util.Optional;
@Service
@Transactional
public class UserService
{
private final UserRepository userRepository;
@Autowired
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public Optional<User> login(String email, String password)
{
return userRepository.login(email, password);
}
public User createUser(User user)
{
Optional<User> userOptional = userRepository.findByEmail(user.getEmail());
if(userOptional.isPresent()){
throw new UserRegistrationException("User with email "+ user.getEmail()+" already exists");
}
return userRepository.save(user);
}
public User updateUser(User user)
{
return userRepository.save(user);
}
public List<User> findAllUsers() {
return userRepository.findAll();
}
public Optional<User> findUserById(Long id) {
return userRepository.findById(id);
}
public void deleteUserById(Long id) {
userRepository.deleteById(id);
}
}
用户控制器.java
package com.sivalabs.myservice.web.controllers;
import com.sivalabs.myservice.entities.User;
import com.sivalabs.myservice.services.UserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/users")
@Slf4j
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping
public List<User> getAllUsers() {
return userService.findAllUsers();
}
@GetMapping("/{id}")
public ResponseEntity<User> getUserById(@PathVariable Long id) {
return userService.findUserById(id)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public User createUser(@RequestBody @Validated User user) {
return userService.createUser(user);
}
@PutMapping("/{id}")
public ResponseEntity<User> updateUser(@PathVariable Long id, @RequestBody User user) {
return userService.findUserById(id)
.map(userObj -> {
user.setId(id);
return ResponseEntity.ok(userService.updateUser(user));
})
.orElseGet(() -> ResponseEntity.notFound().build());
}
@DeleteMapping("/{id}")
public ResponseEntity<User> deleteUser(@PathVariable Long id) {
return userService.findUserById(id)
.map(user -> {
userService.deleteUserById(id);
return ResponseEntity.ok(user);
})
.orElseGet(() -> ResponseEntity.notFound().build());
}
}
这里没有什么特别花哨的东西,只是 SpringBoot 应用程序中典型的 CRUD 操作。
使用 Zalando Problem Web 进行异常处理
我们将使用Zalando Problem Web SpringBoot starter来处理
异常,以便它自动将应用程序错误转换为 JSON 响应。
只需添加以下依赖项就足以开始使用 Zalando Problem Web,当然,如果您愿意,也可以对其进行自定义。
<problem-spring-web.version>0.25.0</problem-spring-web.version>
...
...
<dependency>
<groupId>org.zalando</groupId>
<artifactId>problem-spring-web-starter</artifactId>
<version>${problem-spring-web.version}</version>
<type>pom</type>
</dependency>
现在让我们看看如何为此功能编写单元测试和集成测试。
使用 JUnit 5 和 Mockito 进行单元测试
让我们开始为UserService编写单元测试。
我们应该能够在不使用任何 Spring 特性的情况下为 UserService 编写单元测试。
我们将使用Mockito.mock()创建一个模拟UserRepository,并使用模拟 UserRepository 实例创建 UserService 实例。
package com.sivalabs.myservice.services;
import com.sivalabs.myservice.entities.User;
import com.sivalabs.myservice.exception.UserRegistrationException;
import com.sivalabs.myservice.repositories.UserRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.*;
class UserServiceTest {
private UserService userService;
private UserRepository userRepository;
@BeforeEach
void setUp() {
userRepository = mock(UserRepository.class);
userService = new UserService(userRepository);
}
@Test
void shouldSavedUserSuccessfully() {
User user = new User(null, "siva@gmail.com","siva","Siva");
given(userRepository.findByEmail(user.getEmail())).willReturn(Optional.empty());
given(userRepository.save(user)).willAnswer(invocation -> invocation.getArgument(0));
User savedUser = userService.createUser(user);
assertThat(savedUser).isNotNull();
verify(userRepository).save(any(User.class));
}
@Test
void shouldThrowErrorWhenSaveUserWithExistingEmail() {
User user = new User(1L, "siva@gmail.com","siva","Siva");
given(userRepository.findByEmail(user.getEmail())).willReturn(Optional.of(user));
assertThrows(UserRegistrationException.class, () -> {
userService.createUser(user);
});
verify(userRepository, never()).save(any(User.class));
}
}
我在@BeforeEach方法中创建了UserRepository模拟对象和UserService实例,以便每个测试都有一个干净的设置。 这里我们没有使用任何 Spring 或 SpringBoot 测试功能(例如@SpringBootTest),因为我们不需要测试 UserService 的行为。
我不会为其他方法编写测试,因为它们只是将调用委托给UserRepository。
如果您更喜欢使用注释魔法来创建模拟UserRepository并将该模拟注入到UserService中,则可以按如下方式
使用mockito-junit-jupiter :
添加mockito-junit-jupiter依赖项
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
使用@Mock和@InjectMocks创建和注入模拟对象,如下所示:
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class UserServiceAnnotatedTest {
@Mock
private UserRepository userRepository;
@InjectMocks
private UserService userService;
...
...
}
现在,我们应该为 UserRepository 编写测试吗?嗯……
UserRepository是一个扩展JpaRepository 的接口,我们几乎没有实现任何逻辑,我们不应该测试 Spring Data JPA 框架, 因为我坚信 Spring Data JPA 团队已经对其进行了测试:-)
但是,我们添加了几个自定义方法,一个利用了查询命名约定findByEmail(),另一个使用了自定义 JPQL 查询login()。
我们应该测试这些方法。如果存在任何语法错误,Spring Data JPA 会在启动时抛出错误,但逻辑错误我们应该自行测试。
我们可以使用 SpringBoot 的@DataJpaTest注解以及内存数据库支持来实现UserRepository的测试。 但是,针对内存数据库运行测试可能会让人误以为它也可以在真实的生产数据库上运行。 因此,我更喜欢针对生产数据库类型(在本例中为 Postgresql)运行测试。
我们可以使用TestContainers 支持来启动一个 Postgresql Docker 容器,并运行指向该数据库的测试。
但是,由于我们是在与真实的数据库交互,因此我认为这是集成测试而不是单元测试。
因此,我们稍后将讨论如何为 UserRepository 编写集成测试。
控制器的单元测试怎么样?
是的,我想为控制器编写单元测试,并且我想检查 REST 端点是否提供了正确的
HTTP ResponseCode,是否返回了预期的 JSON 等等。
SpringBoot 提供了@WebMvcTest注解来测试 Spring MVC 控制器。
此外,基于@WebMvcTest的测试运行速度更快,因为它只会加载指定的控制器及其依赖项,而无需加载整个应用程序。
使用@WebMvcTest加载 Controller 时,SpringBoot 不会自动加载 Zalando Problem Web AutoConfiguration。
因此,我们需要按如下方式配置ControllerAdvice :
package com.sivalabs.myservice.common;
import org.springframework.context.annotation.Profile;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.zalando.problem.spring.web.advice.ProblemHandling;
@Profile("test")
@ControllerAdvice
public final class ExceptionHandling implements ProblemHandling {
}
现在我们可以通过注入 Mock UserService bean 为UserController编写测试并使用MockMvc调用 API 端点。
当 SpringBoot 正在创建UserController实例时,我们使用 Spring 的@MockBean而不是普通的 Mockito 的@Mock创建模拟UserService bean 。
package com.sivalabs.myservice.web.controllers;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sivalabs.myservice.entities.User;
import com.sivalabs.myservice.services.UserService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.zalando.problem.ProblemModule;
import org.zalando.problem.violations.ConstraintViolationProblemModule;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.Matchers.hasSize;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.doNothing;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(controllers = UserController.class)
@ActiveProfiles("test")
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private UserService userService;
@Autowired
private ObjectMapper objectMapper;
private List<User> userList;
@BeforeEach
void setUp() {
this.userList = new ArrayList<>();
this.userList.add(new User(1L, "user1@gmail.com", "pwd1","User1"));
this.userList.add(new User(2L, "user2@gmail.com", "pwd2","User2"));
this.userList.add(new User(3L, "user3@gmail.com", "pwd3","User3"));
objectMapper.registerModule(new ProblemModule());
objectMapper.registerModule(new ConstraintViolationProblemModule());
}
@Test
void shouldFetchAllUsers() throws Exception {
given(userService.findAllUsers()).willReturn(this.userList);
this.mockMvc.perform(get("/api/users"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.size()", is(userList.size())));
}
@Test
void shouldFindUserById() throws Exception {
Long userId = 1L;
User user = new User(userId, "newuser1@gmail.com", "pwd", "Name");
given(userService.findUserById(userId)).willReturn(Optional.of(user));
this.mockMvc.perform(get("/api/users/{id}", userId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.email", is(user.getEmail())))
.andExpect(jsonPath("$.password", is(user.getPassword())))
.andExpect(jsonPath("$.name", is(user.getName())))
;
}
@Test
void shouldReturn404WhenFetchingNonExistingUser() throws Exception {
Long userId = 1L;
given(userService.findUserById(userId)).willReturn(Optional.empty());
this.mockMvc.perform(get("/api/users/{id}", userId))
.andExpect(status().isNotFound());
}
@Test
void shouldCreateNewUser() throws Exception {
given(userService.createUser(any(User.class))).willAnswer((invocation) -> invocation.getArgument(0));
User user = new User(null, "newuser1@gmail.com", "pwd", "Name");
this.mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON_UTF8)
.content(objectMapper.writeValueAsString(user)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.email", is(user.getEmail())))
.andExpect(jsonPath("$.password", is(user.getPassword())))
.andExpect(jsonPath("$.name", is(user.getName())))
;
}
@Test
void shouldReturn400WhenCreateNewUserWithoutEmail() throws Exception {
User user = new User(null, null, "pwd", "Name");
this.mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON_UTF8)
.content(objectMapper.writeValueAsString(user)))
.andExpect(status().isBadRequest())
.andExpect(header().string("Content-Type", is("application/problem+json")))
.andExpect(jsonPath("$.type", is("https://zalando.github.io/problem/constraint-violation")))
.andExpect(jsonPath("$.title", is("Constraint Violation")))
.andExpect(jsonPath("$.status", is(400)))
.andExpect(jsonPath("$.violations", hasSize(1)))
.andExpect(jsonPath("$.violations[0].field", is("email")))
.andExpect(jsonPath("$.violations[0].message", is("Email should not be empty")))
.andReturn()
;
}
@Test
void shouldUpdateUser() throws Exception {
Long userId = 1L;
User user = new User(userId, "user1@gmail.com", "pwd", "Name");
given(userService.findUserById(userId)).willReturn(Optional.of(user));
given(userService.updateUser(any(User.class))).willAnswer((invocation) -> invocation.getArgument(0));
this.mockMvc.perform(put("/api/users/{id}", user.getId())
.contentType(MediaType.APPLICATION_JSON_UTF8)
.content(objectMapper.writeValueAsString(user)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.email", is(user.getEmail())))
.andExpect(jsonPath("$.password", is(user.getPassword())))
.andExpect(jsonPath("$.name", is(user.getName())));
}
@Test
void shouldReturn404WhenUpdatingNonExistingUser() throws Exception {
Long userId = 1L;
given(userService.findUserById(userId)).willReturn(Optional.empty());
User user = new User(userId, "user1@gmail.com", "pwd", "Name");
this.mockMvc.perform(put("/api/users/{id}", userId)
.contentType(MediaType.APPLICATION_JSON_UTF8)
.content(objectMapper.writeValueAsString(user)))
.andExpect(status().isNotFound());
}
@Test
void shouldDeleteUser() throws Exception {
Long userId = 1L;
User user = new User(userId, "user1@gmail.com", "pwd", "Name");
given(userService.findUserById(userId)).willReturn(Optional.of(user));
doNothing().when(userService).deleteUserById(user.getId());
this.mockMvc.perform(delete("/api/users/{id}", user.getId()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.email", is(user.getEmail())))
.andExpect(jsonPath("$.password", is(user.getPassword())))
.andExpect(jsonPath("$.name", is(user.getName())));
}
@Test
void shouldReturn404WhenDeletingNonExistingUser() throws Exception {
Long userId = 1L;
given(userService.findUserById(userId)).willReturn(Optional.empty());
this.mockMvc.perform(delete("/api/users/{id}", userId))
.andExpect(status().isNotFound());
}
}
现在,我们已经对应用程序的各个组件进行了大量的单元测试。
但是,仍然有很多事情可能会出错,例如,我们可能会遇到一些属性配置问题,
数据库迁移脚本中可能会出现一些错误等等。
因此,让我们编写集成测试以更加确信我们的应用程序正常运行。
使用 TestContainer 进行集成测试
SpringBoot 对集成测试提供了出色的支持。我们可以使用@SpringBootTest注解来加载应用程序上下文并测试各种组件。
让我们从为UserController编写集成测试开始。
正如我之前提到的,我们想使用 Postgres 数据库而不是内存数据库进行测试。
添加以下依赖项。
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<version>1.11.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>1.11.3</version>
<scope>test</scope>
</dependency>
我们可以使用 TestContainers 对 JUnit 5 的支持,详情请见https://www.testcontainers.org/test_framework_integration/junit_5/。
然而,为每个测试或每个测试类启动和停止 Docker 容器可能会导致测试运行缓慢。
因此,我们将使用https://www.testcontainers.org/test_framework_integration/manual_lifecycle_control/#singleton-containers中提到的单例容器方法。
让我们创建一个基类AbstractIntegrationTest,以便我们所有的集成测试都可以扩展,而无需重复通用配置。
package com.sivalabs.myservice.common;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.util.TestPropertyValues;
import org.springframework.context.ApplicationContextInitializer;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.web.servlet.MockMvc;
import org.testcontainers.containers.PostgreSQLContainer;
import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT;
@Slf4j
@ActiveProfiles("test")
@SpringBootTest(webEnvironment = RANDOM_PORT)
@AutoConfigureMockMvc
@ContextConfiguration(initializers = {AbstractIntegrationTest.Initializer.class})
public abstract class AbstractIntegrationTest {
@Autowired
protected MockMvc mockMvc;
@Autowired
protected ObjectMapper objectMapper;
private static PostgreSQLContainer sqlContainer;
static {
sqlContainer = new PostgreSQLContainer("postgres:10.7")
.withDatabaseName("integration-tests-db")
.withUsername("sa")
.withPassword("sa");
sqlContainer.start();
}
public static class Initializer
implements ApplicationContextInitializer<ConfigurableApplicationContext> {
public void initialize(ConfigurableApplicationContext configurableApplicationContext) {
TestPropertyValues.of(
"spring.datasource.url=" + sqlContainer.getJdbcUrl(),
"spring.datasource.username=" + sqlContainer.getUsername(),
"spring.datasource.password=" + sqlContainer.getPassword()
).applyTo(configurableApplicationContext.getEnvironment());
}
}
}
我们使用@AutoConfigureMockMvc自动配置MockMvc,
并使用@SpringBootTest(webEnvironment = RANDOM_PORT)在随机可用端口上启动服务器。
我们已经启动了PostgreSQLContainer并使用@ContextConfiguration(initializers={AbstractIntegrationTest.Initializer.class})
配置动态数据库连接属性。
现在我们可以对UserController实现集成测试如下:
package com.sivalabs.myservice.web.controllers;
import com.sivalabs.myservice.common.AbstractIntegrationTest;
import com.sivalabs.myservice.entities.User;
import com.sivalabs.myservice.repositories.UserRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import java.util.ArrayList;
import java.util.List;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.Matchers.hasSize;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
class UserControllerIT extends AbstractIntegrationTest {
@Autowired
private UserRepository userRepository;
private List<User> userList = null;
@BeforeEach
void setUp() {
userRepository.deleteAll();
userList = new ArrayList<>();
this.userList.add(new User(1L, "user1@gmail.com", "pwd1","User1"));
this.userList.add(new User(2L, "user2@gmail.com", "pwd2","User2"));
this.userList.add(new User(3L, "user3@gmail.com", "pwd3","User3"));
userList = userRepository.saveAll(userList);
}
@Test
void shouldFetchAllUsers() throws Exception {
this.mockMvc.perform(get("/api/users"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.size()", is(userList.size())));
}
@Test
void shouldFindUserById() throws Exception {
User user = userList.get(0);
Long userId = user.getId();
this.mockMvc.perform(get("/api/users/{id}", userId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.email", is(user.getEmail())))
.andExpect(jsonPath("$.password", is(user.getPassword())))
.andExpect(jsonPath("$.name", is(user.getName())))
;
}
@Test
void shouldCreateNewUser() throws Exception {
User user = new User(null, "user@gmail.com", "pwd", "name");
this.mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON_UTF8)
.content(objectMapper.writeValueAsString(user)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.email", is(user.getEmail())))
.andExpect(jsonPath("$.password", is(user.getPassword())))
.andExpect(jsonPath("$.name", is(user.getName())));
}
@Test
void shouldReturn400WhenCreateNewUserWithoutEmail() throws Exception {
User user = new User(null, null, "pwd", "Name");
this.mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON_UTF8)
.content(objectMapper.writeValueAsString(user)))
.andExpect(status().isBadRequest())
.andExpect(header().string("Content-Type", is("application/problem+json")))
.andExpect(jsonPath("$.type", is("https://zalando.github.io/problem/constraint-violation")))
.andExpect(jsonPath("$.title", is("Constraint Violation")))
.andExpect(jsonPath("$.status", is(400)))
.andExpect(jsonPath("$.violations", hasSize(1)))
.andExpect(jsonPath("$.violations[0].field", is("email")))
.andExpect(jsonPath("$.violations[0].message", is("Email should not be empty")))
.andReturn()
;
}
@Test
void shouldUpdateUser() throws Exception {
User user = userList.get(0);
user.setPassword("newpwd");
user.setName("NewName");
this.mockMvc.perform(put("/api/users/{id}", user.getId())
.contentType(MediaType.APPLICATION_JSON_UTF8)
.content(objectMapper.writeValueAsString(user)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.email", is(user.getEmail())))
.andExpect(jsonPath("$.password", is(user.getPassword())))
.andExpect(jsonPath("$.name", is(user.getName())));
}
@Test
void shouldDeleteUser() throws Exception {
User user = userList.get(0);
this.mockMvc.perform(
delete("/api/users/{id}", user.getId()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.email", is(user.getEmail())))
.andExpect(jsonPath("$.password", is(user.getPassword())))
.andExpect(jsonPath("$.name", is(user.getName())));
}
}
UserControllerIT测试与UserControllerTest非常相似,不同之处在于 ApplicationContext 的加载方式。 使用@SpringBootTest时, SpringBoot 实际上会通过加载整个应用程序来启动应用程序,因此 如果配置错误,测试就会失败。
接下来,我们将使用@DataJpaTest为UserRepository编写一个测试。 但我们希望针对真实的数据库运行测试,而不是使用内存数据库。 我们可以使用@AutoConfigureTestDatabase(replace=AutoConfigureTestDatabase.Replace.NONE)来关闭内存数据库,并使用配置的数据库。
让我们创建PostgreSQLContainerInitializer,以便任何存储库测试都可以使用它来配置动态 Postgres 数据库属性。
package com.sivalabs.myservice.common;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.test.util.TestPropertyValues;
import org.springframework.context.ApplicationContextInitializer;
import org.springframework.context.ConfigurableApplicationContext;
import org.testcontainers.containers.PostgreSQLContainer;
@Slf4j
public class PostgreSQLContainerInitializer
implements ApplicationContextInitializer<ConfigurableApplicationContext> {
private static PostgreSQLContainer sqlContainer;
static {
sqlContainer = new PostgreSQLContainer("postgres:10.7")
.withDatabaseName("integration-tests-db")
.withUsername("sa")
.withPassword("sa");
sqlContainer.start();
}
public void initialize (ConfigurableApplicationContext configurableApplicationContext){
TestPropertyValues.of(
"spring.datasource.url=" + sqlContainer.getJdbcUrl(),
"spring.datasource.username=" + sqlContainer.getUsername(),
"spring.datasource.password=" + sqlContainer.getPassword()
).applyTo(configurableApplicationContext.getEnvironment());
}
}
现在我们可以按如下方式创建UserRepositoryTest:
package com.sivalabs.myservice.repositories;
import com.sivalabs.myservice.common.PostgreSQLContainerInitializer;
import com.sivalabs.myservice.entities.User;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.test.context.ContextConfiguration;
import javax.persistence.EntityManager;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
@Slf4j
@DataJpaTest
@AutoConfigureTestDatabase(replace= AutoConfigureTestDatabase.Replace.NONE)
@ContextConfiguration(initializers = {PostgreSQLContainerInitializer.class})
class UserRepositoryTest {
@Autowired
EntityManager entityManager;
@Autowired
private UserRepository userRepository;
@Test
void shouldReturnUserGivenValidCredentials() {
User user = new User(null, "test@gmail.com", "test", "Test");
entityManager.persist(user);
Optional<User> userOptional = userRepository.login("test@gmail.com", "test");
assertThat(userOptional).isNotEmpty();
}
}
好吧,我想我们了解了一些如何使用各种 SpringBoot 功能编写单元测试和集成测试的知识。
我们生活在一个微服务的世界里,我们的服务很有可能与其他微服务进行通信。
我们该如何测试这些集成点?该如何验证超时场景?
当然,我们可以使用 Mock 对象,然后祈祷它在生产环境中能够正常工作 :-)
或者我们可以使用像MockServer这样的库来模拟服务之间的通信。
使用 MockServer 测试微服务集成
假设我们的应用程序需要获取用户的 GitHub 个人资料。我们可以使用 GitHub REST API 来获取用户个人资料。
此外,我们希望调用在 2 秒后超时,如果到那时还没有收到响应,则返回默认的用户响应。
我们可以使用Hystrix实现如下功能:
应用程序.属性
githuub.api.base-url=https://api.github.com
package com.sivalabs.myservice.config;
import lombok.Data;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@Component
@Data
public class ApplicationProperties {
@Value("${githuub.api.base-url}")
private String githubBaseUrl;
}
注册RestTemplate bean 并使用@EnableCircuitBreaker启用 Hystrix CircuitBreaker 。
package com.sivalabs.myservice;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.circuitbreaker.EnableCircuitBreaker;
import org.springframework.context.annotation.Bean;
import org.springframework.web.client.RestTemplate;
@SpringBootApplication
@EnableCircuitBreaker
public class Application
{
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}
}
创建GithubUser类,保存来自 GitHub API 的响应。
package com.sivalabs.myservice.model;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
@Data
public class GithubUser {
private Long id;
private String login;
private String url;
private String name;
@JsonProperty("public_repos")
private int publicRepos;
private int followers;
private int following;
}
创建使用RestTemplate与 GitHub REST API 通信的GithubService,如下所示:
package com.sivalabs.myservice.services;
import com.netflix.hystrix.contrib.javanica.annotation.HystrixCommand;
import com.netflix.hystrix.contrib.javanica.annotation.HystrixProperty;
import com.sivalabs.myservice.config.ApplicationProperties;
import com.sivalabs.myservice.model.GithubUser;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
@Service
@Slf4j
public class GithubService {
private final ApplicationProperties properties;
private final RestTemplate restTemplate;
@Autowired
public GithubService(ApplicationProperties properties, RestTemplate restTemplate) {
this.properties = properties;
this.restTemplate = restTemplate;
}
@HystrixCommand(fallbackMethod = "getDefaultUser", commandProperties = {
@HystrixProperty(name = "execution.isolation.thread.timeoutInMilliseconds", value = "2000")
})
public GithubUser getGithubUserProfile(String username) {
log.info("GithubBaseUrl:"+properties.getGithubBaseUrl());
return this.restTemplate.getForObject(properties.getGithubBaseUrl() + "/users/" + username, GithubUser.class);
}
GithubUser getDefaultUser(String username) {
log.info("---------getDefaultUser-----------");
GithubUser user = new GithubUser();
user.setId(-1L);
user.setLogin("guest");
user.setName("Guest");
user.setPublicRepos(0);
return user;
}
}
让我们创建一个带有端点的GithubController来返回用户的 GitHub 个人资料。
package com.sivalabs.myservice.web.controllers;
import com.sivalabs.myservice.model.GithubUser;
import com.sivalabs.myservice.services.GithubService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/github")
public class GithubController {
private final GithubService githubService;
@Autowired
public GithubController(GithubService githubService) {
this.githubService = githubService;
}
@GetMapping("/users/{username}")
public GithubUser getGithubUserProfile(@PathVariable String username) {
return githubService.getGithubUserProfile(username);
}
}
我们可以使用MockServer来模拟依赖的微服务响应,以便我们可以在各种场景中验证我们的应用程序行为。
我们可以使用TestContainers 支持来启动 MockServer Docker 容器,如下所示:
添加以下依赖项:
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mockserver</artifactId>
<version>1.11.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mock-server</groupId>
<artifactId>mockserver-netty</artifactId>
<version>5.5.1</version>
<scope>test</scope>
</dependency>
在AbstractIntegrationTest中添加MockServerContainer配置如下:
import org.mockserver.client.MockServerClient;
import org.testcontainers.containers.MockServerContainer;
@Slf4j
@ActiveProfiles("test")
@SpringBootTest(webEnvironment = RANDOM_PORT)
@AutoConfigureMockMvc
@ContextConfiguration(initializers = {AbstractIntegrationTest.Initializer.class})
public abstract class AbstractIntegrationTest {
...
...
private static MockServerContainer mockServerContainer;
static {
....
....
mockServerContainer = new MockServerContainer();
mockServerContainer.start();
}
protected MockServerClient mockServerClient = new MockServerClient(
mockServerContainer.getContainerIpAddress(),
mockServerContainer.getServerPort());
public static class Initializer
implements ApplicationContextInitializer<ConfigurableApplicationContext> {
public void initialize(ConfigurableApplicationContext configurableApplicationContext) {
TestPropertyValues.of(
"spring.datasource.url=" + sqlContainer.getJdbcUrl(),
"spring.datasource.username=" + sqlContainer.getUsername(),
"spring.datasource.password=" + sqlContainer.getPassword(),
"githuub.api.base-url=" + mockServerContainer.getEndpoint()
).applyTo(configurableApplicationContext.getEnvironment());
}
}
}
请注意,我们正在声明MockServerContainer并使用"githuub.api.base-url="+mockServerContainer.getEndpoint()注入端点 URL 。
此外,我们还创建了MockServerClient,我们将使用它来设置预期响应。
package com.sivalabs.myservice.web.controllers;
import com.sivalabs.myservice.common.AbstractIntegrationTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockserver.model.Header;
import org.mockserver.verify.VerificationTimes;
import java.util.concurrent.TimeUnit;
import static org.hamcrest.CoreMatchers.is;
import static org.mockserver.model.HttpRequest.request;
import static org.mockserver.model.HttpResponse.response;
import static org.mockserver.model.JsonBody.json;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class GithubControllerIT extends AbstractIntegrationTest {
@BeforeEach
void setup() {
mockServerClient.reset();
}
@Test
void shouldGetGithubUserProfile() throws Exception {
String username = "sivaprasadreddy";
mockGetUserFromGithub(username);
this.mockMvc.perform(get("/api/github/users/{username}", username))
.andExpect(status().isOk())
.andExpect(jsonPath("$.login", is(username)))
.andExpect(jsonPath("$.name", is("K. Siva Prasad Reddy")))
.andExpect(jsonPath("$.public_repos", is(50)))
;
verifyMockServerRequest("GET", "/users/.*", 1);
}
private void mockGetUserFromGithub(String username) {
mockServerClient.when(
request().withMethod("GET").withPath("/users/.*"))
.respond(
response()
.withStatusCode(200)
.withHeaders(new Header("Content-Type", "application/json; charset=utf-8"))
.withBody(json("{ " +
"\"login\": \""+username+"\", " +
"\"name\": \"K. Siva Prasad Reddy\", " +
"\"public_repos\": 50 " +
"}"))
);
}
private void verifyMockServerRequest(String method, String path, int times) {
mockServerClient.verify(
request()
.withMethod(method)
.withPath(path),
VerificationTimes.exactly(times)
);
}
}
注意,我们在mockServerClient上为<githuub.api.base-url>/users/.* URL 模式设置了预期的 JSON 响应。 因此,当我们调用http://localhost:8080/api/github/users/{username}时,GithubController 将反过来调用 GithubService,后者会调用<githuub.api.base-url>/users/{username}并返回我们使用mockServerClient设置的模拟 JSON 响应。
我们还可以模拟故障和超时场景,如下所示:
@Test
void shouldGetDefaultUserProfileWhenFetchingFromGithubFails() throws Exception {
mockGetUserFromGithubFailure();
this.mockMvc.perform(get("/api/github/users/{username}", "dummy"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.login", is("guest")))
.andExpect(jsonPath("$.name", is("Guest")))
.andExpect(jsonPath("$.public_repos", is(0)))
;
verifyMockServerRequest("GET", "/users/.*", 1);
}
@Test
void shouldGetDefaultUserProfileWhenFetchingFromGithubTimeout() throws Exception {
mockGetUserFromGithubDelayResponse();
this.mockMvc.perform(get("/api/github/users/{username}", "dummy"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.login", is("guest")))
.andExpect(jsonPath("$.name", is("Guest")))
.andExpect(jsonPath("$.public_repos", is(0)))
;
verifyMockServerRequest("GET", "/users/.*", 1);
}
private void mockGetUserFromGithubDelayResponse() {
mockServerClient.when(
request().withMethod("GET").withPath("/users/.*"))
.respond(response().withStatusCode(200).withDelay(TimeUnit.SECONDS, 10));
}
private void mockGetUserFromGithubFailure() {
mockServerClient.when(
request().withMethod("GET").withPath("/users/.*"))
.respond(response().withStatusCode(404));
}
在shouldGetDefaultUserProfileWhenFetchingFromGithubFails()测试中,我们设置模拟服务器
以404 错误响应,以验证Hystrix 回退方法是否有效。
类似地,在 shouldGetDefaultUserProfileWhenFetchingFromGithubTimeout() 测试中,我们设置模拟服务器
以10 秒的延迟进行响应,以验证Hystrix 超时是否有效。
确保对@BeforeEach方法中的每个测试使用mockServerClient.reset()重置mockServerClient,以重置上次测试运行中设置的任何期望。
本文的示例应用程序代码可以在https://github.com/sivaprasadreddy/spring-boot-tutorials/tree/master/testing/springboot-testing-demo找到
我希望我们已经涵盖了 SpringBoot 应用程序中许多常见的测试场景。
鏂囩珷鏉ユ簮锛�https://dev.to/sivalabs/testing-springboot-applications-4i5p