测试SpringBoot应用程序
SpringBoot是用于构建基于Java的REST API的最受欢迎的技术堆栈。
在本教程中,我们将学习如何为SpringBoot应用程序编写测试。
- 创建SpringBoot应用程序
- 使用JUnit 5和Mockito进行单元测试
- 使用TestContainers进行集成测试
- 使用MockServer测试MicroService集成
众所周知,我们编写单元测试来测试单个组件(类)的行为
而我们编写集成测试来测试可能涉及与多个组件交互的功能。
在大多数情况下,一个组件将依赖于其他组件,因此在执行单元测试时,我们应该模拟
使用Mockito之类的框架来实现具有所需行为的依赖关系。
那么,问题是我们如何在SpringBoot应用程序中实现单元测试和集成测试?继续阅读:-)
可以在https://github.com/sivaprasadreddy/spring-boot-tutorials/tree/master/testing/springboot-testing-demo中找到本文的示例应用程序代码。
创建SpringBoot应用程序
让我们考虑一个场景,其中我们正在构建一个REST API来管理用户。
如果我们遵循典型的3层分层架构,那么我们可能会有JPA实体用户,Spring Data JPA存储库UserRepository,
UserService和UserController实现CRUD操作,如下所示:
首先,创建具有以下依赖关系的SpringBoot应用程序:
pom.xml
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">
4.0.0
com.sivalabs
springboot-testing-demo
jar
1.0-SNAPSHOT
springboot-testing-demo
org.springframework.boot
spring-boot-starter-parent
2.1.8.RELEASE
UTF-8
1.8
org.springframework.boot
spring-boot-maven-plugin
maven-failsafe-plugin
org.springframework.boot
spring-boot-starter-actuator
org.springframework.boot
spring-boot-starter-web
org.springframework.boot
spring-boot-starter-data-jpa
org.springframework.boot
spring-boot-devtools
true
org.springframework.boot
spring-boot-starter-test
test
org.projectlombok
lombok
true
com.h2database
h2
runtime
org.postgresql
postgresql
runtime
默认情况下,JUnit 4附带有spring-boot-starter-test作为测试框架。
我们可以排除JUnit4并添加JUnit 5依赖项,如下所示:
org.springframework.boot
spring-boot-starter-test
test
junit
junit
org.junit.jupiter
junit-jupiter-engine
test
org.junit.jupiter
junit-jupiter-params
test
让我们为用户创建JPA实体,存储库,服务和控制器,如下所示:
User.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;
}
UserRepository.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);
}
UserService.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);
}
}
UserController.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问题Web的异常处理
我们将使用Zalando Problem Web SpringBoot启动程序来处理
例外,它将自动将应用程序错误转换为JSON响应。
仅添加以下依赖关系就足以开始使用Zalando Problem Web,当然,如果需要,您可以对其进行自定义。
0.25.0
...
...
org.zalando
problem-spring-web-starter
${problem-spring-web.version}
pom
现在让我们看看如何为该功能编写单元测试和集成测试。
使用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:
添加模仿-Junit-Jupiter依赖
org.mockito
mockito-junit-jupiter
test
使用@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加载控制器时,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数据库而不是内存数据库进行测试。
添加以下依赖项。
org.testcontainers
postgresql
1.11.3
test
org.testcontainers
junit-jupiter
1.11.3
test
我们可以使用https://www.testcontainers.org/test_framework_integration/junit_5/中提到的对JUnit 5的TestContainers支持。
但是,为每个测试或每个测试类启动和停止docker容器可能会导致测试运行缓慢。
因此,我们将使用在https://www.testcontainers.org/test_framework_integration/manual_lifecycle_control/#singleton-containers中提到的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测试MicroService集成
假设从我们的应用程序中,我们想要获取用户的GitHub个人资料。我们可以使用GitHub REST API来获取用户个人资料。
另外,我们希望在2秒钟后使呼叫超时,如果到那时仍未收到响应,我们将返回默认的用户响应。
我们可以使用Hystrix如下实现:
application.properties
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;
}
创建如下的GithubService,它使用RestTemplate与GitHub REST API通讯:
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容器:
添加以下依赖项:
org.testcontainers
mockserver
1.11.3
test
org.mock-server
mockserver-netty
5.5.1
test
在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)
);
}
}
请注意,我们正在为设置预期的JSON响应
因此,当我们调用http:// localhost:8080 / api / github / users / {username}时,GithubController会依次调用GithubService,从而调用
我们还可以模拟失败和超时情况,如下所示:
@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()来重置模拟服务器客户端,以重置在先前测试运行中设置的任何期望。
可以在https://github.com/sivaprasadreddy/spring-boot-tutorials/tree/master/testing/springboot-testing-demo中找到本文的示例应用程序代码。
我希望我们已经涵盖了SpringBoot应用程序中的许多常见测试场景。