0%

译:在Docker中运行Spring Boot的高级功能测试

译:在Docker中运行Spring Boot的高级功能测试

原文链接:https://dzone.com/articles/advanced-functional-testing-in-spring-boot-by-usin

作者:Taras Danylchuk

译者:liumapp

想要学习更多有关Spring Boot项目的功能测试吗?阅读这篇博客可以让您掌握如何利用Docker容器进行测试。

概览

本文重点介绍如何使用Spring Boot进行功能测试的一些最佳实践。我们将演示如何在不设置模拟环境的情况下将服务作为黑盒测试的高级方法。

本文是我之前这篇文章 Native Integration Testing in Spring Boot 的后续。

因此我将参考上一篇文章来介绍这两种测试方法的区别。

我建议你在阅读这篇文章之前先了解上一篇文章。

理论

让我们从功能测试的定义开始(来自于Techopedia):

功能测试是在软件开发过程中使用的软件测试流程,通过测试来确保软件符合所有的预期需求。 功能测试也是一种检查软件的方法,通过这种方法来确保它具有指定的所有必须功能。

虽然这个解释看起来有点让人迷惑,但不需要担心——接下来的定义提供了更进一步的解释(来自于Techopedia):

功能测试主要用于验证一个软件是否提供最终用户或业务所需要的输出。 通常,功能测试涉及评估和比较每个软件功能与业务需求。
通过向软件提供一些相关输入进行测试,以便评估软件的输出并查看其与基本要求相比是符合、关联还是变化的。
此外,功能测试还可以检查软件的可用性,例如确保导航功能能够按要求工作。
在我们的例子中,我们将微服务作为一个软件,这个软件将根据最终用户的要求来提供一些输出。

目的

功能测试应涵盖我们应用程序的以下方面:

  • 上下文启动 - 这可确保服务在上下文中没有冲突,并且可以在没有问题的情况下进行初始化。

  • 业务需求/用户故事 - 包括所请求的功能。

基本上,每个(或大多数)用户的故事都应该有自己的专用功能测试。

我们不需要编写上下文启动测试,因为只要有一个功能要测试,那么上下文启动无论如何都会被测试到的。

实践

为了演示如何使用我们的最佳实践,我们需要编写一些示范服务代码。

就让我们从头开始吧。

任务

我们的新服务有以下要求:

  • 用于存储和检索用户详细信息的REST API。

  • 通过REST检索联系服务中的联系人详细信息,从而检索用户详细信息的REST API。

架构设计

对于此任务,我们将基于Spring平台作为框架,使用Spring Boot作为应用的启动者。

为了存储用户详细信息,我们将使用MariaDB数据库。

由于服务应存储和检索用户详细信息,因此将其命名为用户详细信息服务是合乎逻辑的。

在实现之前,应该使用组件图来更好地理解系统的主要组件:

1.pic.jpg

实现

以下示例代码包含许多Lombok注释。

您可以在网站上的docs文件中找到每个注释的说明。

模型

用户详情模型:

1
2
3
@Value(staticConstructor = "of")
public class UserDetails {
String firstName; String lastName; public static UserDetails fromEntity(UserDetailsEntity entity) { return UserDetails.of(entity.getFirstName(), entity.getLastName()); } public UserDetailsEntity toEntity(long userId) { return new UserDetailsEntity(userId, firstName, lastName); }}

用户联系人模型:

1
2
3
@Value
public class UserContacts {
String email; String phone;}

具有汇总信息的用户类:

1
2
3
@Value(staticConstructor = "of")
public class User {
UserDetails userDetails; UserContacts userContacts;}

REST API

1
2
3
4
5
@RestController
@RequestMapping("user")
@AllArgsConstructor
public class UserController {
private final UserService userService; @GetMapping("/{userId}") //1 public User getUser(@PathVariable("userId") long userId) { return userService.getUser(userId); } @PostMapping("/{userId}/details") //2 public void saveUserDetails(@PathVariable("userId") long userId, @RequestBody UserDetails userDetails) { userService.saveDetails(userId, userDetails); } @GetMapping("/{userId}/details") //3 public UserDetails getUserDetails(@PathVariable("userId") long userId) { return userService.getDetails(userId); }}
  1. 按ID获取用户汇总数据

  2. 按ID保存用户的用户详细信息

  3. 按ID获取用户详细信息

客户联系人服务

1
2
3
@Component
public class ContactsServiceClient {
private final RestTemplate restTemplate; private final String contactsServiceUrl; public ContactsServiceClient(final RestTemplateBuilder restTemplateBuilder, @Value("${contacts.service.url}") final String contactsServiceUrl) { this.restTemplate = restTemplateBuilder.build(); this.contactsServiceUrl = contactsServiceUrl; } public UserContacts getUserContacts(long userId) { URI uri = UriComponentsBuilder.fromHttpUrl(contactsServiceUrl + "/contacts") .queryParam("userId", userId).build().toUri(); return restTemplate.getForObject(uri, UserContacts.class); }}

详细信息实体及其存储库

1
2
3
4
5
6
@Entity
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserDetailsEntity {
@Id private Long id; @Column private String firstName; @Column private String lastName;}
1
2
3
@Repository
public interface UserDetailsRepository extends JpaRepository {
}

用户服务

1
2
3
4
@Service
@AllArgsConstructor
public class UserService {
private final UserDetailsRepository userDetailsRepository; private final ContactsServiceClient contactsServiceClient; public User getUser(long userId) { UserDetailsEntity userDetailsEntity = userDetailsRepository.getOne(userId); //1 UserDetails userDetails = UserDetails.fromEntity(userDetailsEntity); UserContacts userContacts = contactsServiceClient.getUserContacts(userId); //2 return User.of(userDetails, userContacts); //3 } public void saveDetails(long userId, UserDetails userDetails) { UserDetailsEntity entity = userDetails.toEntity(userId); userDetailsRepository.save(entity); } public UserDetails getDetails(long userId) { UserDetailsEntity userDetailsEntity = userDetailsRepository.getOne(userId); return UserDetails.fromEntity(userDetailsEntity); }}
  1. 从DB检索用户详细信息

  2. 从联系人服务中检索用户联系人

  3. 返回具有汇总数据的用户

应用启动类和它的配置文件

UserDetailsServiceApplication.java

1
2
3
4

@SpringBootApplication
public class UserDetailsServiceApplication {
public static void main(String[] args) { SpringApplication.run(UserDetailsServiceApplication.class, args); }}

application.properties:

1
2
3
4
5
6
7
8
9
10
#contact service
contacts.service.url=http://www.prod.contact.service.com
#database
user.details.db.host=prod.maria.url.com
user.details.db.port=3306
user.details.db.schema=user_details
spring.datasource.url=jdbc:mariadb://${user.details.db.host}:${user.details.db.port}/${user.details.db.schema}
spring.datasource.username=prod-username
spring.datasource.password=prod-password
spring.datasource.driver-class-name=org.mariadb.jdbc.Driver

Mavan配置文件pom.xml

1
2
3
4

xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
4.0.0 user-details-service 0.0.1-SNAPSHOT jar User details service com.tdanylchuk functional-tests-best-practices 0.0.1-SNAPSHOT org.springframework.boot spring-boot-starter-data-jpa org.springframework.boot spring-boot-starter-web org.projectlombok lombok provided org.mariadb.jdbc mariadb-java-client 2.3.0 org.springframework.boot spring-boot-maven-plugin

注意:父级是自定义的functional-tests-best-practices项目,它继承了spring-boot-starter-parent。稍后将对此进行说明。

目录结构

2.pic.jpg

这几乎是我们为满足初始要求所需要的一切:保存和检索用户详细信息,通过联系人检索的用户详细信息。

功能测试

是时候添加功能测试了!对于TDD(测试驱动开发)是什么,您需要在具体实现之前阅读本节。

位置

在开始之前,我们需要选择功能测试的位置;

有两个相对合适的地方:

  • 通过一个独立的文件夹放在单元测试下:

    3.pic.jpg

    这是开始添加功能测试最简单,最快速的方法,虽然它有一个很大的缺点:如果你想单独运行单元测试,你需要排除功能测试文件夹。

    那为什么不能每次修改代码时都运行所有测试呢?

    因为功能测试在大多数情况下与单元测试相比具有巨大的执行时间,因此应单独运行以节省开发时间。

  • 做为一个独立项目放置在父项目下:

    4.pic.jpg

    1. 父POM(聚合项目)

    2. Service项目

    3. 功能测试项目

      这种方法优于前一种方法 - 我们在服务单元测试中有一个独立的功能测试模块,因此我们可以通过单独运行单元测试或功能测试来轻松验证逻辑。另一方面,这种方法需要一个多模块项目结构,与单模块项目相比,这种结构更加困难。

      您可能已经从service的pom.xml中猜到,对于我们的情况,我们将选择第二种方法。

父pom.xml文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53

xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
4.0.0 com.tdanylchuk functional-tests-best-practices 0.0.1-SNAPSHOT pom Functional tests best practices parent project org.springframework.boot spring-boot-starter-parent 2.0.4.RELEASE user-details-service user-details-service-functional-tests UTF-8 UTF-8 1.8
````

1. spring-boot-starter-parent是父POM的父项目。通过这种方式,我们为Spring提供了依赖管理。

2. 模块声明。注意:顺序很重要,功能测试应始终放置在最后。

### 案例

对于挑选案例以涵盖功能测试,我们需要考虑两件大事:

* 功能需求 - 基本上,每个需求都应有自己的功能测试。

* 执行时间长 - 案例应该侧重于应用程序的关键部分,这与单元测试相反的是它还要涵盖每个次要案例。否则,构建时间将是巨大的。

### 构建

是的,测试还需要考虑构建文件,尤其是那些受执行时间影响程度比较大的功能测试,比如一些业务逻辑可能会随着时间的推移变得过于复杂。

此外,功能测试应该是可维护的。这意味着,在功能转换的情况下,功能测试对开发人员来说不是一件令人头疼的问题。

### 步骤

步骤(也称为固定节点)是一种封装每个通信通道逻辑的方法。

每个通道都应有自己的步骤对象,与其他步骤隔离。

在我们的例子中,我们有两个通信渠道:

* 用户详细信息服务的REST API(输入频道)

* 联系人服务的REST API(输出频道)

对于输入频道的REST,我们将使用名为REST Assured的库。

与我们之前使用MockMvc进行验证REST API的集成测试相比,这里我们使用更多的黑盒测试来避免测试模拟对象破坏Spring的上下文。

至于REST输出频道,我们将使用WireMock。

我们不会让Spring用REST模板替换模拟的模板。

相反,WireMock引擎使用的jetty服务器将与我们的服务一起启动,以模拟真正的外部REST服务。

### 用户详情步骤

````java

@Component
public class UserDetailsServiceSteps implements ApplicationListener {
private int servicePort; public String getUser(long userId) { return given().port(servicePort) .when().get("user/" + userId) .then().statusCode(200).contentType(ContentType.JSON).extract().asString(); } public void saveUserDetails(long userId, String body) { given().port(servicePort).body(body).contentType(ContentType.JSON) .when().post("user/" + userId + "/details") .then().statusCode(200); } public String getUserDetails(long userId) { return given().port(servicePort) .when().get("user/" + userId + "/details") .then().statusCode(200).contentType(ContentType.JSON).extract().asString(); } @Override public void onApplicationEvent(@NotNull WebServerInitializedEvent webServerInitializedEvent) { this.servicePort = webServerInitializedEvent.getWebServer().getPort(); }}

就像你从steps对象中看到的,每个API都有自己的方法。

默认情况下,REST Assured将访问localhost的API,但需要指定端口号否则我们的服务将使用随机端口来启动。

为了区分端口号,我们应该从WebServerInitializedEvent中去获取端口。

注意:@LocalServerPort注解不能在此处使用,因为在Spring Boot-embedded容器启动之前就初始化了步骤的bean。

联系人服务步骤

1
2
3
@Component
public class ContactsServiceSteps {
public void expectGetUserContacts(long userId, String body) { stubFor(get(urlPathMatching("/contacts")).withQueryParam("userId", equalTo(String.valueOf(userId))) .willReturn(okJson(body))); }}

在这里,我们需要以与从我们的应用程序调用远程服务时相同的方式来模拟服务器的端口、参数等。

数据库

我们的服务是将数据存储在Maria DB中,但就功能测试而言,数据的存储位置并不重要,因此按照黑盒测试的要求,我们不需要在测试中提及数据库。

假设在未来,我们考虑将Maria DB更改为某些NoSQL解决方案,那么功能测试应不需要做出改动。

那么,为此解决方案是什么?

当然,我们可以使用嵌入式解决方案,就像在集成测试中使用H2数据库一样,但在生产时,我们的服务又将使用Maria DB,这可能会导致某些地方出错。

例如,我们有一个名为MAXVALUE的列,并针对H2运行测试,一切正常。但是,在生产中,服务失败了,因为这是MariaDB中的一个预留关键字,这意味着我们的测试不如预期的那么好,并且在将服务发布之前可能浪费大量时间来解决这个问题。

避免这种情况的唯一方法是在测试中使用真正的Maria DB。同时,我们需要确保我们的测试可以在本地执行,而无需设置Maria DB的任何其他临时环境。

为了解决这个问题,我们使用testcontainers项目,该项目提供常见的轻量级数据库实例:可以在Selenium Web浏览器或Docker容器中运行的。

但testcontainers库不支持Spring Boot的开箱即用。因此,我们将使用另一个名为testcontainers-spring-boot的库,而不是为MariaDB编写自定义的Generic Container并将其注入Spring Boot。testcontainers-spring-boot支持最常用的技术,并可以直接在您的服务中使用:MariaDB,Couchbase,Kafka,Aerospike,MemSQL,Redis,neo4j,Zookeeper,PostgreSQL,ElasticSearch等等。

要将真正的Maria DB注入我们的测试,我们只需要将相应的依赖项添加到我们的user-details-service-functional-tests项目pom.xml文件中,如下所示:

1
2

com.playtika.testcontainers embedded-mariadb 1.9 test

如果您的服务不使用Spring Cloud,则应把下述依赖跟上述依赖一并添加:

1
2

org.springframework.cloud spring-cloud-context 2.0.1.RELEASE test

它需要在Spring Boot上下文启动之前为dockerized资源进行引导。

这种方法显然有很多优点。

由于我们拥有“真实”资源,因此如果无法对所需资源进行真正的连接测试,则无需在代码中编写解决办法。

不幸的是,这个解决方案带来了一个巨大的缺点 - 测试只能在安装Docker的环境中运行。

这意味着您的工作站和CI工具应该安装Docker。

此外,您应该准备好更多的时间来执行您的测试。

父测试类

因为执行时间很重要,所以我们需要避免对每个测试进行多个上下文加载,因此Docker容器将对所有测试仅启动一次。

Spring默认启用了上下文缓存功能,但我们需要小心使用:通过添加简单的@MockBean注解,我们将强制Spring为模拟的bean创建一个新的上下文而不是重用现有的上下文。

此问题的解决方案是创建单个父抽象类,该类将包含所有需要的Spring注解,以确保所有测试套件重用单个上下文:

1
2
3
4
5
@RunWith(SpringRunner.class)
@SpringBootTest(
classes = UserDetailsServiceApplication.class, //1 webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) //2@ActiveProfiles("test") //3
public abstract class BaseFunctionalTest {
@Rule public WireMockRule contactsServiceMock = new WireMockRule(options().port(8777)); //4 @Autowired //5 protected UserDetailsServiceSteps userDetailsServiceSteps; @Autowired protected ContactsServiceSteps contactsServiceSteps; @TestConfiguration //6 @ComponentScan("com.tdanylchuk.user.details.steps") public static class StepsConfiguration { }}
  1. 指定Spring Boot的测试注解以加载我们服务的主要配置类。

  2. 引导使用Web生产环境(默认情况下将使用模拟的环境)。

  3. 测试环境的配置项被加载在了application-test.properties文件中,其中包含了一些生产环境的属性,例如URL,用户,密码等。

  4. WireMockRulet通过启动jetty服务器以便在提供的端口上进行连接。

  5. 步骤是自动加载为protected属性的,这样每一个步骤会在每一个测试得到访问。

  6. @TestConfiguration注解会通过扫描包名来加载上下文的步骤。

在这里,我们试图不修改上下文,这样的好处时在后期用于生产环境时,只需要往上下文添加一些util项就可以了,例如步骤和属性覆盖。

使用@MockBean注解是不好的做法,因为它会用mock替换部分应用程序,所以这部分程序将是没有经过测试的。

在不可避免的情况下 - 例如在业务中获取当前时间System.currentTimeMillis(),这样的代码应该被重构,最好使用Clock对象:clock.millis()。并且,在功能测试中,应该模拟Clock对象以便验证结果。

测试属性

application-test.properties:

1
2
3
4
5
6
7
8
9
#contact service                                          #1
contacts.service.url=http://localhost:8777
#database #2
user.details.db.host=${embedded.mariadb.host}
user.details.db.port=${embedded.mariadb.port}
user.details.db.schema=${embedded.mariadb.schema}
spring.datasource.username=${embedded.mariadb.user}
spring.datasource.password=${embedded.mariadb.password}
#3spring.jpa.hibernate.ddl-auto=create-drop
  1. 使用WireMock jetty服务器节点而不是生产环境下的联系人服务URL。

  2. 数据库属性的重载。注意:这些属性由spring-boo-test-containers库提供。

  3. 在测试中,数据库的表将由Hibernate创建。

自我测试

为了进行这项测试,我们做了很多准备工作,让我们先瞅一眼:

1
2
public class RestUserDetailsTest extends BaseFunctionalTest {
private static final long USER_ID = 32343L; private final String userContactsResponse = readFile("json/user-contacts.json"); private final String userDetails = readFile("json/user-details.json"); private final String expectedUserResponse = readFile("json/user.json"); @Test public void shouldSaveUserDetailsAndRetrieveUser() throws Exception { //when userDetailsServiceSteps.saveUserDetails(USER_ID, userDetails); //and contactsServiceSteps.expectGetUserContacts(USER_ID, userContactsResponse); //then String actualUserResponse = userDetailsServiceSteps.getUser(USER_ID); //expect JSONAssert.assertEquals(expectedUserResponse, actualUserResponse, false); }}

在以前,打桩和断言都是通过JSON文件来创建使用的。这种方式,把请求和响应的格式同时进行了验证。但在功能测试里,我们最好不要使用确定格式的测试数据,而是使用生产环境中请求/响应数据的副本。

由于整个逻辑封装在步骤、配置和JSON文件中,如果改动与功能无关,那么测试将保持不变。例如:

  • 响应数据格式的更改 - 只应修改JSON测试文件。

  • 联系人服务节点更改 - 应修改ContactsServiceSteps对象。

  • Maria DB替换为No SQL DB - 应修改pom.xml和test properties文件。

功能测试项目的POM文件

1
2
3
4

xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
4.0.0 user-details-service-functional-tests 0.0.1-SNAPSHOT User details service functional tests com.tdanylchuk functional-tests-best-practices 0.0.1-SNAPSHOT com.tdanylchuk user-details-service ${project.version} test org.springframework.boot spring-boot-starter-test test org.springframework.cloud spring-cloud-context 2.0.1.RELEASE test com.playtika.testcontainers embedded-mariadb 1.9 test com.github.tomakehurst wiremock 2.18.0 test io.rest-assured rest-assured test

用户详细信息服务作为依赖项添加,因此它可以由SpringBootTest进行加载。

目录结构

5.pic.jpg

总而言之,我们有了下一个项目的目录结构。

向服务添加功能不会改变当前目录结构,只会扩展它。

通过添加额外的步骤,比如添加了更多的通信渠道,那么utils文件夹下可以添加很多常用方法;

比如带有测试数据的新文件; 当然还有针对每个功能要求的附加测试。

总结

在本文中,我们基于给定的要求构建了一个新的微服务,并通过功能测试来满足这些要求。

在测试中,我们使用黑盒类型的测试,我们尝试不改变应用程序的内部部分,而是作为普通客户端从外部与它进行通信,以尽可能多地模拟生产行为。

同时,我们奠定了功能测试架构的基础,因此未来的服务更改不需要重构现有测试,并且尽可能将添加新的测试简单化。

这个项目的源代码都可以在GitHub上找到。