原文鏈接:https://dzone.com/articles/advanced-functional-testing-in-spring-boot-by-usin
作者:Taras Danylchuk
譯者:liumapp
來源:SpringForAll社區


在Docker中運行Spring Boot的高級功能測試


想要學習更多有關Spring Boot項目的功能測試嗎?閱讀這篇博客可以讓您掌握如何利用Docker容器進行測試。

概覽

本文重點介紹如何使用Spring Boot進行功能測試的一些最佳實踐。我們將演示如何在不設置模擬環境的情況下將服務作爲黑盒測試的高級方法。

本文是我之前這篇文章 Native Integration Testing in Spring Boot 的後續。

因此我將參考上一篇文章來介紹這兩種測試方法的區別。

我建議你在閱讀這篇文章之前先了解上一篇文章。

理論

讓我們從功能測試的定義開始(來自於Techopedia):

功能測試是在軟件開發過程中使用的軟件測試流程,通過測試來確保軟件符合所有的預期需求。

功能測試也是一種檢查軟件的方法,通過這種方法來確保它具有指定的所有必須功能。

雖然這個解釋看起來有點讓人迷惑,但不需要擔心——接下來的定義提供了更進一步的解釋(來自於Techopedia):

功能測試主要用於驗證一個軟件是否提供最終用戶或業務所需要的輸出。

通常,功能測試涉及評估和比較每個軟件功能與業務需求。

通過向軟件提供一些相關輸入進行測試,以便評估軟件的輸出並查看其與基本要求相比是符合、關聯還是變化的。

此外,功能測試還可以檢查軟件的可用性,例如確保導航功能能夠按要求工作。

在我們的例子中,我們將微服務作爲一個軟件,這個軟件將根據最終用戶的要求來提供一些輸出。

目的

功能測試應涵蓋我們應用程序的以下方面:

上下文啓動 - 這可確保服務在上下文中沒有衝突,並且可以在沒有問題的情況下進行初始化。

業務需求/用戶故事 - 包括所請求的功能。

基本上,每個(或大多數)用戶的故事都應該有自己的專用功能測試。

我們不需要編寫上下文啓動測試,因爲只要有一個功能要測試,那麼上下文啓動無論如何都會被測試到的。

實踐

爲了演示如何使用我們的最佳實踐,我們需要編寫一些示範服務代碼。

就讓我們從頭開始吧。

任務

我們的新服務有以下要求:

用於存儲和檢索用戶詳細信息的REST API。

通過REST檢索聯繫服務中的聯繫人詳細信息,從而檢索用戶詳細信息的REST API。

架構設計

對於此任務,我們將基於Spring平臺作爲框架,使用Spring Boot作爲應用的啓動者。

爲了存儲用戶詳細信息,我們將使用MariaDB數據庫。

由於服務應存儲和檢索用戶詳細信息,因此將其命名爲用戶詳細信息服務是合乎邏輯的。

在實現之前,應該使用組件圖來更好地理解系統的主要組件:


在Docker中運行Spring Boot的高級功能測試


實現

以下示例代碼包含許多Lombok註釋。

您可以在網站上的docs文件中找到每個註釋的說明。

模型

用戶詳情模型:

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

用戶聯繫人模型:

@Value
public class UserContacts {
String email;
String phone;
}

具有彙總信息的用戶類:

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

REST API

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

按ID獲取用戶彙總數據

按ID保存用戶的用戶詳細信息

按ID獲取用戶詳細信息

客戶聯繫人服務

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

詳細信息實體及其存儲庫

@Entity
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserDetailsEntity {
@Id
private Long id;
@Column
private String firstName;
@Column
private String lastName;
}
@Repository
public interface UserDetailsRepository extends JpaRepository {
}
用戶服務
@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);
}
}

從DB檢索用戶詳細信息

從聯繫人服務中檢索用戶聯繫人

返回具有彙總數據的用戶

應用啓動類和它的配置文件

UserDetailsServiceApplication.java
@SpringBootApplication
public class UserDetailsServiceApplication {
public static void main(String[] args) {
SpringApplication.run(UserDetailsServiceApplication.class, args);
}
}
application.properties:
#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

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。稍後將對此進行說明。

目錄結構


在Docker中運行Spring Boot的高級功能測試


這幾乎是我們爲滿足初始要求所需要的一切:保存和檢索用戶詳細信息,通過聯繫人檢索的用戶詳細信息。

功能測試

是時候添加功能測試了!對於TDD(測試驅動開發)是什麼,您需要在具體實現之前閱讀本節。

位置

在開始之前,我們需要選擇功能測試的位置;

有兩個相對合適的地方:

通過一個獨立的文件夾放在單元測試下:


在Docker中運行Spring Boot的高級功能測試


這是開始添加功能測試最簡單,最快速的方法,雖然它有一個很大的缺點:如果你想單獨運行單元測試,你需要排除功能測試文件夾。

那爲什麼不能每次修改代碼時都運行所有測試呢?

因爲功能測試在大多數情況下與單元測試相比具有巨大的執行時間,因此應單獨運行以節省開發時間。

做爲一個獨立項目放置在父項目下:


在Docker中運行Spring Boot的高級功能測試


父POM(聚合項目)

Service項目

功能測試項目

這種方法優於前一種方法 - 我們在服務單元測試中有一個獨立的功能測試模塊,因此我們可以通過單獨運行單元測試或功能測試來輕鬆驗證邏輯。另一方面,這種方法需要一個多模塊項目結構,與單模塊項目相比,這種結構更加困難。

您可能已經從service的pom.xml中猜到,對於我們的情況,我們將選擇第二種方法。

父pom.xml文件


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


spring-boot-starter-parent是父POM的父項目。通過這種方式,我們爲Spring提供了依賴管理。

模塊聲明。注意:順序很重要,功能測試應始終放置在最後。

案例

對於挑選案例以涵蓋功能測試,我們需要考慮兩件大事:

功能需求 - 基本上,每個需求都應有自己的功能測試。

執行時間長 - 案例應該側重於應用程序的關鍵部分,這與單元測試相反的是它還要涵蓋每個次要案例。否則,構建時間將是巨大的。

構建

是的,測試還需要考慮構建文件,尤其是那些受執行時間影響程度比較大的功能測試,比如一些業務邏輯可能會隨着時間的推移變得過於複雜。

此外,功能測試應該是可維護的。這意味着,在功能轉換的情況下,功能測試對開發人員來說不是一件令人頭疼的問題。

步驟

步驟(也稱爲固定節點)是一種封裝每個通信通道邏輯的方法。

每個通道都應有自己的步驟對象,與其他步驟隔離。

在我們的例子中,我們有兩個通信渠道:

用戶詳細信息服務的REST API(輸入頻道)

聯繫人服務的REST API(輸出頻道)

對於輸入頻道的REST,我們將使用名爲REST Assured的庫。

與我們之前使用MockMvc進行驗證REST API的集成測試相比,這裏我們使用更多的黑盒測試來避免測試模擬對象破壞Spring的上下文。

至於REST輸出頻道,我們將使用WireMock。

我們不會讓Spring用REST模板替換模擬的模板。

相反,WireMock引擎使用的jetty服務器將與我們的服務一起啓動,以模擬真正的外部REST服務。

用戶詳情步驟

@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。

聯繫人服務步驟

@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文件中,如下所示:


com.playtika.testcontainers
embedded-mariadb
1.9
test

如果您的服務不使用Spring Cloud,則應把下述依賴跟上述依賴一併添加:


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

它需要在Spring Boot上下文啓動之前爲dockerized資源進行引導。

這種方法顯然有很多優點。

由於我們擁有“真實”資源,因此如果無法對所需資源進行真正的連接測試,則無需在代碼中編寫解決辦法。

不幸的是,這個解決方案帶來了一個巨大的缺點 - 測試只能在安裝Docker的環境中運行。

這意味着您的工作站和CI工具應該安裝Docker。

此外,您應該準備好更多的時間來執行您的測試。

父測試類

因爲執行時間很重要,所以我們需要避免對每個測試進行多個上下文加載,因此Docker容器將對所有測試僅啓動一次。

Spring默認啓用了上下文緩存功能,但我們需要小心使用:通過添加簡單的@MockBean註解,我們將強制Spring爲模擬的bean創建一個新的上下文而不是重用現有的上下文。

此問題的解決方案是創建單個父抽象類,該類將包含所有需要的Spring註解,以確保所有測試套件重用單個上下文:

@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 {
}
}

指定Spring Boot的測試註解以加載我們服務的主要配置類。

引導使用Web生產環境(默認情況下將使用模擬的環境)。

測試環境的配置項被加載在了application-test.properties文件中,其中包含了一些生產環境的屬性,例如URL,用戶,密碼等。

WireMockRulet通過啓動jetty服務器以便在提供的端口上進行連接。

步驟是自動加載爲protected屬性的,這樣每一個步驟會在每一個測試得到訪問。

@TestConfiguration註解會通過掃描包名來加載上下文的步驟。

在這裏,我們試圖不修改上下文,這樣的好處時在後期用於生產環境時,只需要往上下文添加一些util項就可以了,例如步驟和屬性覆蓋。

使用@MockBean註解是不好的做法,因爲它會用mock替換部分應用程序,所以這部分程序將是沒有經過測試的。

在不可避免的情況下 - 例如在業務中獲取當前時間System.currentTimeMillis(),這樣的代碼應該被重構,最好使用Clock對象:clock.millis()。並且,在功能測試中,應該模擬Clock對象以便驗證結果。

測試屬性

application-test.properties:
#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}
#3
spring.jpa.hibernate.ddl-auto=create-drop

使用WireMock jetty服務器節點而不是生產環境下的聯繫人服務URL。

數據庫屬性的重載。注意:這些屬性由spring-boo-test-containers庫提供。

在測試中,數據庫的表將由Hibernate創建。

自我測試

爲了進行這項測試,我們做了很多準備工作,讓我們先瞅一眼:

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文件


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進行加載。

目錄結構


在Docker中運行Spring Boot的高級功能測試


總而言之,我們有了下一個項目的目錄結構。

向服務添加功能不會改變當前目錄結構,只會擴展它。

通過添加額外的步驟,比如添加了更多的通信渠道,那麼utils文件夾下可以添加很多常用方法;

比如帶有測試數據的新文件; 當然還有針對每個功能要求的附加測試。

總結

在本文中,我們基於給定的要求構建了一個新的微服務,並通過功能測試來滿足這些要求。

在測試中,我們使用黑盒類型的測試,我們嘗試不改變應用程序的內部部分,而是作爲普通客戶端從外部與它進行通信,以儘可能多地模擬生產行爲。

同時,我們奠定了功能測試架構的基礎,因此未來的服務更改不需要重構現有測試,並且儘可能將添加新的測試簡單化。

這個項目的源代碼都可以在GitHub上找到。

相關文章