1. 概述
在本教程中,我們將瞭解流行的軟件測試模型——測試金字塔。
我們將探討其在微服務領域的應用。 在過程中,我們將開發一個示例應用程序以及符合該模型的相關測試。 此外,我們將嘗試理解使用模型的好處和限制。
2. 讓我們退後一步
在深入理解任何特定模型,例如測試金字塔之前,理解我們為什麼需要這些模型至關重要。
對軟件進行測試是內在的,也許可以追溯到軟件開發的整個歷史。軟件測試已經從手工測試發展到自動化,並且不斷進步。然而,目標始終如一——交付符合規範的軟件。
2.1. 測試類型
在實踐中,存在多種不同的測試類型,它們側重於特定的目標。不幸的是,這些測試的詞彙和理解存在相當大的差異。
讓我們回顧一些流行的且可能沒有歧義的測試:
- 單元測試: 單元測試是對小代碼單元的測試,最好是隔離的。這裏的目標是在不擔心代碼庫中其他部分的情況下,驗證最小的可測試代碼的行為。這自動意味着任何依賴項都需要用 mock 或 stub 或類似構造體替換。
- 集成測試: 雖然單元測試側重於代碼片段的內部,但事實是很多複雜性存在於其外部。代碼單元需要協同工作,通常與外部服務(如數據庫、消息代理或 Web 服務)一起工作。集成測試是對應用程序在與外部依賴項集成時,目標行為的測試。
- UI 測試: 我們開發的一些軟件通常通過界面進行消費,消費者可以與之交互。應用程序通常具有 Web 界面。然而,API 接口越來越受歡迎。UI 測試旨在測試這些接口的行為,這些接口通常具有高度交互的特性。這些測試可以以端到端方式進行,或者用户界面也可以單獨進行測試。
2.2. 手動測試與自動化測試
自測試開始以來,軟件測試一直採用手動方式進行,並且在今天仍然被廣泛採用。然而,很容易理解手動測試存在侷限性。 為了使測試有價值,測試必須全面且經常運行。
這一點在敏捷開發方法論和雲原生微服務架構中尤為重要。 然而,自動化測試的需求早在之前就得到了認識。
回想一下我們之前討論的不同類型的測試,隨着我們從單元測試移動到集成和 UI 測試,它們的複雜性和範圍都在增加。 因此,自動化單元測試更容易並帶來最大的收益。 隨着我們進一步深入,自動化測試變得越來越困難,收益也相對較小。
在某些方面,可以自動化測試大多數軟件行為。 但是,這必須與自動化所需的努力與收益進行理性權衡。
3. 什麼是測試金字塔?
現在我們已經充分了解了測試類型和工具,是時候理解什麼是測試金字塔。我們已經看到應該編寫的不同類型的測試。
但是,我們應該為每種類型編寫多少測試?有什麼好處或需要注意的陷阱?這些正是測試自動化模型,如測試金字塔所解決的問題。
Mike Cohn 在他的書《《成功地使用敏捷軟件開發——使用Scrum》》中提出了一個名為“測試金字塔”的構造。這提供了一個視覺表示,展示了我們應該在不同粒度級別編寫多少測試。
它的理念是,應該在最細粒度級別上最多,隨着測試範圍的擴大,應該逐漸減少。這形成了典型的金字塔形狀,因此得名:
雖然這個概念很簡單而優雅,但要有效地採用它卻常常是一個挑戰。重要的是要理解,我們不應被模型形狀和它提到的測試類型所困住。關鍵要點是:
- 我們必須編寫具有不同粒度級別的測試
- 隨着測試範圍的擴大,我們必須編寫更少的測試
4. 測試自動化工具
在主流編程語言中,有多種工具可用於編寫不同類型的測試。我們將重點介紹在 Java 世界中一些流行的選擇。
4.1. 單元測試
- 測試框架:在Java中,最流行的選擇是JUnit,它有一個下一代發佈版本,稱為JUnit5。在這個領域,其他流行的選擇包括TestNG,它與JUnit5相比提供了一些差異化的功能。然而,對於大多數應用程序,這兩種框架都是合適的選擇。
- 樁化:正如我們之前所看到的那樣,在執行單元測試時,我們通常希望排除大部分依賴項,如果不是全部。為此,我們需要一種機制來用測試雙(如樁或模擬對象)替換依賴項。Mockito是一個優秀的框架,用於為Java中的真實對象提供樁對象。
4.2. 集成測試
- 測試範圍:集成測試的範圍比單元測試更廣,但入口點通常是更高抽象級別的相同代碼。因此,適用於單元測試的測試框架也適用於集成測試。
- 樁化(Mocking):集成測試的目標是測試應用程序與真實集成時的行為。然而,我們可能不想實際訪問數據庫或消息代理。許多數據庫和類似服務都提供嵌入式版本,以便使用它們進行集成測試。
4.3. UI 測試
- 測試框架:UI 測試的複雜性取決於客户端處理軟件 UI 元素的程度。例如,網頁的行為可能取決於設備、瀏覽器甚至操作系統。Selenium 是模擬 Web 應用程序行為的流行選擇。但是,對於 REST API,像 REST-assured 這樣的框架是更好的選擇。
- 模擬:用户界面變得越來越交互式且使用 JavaScript 框架(如 Angular 和 React)進行客户端渲染。因此,使用像 Jasmine (https://jasmine.github.io/) 和 Mocha (https://mochajs.org/) 這樣的測試框架隔離測試這些 UI 元素更為合理。當然,我們應該將其與端到端測試結合使用。
5. 在實踐中採用原則
讓我們通過開發一個小應用程序來演示我們討論過的原則。我們將開發一個小型微服務,並瞭解如何編寫符合測試金字塔的測試。
微服務架構有助於將應用程序結構為一組圍繞領域邊界的鬆散耦合服務。Spring Boot 提供了一個極佳的平台,可以在短時間內使用用户界面和依賴項(如數據庫)來啓動微服務。
我們將利用這些功能來演示測試金字塔的實際應用。
5.1. 應用架構
我們將開發一個簡單的應用程序,允許我們存儲和查詢我們觀看過的電影:
如你所見,它具有三個端點的簡單 REST 控制器:
@RestController
public class MovieController {
@Autowired
private MovieService movieService;
@GetMapping("/movies")
public List<Movie> retrieveAllMovies() {
return movieService.retrieveAllMovies();
}
@GetMapping("/movies/{id}")
public Movie retrieveMovies(@PathVariable Long id) {
return movieService.retrieveMovies(id);
}
@PostMapping("/movies")
public Long createMovie(@RequestBody Movie movie) {
return movieService.createMovie(movie);
}
}控制器僅負責將請求路由到相應的服務,不涉及數據映射(marshalling)和反向映射(unmarshalling)的處理。
@Service
public class MovieService {
@Autowired
private MovieRepository movieRepository;
public List<Movie> retrieveAllMovies() {
return movieRepository.findAll();
}
public Movie retrieveMovies(@PathVariable Long id) {
Movie movie = movieRepository.findById(id)
.get();
Movie response = new Movie();
response.setTitle(movie.getTitle()
.toLowerCase());
return response;
}
public Long createMovie(@RequestBody Movie movie) {
return movieRepository.save(movie)
.getId();
}
}此外,我們還擁有一個JPA Repository,它映射到我們的持久化層:
@Repository
public interface MovieRepository extends JpaRepository<Movie, Long> {
}最後,我們的簡單領域實體用於存儲和傳遞電影數據:
@Entity
public class Movie {
@Id
private Long id;
private String title;
private String year;
private String rating;
// Standard setters and getters
}使用此簡單應用程序,我們現在準備好探索具有不同粒度和數量的測試。
5.2. 單元測試
首先,我們將瞭解如何為我們的應用程序編寫一個簡單的單元測試。如從該應用程序所示,大部分邏輯都集中在服務層。這要求我們對它進行廣泛且頻繁的測試——這非常適合單元測試:
public class MovieServiceUnitTests {
@InjectMocks
private MovieService movieService;
@Mock
private MovieRepository movieRepository;
@Before
public void setUp() throws Exception {
MockitoAnnotations.initMocks(this);
}
@Test
public void givenMovieServiceWhenQueriedWithAnIdThenGetExpectedMovie() {
Movie movie = new Movie(100L, "Hello World!");
Mockito.when(movieRepository.findById(100L))
.thenReturn(Optional.ofNullable(movie));
Movie result = movieService.retrieveMovies(100L);
Assert.assertEquals(movie.getTitle().toLowerCase(), result.getTitle());
}
}在這裏,我們使用 JUnit 作為我們的測試框架,並使用 Mockito 模擬依賴項。 我們的服務,由於一些奇怪的要求,被要求返回的小寫電影標題,而這就是我們在這裏要測試的內容。 這樣的行為可能有很多,我們應該通過大量的單元測試進行廣泛覆蓋。
5.3. 集成測試
在單元測試中,我們模擬了倉庫(repository),這正是我們對持久化層(persistence layer)的依賴。雖然我們已經徹底測試了服務層(service layer)的行為,但當它連接到數據庫時,我們仍然可能遇到問題。這時,集成測試就派上用場了:
@RunWith(SpringRunner.class)
@SpringBootTest
public class MovieControllerIntegrationTests {
@Autowired
private MovieController movieController;
@Test
public void givenMovieControllerWhenQueriedWithAnIdThenGetExpectedMovie() {
Movie movie = new Movie(100L, "Hello World!");
movieController.createMovie(movie);
Movie result = movieController.retrieveMovies(100L);
Assert.assertEquals(movie.getTitle().toLowerCase(), result.getTitle());
}
}請注意以下一些有趣的差異。我們並不嘲諷任何依賴項。但是,我們可能仍然需要模擬一些依賴項,具體取決於情況。此外,我們使用 SpringRunner 運行這些測試。
這基本上意味着我們擁有一個 Spring 應用上下文和一個實時數據庫,用於運行這些測試。因此,運行速度會變慢!因此,我們應該選擇更少的場景進行測試。
5.4. UI 測試
最後,我們的應用程序具有 REST 端點供消費者使用,這些端點可能具有自身的細微差別需要進行測試。由於這是一個應用程序的用户界面,我們將重點關注在 UI 測試中對其進行覆蓋。現在,讓我們使用 REST-assured 測試應用程序:
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class MovieApplicationE2eTests {
@Autowired
private MovieController movieController;
@LocalServerPort
private int port;
@Test
public void givenMovieApplicationWhenQueriedWithAnIdThenGetExpectedMovie() {
Movie movie = new Movie(100L, "Hello World!");
movieController.createMovie(movie);
when().get(String.format("http://localhost:%s/movies/100", port))
.then()
.statusCode(is(200))
.body(containsString("Hello World!".toLowerCase()));
}
}如我們所見,這些測試是在運行的應用程序中執行的,並通過可用的端點訪問它。
我們重點關注與 HTTP 相關的典型場景,例如響應代碼。這些將是執行速度最慢的測試,這是顯而易見的。
因此,我們必須非常仔細地選擇在此處進行測試的場景。我們應該只關注我們先前更精細的測試中未能覆蓋的複雜性。
6. 微服務中的測試金字塔
現在我們已經瞭解瞭如何編寫具有不同粒度和結構的測試。然而,關鍵目標是使用更細粒度和更快的測試來捕獲大部分應用程序的複雜性。
在處理 單體應用程序時,我們可以獲得理想的金字塔結構,但這對於其他架構可能並非必需。
正如我們所知,微服務架構將一個應用程序分解為一組鬆散耦合的應用程序。在這樣做的同時,它將一些內在的複雜性外包出去。
現在,這些複雜性體現在服務之間的通信中。並非總是可以通過單元測試來捕獲它們,因此我們需要編寫更多的集成測試。
雖然這可能意味着我們偏離了經典的金字塔模型,但這並不意味着我們偏離了原則。請記住,我們仍然使用盡可能細粒度的測試來捕獲大部分複雜性。只要我們明確這一點,即使不完全符合完美的金字塔模型,該模型仍然具有價值。
重要的是要理解的是,一個模型只有在能夠提供價值時才有用。價值通常取決於上下文,在本例中是應用程序所採用的架構。因此,雖然使用模型作為指導很有幫助,但我們應該 專注於底層原則,並最終選擇在架構上下文中何為合適。
7. 與持續集成集成
自動化測試的最大價值體現在將其集成到持續集成(CI)流水線中。 Jenkins 是一個流行的選擇,用於聲明式地定義構建和部署流水線。
我們可以將我們已經自動化測試的任何測試集成到 Jenkins 流水線中。 然而,我們必須理解這會增加流水線的執行時間。 持續集成的主要目標是快速反饋。 如果我們開始添加使流水線運行速度變慢的測試,這可能會與此相沖突。
關鍵要點是添加快速測試,例如單元測試,到預期運行頻率較高的流水線中。 例如,我們可能不會從將 UI 測試添加到每次提交觸發的流水線中獲得好處。 但是,這只是一個指導原則,最終取決於我們正在處理的應用程序的類型和複雜性。
8. 結論
在本文中,我們回顧了軟件測試的基礎知識。我們理解了不同類型的測試及其重要性,並使用可用的工具進行自動化測試。
此外,我們理解了測試金字塔的概念。我們使用基於 Spring Boot 構建的微服務來實現它。
最後,我們探討了測試金字塔在微服務架構等上下文中的相關性。