實用的測試金字塔
「測試金字塔」是一個比喻,告訴我們將軟體測試分組到不同粒度的層級中。它也提供一個概念,說明我們應該在每個群組中擁有多少測試。儘管測試金字塔的概念已經存在一段時間,但團隊仍然難以適當地將其付諸實踐。本文重新探討測試金字塔的原始概念,並展示如何將其付諸實踐。它說明您應該在金字塔的不同層級中尋找哪些類型的測試,並提供如何實作這些測試的實際範例。
2018 年 2 月 26 日

準備投入生產的軟體在進入生產之前需要進行測試。隨著軟體開發領域的成熟,軟體測試方法也日益成熟。開發團隊不再採用大量的人工軟體測試人員,而是轉向自動化其測試工作的大部分。自動化測試讓團隊能夠在幾秒或幾分鐘內得知其軟體是否損壞,而不是幾天或幾週。
由自動化測試縮短的回饋迴路與敏捷開發實務、持續交付和 DevOps 文化相輔相成。具備有效的軟體測試方法讓團隊能夠快速且有信心地行動。
本文探討全面測試組合應具備哪些特質才能靈敏、可靠且可維護,無論您是建構微服務架構、行動應用程式或 IoT 生態系統。我們也會深入探討建構有效且可讀的自動化測試。
(測試) 自動化的重要性
軟體已成為我們生活世界中不可或缺的一部分。它已超越其早期讓企業更有效率的單一目的。現今,公司試圖找出方法成為一流的數位公司。身為使用者的我們每個人每天都會與越來越多軟體互動。創新的輪子轉得越來越快。
如果您想跟上腳步,您必須找出方法在不犧牲軟體品質的情況下更快交付軟體。持續交付是一種實務,您可以自動確保您的軟體隨時可以發布到生產環境,這可以幫助您達成目標。透過持續交付,您可以使用建置管線自動測試您的軟體,並將其部署到您的測試和生產環境。
手動建置、測試和部署越來越多軟體很快就會變得不可能,除非您想花費所有時間進行手動、重複的工作,而不是交付可用的軟體。自動化所有事項,從建置到測試、部署和基礎架構,是您唯一的前進之路。

圖 1:使用建置管線自動且可靠地將您的軟體投入生產
傳統上,軟體測試是過度的手動工作,透過將您的應用程式部署到測試環境,然後執行一些黑盒式測試來完成,例如透過點選您的使用者介面來查看是否有任何損壞。這些測試通常會由測試腳本指定,以確保測試人員會進行一致的檢查。
很明顯地,手動測試所有變更會耗時、重複且乏味。重複很無聊,無聊會導致錯誤,並讓你到週末時就想找另一份工作。
幸運的是,重複性工作有一個補救方法:自動化。
自動化重複性測試可以大幅改變你作為軟體開發人員的生活。自動化這些測試,你就不再需要無腦地遵循點擊協定來檢查你的軟體是否仍然正常運作。自動化你的測試,你就可以毫不猶豫地變更你的程式碼庫。如果你曾經嘗試在沒有適當測試套件的情況下進行大規模重構,我敢打賭你知道這會是多麼可怕的體驗。你怎麼知道你是否在過程中意外中斷了某些東西?嗯,你會點擊所有手動測試案例,就是這樣。但我們誠實地說:你真的喜歡這樣嗎?何不進行更大規模的變更,並在悠閒地啜飲咖啡的同時,在幾秒鐘內知道你是否中斷了某些東西?如果你問我,聽起來更令人愉快。
測試金字塔
如果你想要認真對待軟體的自動化測試,有一個關鍵概念你應該知道:測試金字塔。Mike Cohn 在他的著作《敏捷成功之道》中提出了這個概念。這是一個很棒的視覺比喻,告訴你要思考不同層級的測試。它還會告訴你如何在每個層級執行多少測試。

圖 2:測試金字塔
Mike Cohn 最初的測試金字塔包含你的測試套件應該包含的三個層級(由下至上)
- 單元測試
- 服務測試
- 使用者介面測試
不幸的是,如果你仔細觀察,測試金字塔的概念會有一點不足。有些人認為 Mike Cohn 測試金字塔的命名或某些概念方面並非理想,我必須同意。從現代觀點來看,測試金字塔似乎過於簡化,因此可能會產生誤導。
儘管如此,由於其簡潔性,測試金字塔的精髓在你建立自己的測試套件時,可以作為一個很好的經驗法則。你最好的方法是從 Cohn 最初的測試金字塔中記住兩件事
- 使用不同的粒度撰寫測試
- 層級越高,你應該擁有的測試就越少
堅持金字塔形狀,以建立一個健康、快速且可維護的測試套件:撰寫大量小型且快速的單元測試。撰寫一些更粗略的測試,以及極少數從頭到尾測試你的應用程式的頂級測試。注意,不要以一個測試冰淇淋甜筒收場,它將會是維護的惡夢,而且執行時間過長。
不要過度執著於 Cohn 測試金字塔中各個層級的名稱。事實上,它們可能會造成誤導:服務測試 是一個難以理解的術語(Cohn 本人談到一個觀察結果,即 許多開發人員完全忽略了這個層級)。在 React、Angular、Ember.js 等單頁應用程式架構的時代,很明顯地,UI 測試 不必在金字塔的最高層級中,您完全可以在所有這些架構中對 UI 進行單元測試。
鑑於原始名稱的缺點,為您的測試層級想出其他名稱完全沒問題,只要在您的程式碼庫和團隊討論中保持一致即可。
我們將探討的工具和函式庫
範例應用程式
我寫了一個 簡單的微服務,其中包含一個測試套件,用於測試測試金字塔的不同層級。
範例應用程式顯示了典型微服務的特徵。它提供 REST 介面,與資料庫對話,並從第三方 REST 服務擷取資訊。它是在 Spring Boot 中實作的,即使您之前從未使用過 Spring Boot,也應該可以理解。
請務必查看 Github 上的程式碼。自述檔案包含在您的機器上執行應用程式及其自動化測試所需的說明。
功能
應用程式的功能很簡單。它提供一個具有三個端點的 REST 介面
GET /hello | 始終傳回 「Hello World」。 |
GET /hello/{lastname} | 查詢提供姓氏的人員。如果知道此人,則傳回 「Hello {Firstname} {Lastname}」。 |
GET /weather | 傳回 德國漢堡 的當前天氣狀況。 |
高階結構
在高層級中,系統具有下列結構

圖 3:我們的微服務系統的高層級結構
我們的微服務提供可透過 HTTP 呼叫的 REST 介面。對於某些端點,服務會從資料庫擷取資訊。在其他情況下,服務會透過 HTTP 呼叫外部 天氣 API 以擷取並顯示目前的氣象狀況。
內部架構
在內部,Spring 服務具有 Spring 典型的架構

圖 4:我們的微服務的內部結構
Controller
類別提供 REST 端點,並處理 HTTP 要求和回應Repository
類別與 資料庫 介接,並負責將資料寫入/從持久性儲存裝置讀取資料Client
類別與其他 API 對話,在我們的案例中,它會透過 HTTPS 從 darksky.net 天氣 API 擷取 JSONDomain
類別擷取我們的 網域模型,包括網域邏輯(在我們的案例中,相當平凡)。
經驗豐富的 Spring 開發人員可能會注意到這裡缺少一個經常使用的層:受到 領域驅動設計 的啟發,許多開發人員會建立由 服務 類別組成的 服務層。我決定不將服務層包含在此應用程式中。原因之一是我們的應用程式夠簡單,服務層會是一個不必要的間接層級。另一個原因是我認為人們過度使用服務層。我經常遇到程式碼庫,其中整個商業邏輯都擷取在服務類別中。網域模型僅成為資料的層級,而非行為(貧血網域模型)。對於每個非平凡的應用程式,這會浪費許多潛力,無法讓您的程式碼保持結構良好且可測試,也無法充分利用物件導向的強大功能。
我們的儲存庫很直接,並提供簡單的 CRUD 功能。為了讓程式碼保持簡單,我使用了 Spring 資料。Spring 資料為我們提供一個簡單且通用的 CRUD 儲存庫實作,我們可以使用它,而不用自己開發。它也會負責在我們的測試中啟動一個記憶體中資料庫,而不是在製作環境中使用真正的 PostgreSQL 資料庫。
檢視程式碼庫,並熟悉內部結構。這將有助於我們執行下一步:測試應用程式!
單元測試
您的測試套件的基礎將由單元測試組成。您的單元測試會確保您的程式碼庫中的特定單元(您的 受測對象)按預期運作。在您的測試套件中,單元測試具有最狹窄的範圍。您的測試套件中的單元測試數量將遠多於任何其他類型的測試。

圖 5:單元測試通常會用測試替身取代外部協作者
什麼是單元?
如果你詢問三位不同的人在單元測試中「單元」是什麼意思,你可能會收到四種不同且略有差異的答案。在某種程度上,這是你自己的定義問題,沒有標準答案也沒關係。
如果你使用函式語言,則「單元」很可能是一個單一函式。你的單元測試會呼叫具有不同參數的函式,並確保它傳回預期的值。在物件導向語言中,單元範圍可以從單一方法到整個類別。
群居和獨居
有些人認為,受測對象的所有協作者(例如受測類別呼叫的其他類別)都應該以「模擬」或「存根」取代,以達到完美的隔離,並避免副作用和複雜的測試設定。其他人則認為,只有速度較慢或具有較大副作用的協作者(例如存取資料庫或進行網路呼叫的類別)才應該被存根或模擬。
偶爾,人們會將這兩種測試標記為孤立單元測試(針對存根所有協作者的測試)和社交單元測試(允許與真實協作者對話的測試)(Jay Fields 的 有效使用單元測試 一書創造了這些術語)。如果你有空,可以深入探討並 進一步了解不同思想流派的優缺點。
最後,決定採用孤立或社交單元測試並不重要。撰寫自動化測試才是重點。就我個人而言,我發現自己一直同時使用這兩種方法。如果使用真實協作者變得尷尬,我會大量使用模擬和存根。如果我覺得讓真實協作者參與可以讓我對測試更有信心,我只會存根服務的最外層部分。
模擬和存根
模擬和存根是兩種不同的 測試替身(還有其他種類)。許多人會交替使用「模擬」和「存根」這兩個術語。我覺得準確表達並牢記其特定屬性很重要。你可以使用測試替身來取代你會在實際環境中使用的物件,並使用有助於測試的實作。簡單來說,這表示你會用該事物的假版本取代真實事物(例如類別、模組或函式)。假版本看起來和真實事物一樣,也會做出相同的行為(回應相同的方法呼叫),但會回應你在單元測試一開始定義的罐頭回應。
使用測試替身不限於單元測試。更精細的測試替身可以用來以受控的方式模擬系統的整個部分。不過,在單元測試中,你最有可能會遇到許多模擬和存根(取決於你是社交型還是孤立型的開發人員),原因很簡單,因為許多現代語言和函式庫讓設定模擬和存根變得容易且方便。
無論您選擇哪種技術,您的語言標準函式庫或一些熱門的第三方函式庫都有很大的機率會提供您設定模擬的優雅方式。甚至從頭撰寫自己的模擬也只不過是撰寫一個具有與實際函式庫相同簽章的假類別/模組/函式,並在測試中設定假資料。
您的單元測試會執行得非常快。在不錯的機器上,您可以在幾分鐘內執行數千個單元測試。孤立地測試程式碼庫中的小片段,並避免存取資料庫、檔案系統或發出 HTTP 查詢(透過使用這些部分的模擬和存根)以保持測試速度。
一旦您掌握了撰寫單元測試,您將會越來越流暢地撰寫它們。存根外部協作者,設定一些輸入資料,呼叫受測主體,並檢查回傳值是否符合您的預期。深入瞭解測試驅動開發,並讓您的單元測試引導您的開發;如果正確套用,它可以幫助您進入絕佳的流程,並在自動產生全面且完全自動化的測試套件的同時,提出良好且可維護的設計。不過,它並非萬靈丹。繼續吧,給它一個真正的機會,看看它是否適合您。
要測試什麼?
單元測試的好處在於,你可以為所有製作程式碼類別撰寫單元測試,無論其功能為何,或屬於內部結構中的哪一層。你可以像單元測試儲存庫、網域類別或檔案讀取器一樣,單元測試控制器。只要遵守每個製作類別一個測試類別的經驗法則,你就能順利開始。
單元測試類別至少應該測試類別的公開介面。私人方法無法測試,因為你根本無法從不同的測試類別呼叫它們。受保護或封包私人方法可以從測試類別存取(假設測試類別的封包結構與製作類別相同),但測試這些方法可能會太過深入。
撰寫單元測試時,有一條微妙的界線:它們應該確保所有非平凡程式碼路徑都經過測試(包括正常路徑和臨界狀況)。同時,它們不應該與你的實作綁得太緊密。
為什麼呢?
與製作程式碼太過接近的測試會很快變得令人厭煩。只要你重構製作程式碼(快速回顧:重構是指變更程式碼的內部結構,但不會變更外部可見的行為),你的單元測試就會中斷。
這樣一來,你就會失去單元測試的一大好處:作為程式碼變更的安全網。你反而會對這些愚蠢的測試感到厭煩,因為它們會在你每次重構時失敗,造成更多工作,而不是提供幫助;而且,這愚蠢的測試到底是什麼鬼點子?
你該怎麼做?不要在單元測試中反映你的內部程式碼結構。改為測試可觀察的行為。想想
如果我輸入值x
和y
,結果會是z
嗎?
而不是
如果我輸入 x
和 y
,方法會先呼叫類別 A,然後呼叫類別 B,再傳回類別 A 的結果加上類別 B 的結果嗎?
私有方法通常應視為實作細節。這就是為什麼你甚至不應該有測試它們的衝動。
我常聽到單元測試(或 TDD )的對手爭論說,撰寫單元測試會變成毫無意義的工作,因為你必須測試所有方法才能達到高測試涵蓋率。他們常常舉出過於熱心的團隊負責人強迫他們為取得 100% 測試涵蓋率而為取得函數和設定函數以及所有其他瑣碎程式碼撰寫單元測試的範例。
那裡有許多錯誤。
是的,你應該測試公開介面。然而,更重要的是,你不要測試瑣碎的程式碼。別擔心, Kent Beck 認為這沒問題。你不會從測試簡單的取得函數或設定函數或其他瑣碎的實作(例如,沒有任何條件式邏輯)中獲得任何好處。省下時間,這表示你可以參加更多會議,歡呼!
測試結構
所有測試的良好結構(這不限於單元測試)是這個
- 設定測試資料
- 呼叫測試中的方法
- 斷言傳回預期的結果
有一個不錯的記憶技巧可以記住這個結構:「安排、動作、斷言」。另一個你可以使用的技巧是從 BDD 中獲得靈感。它是 「給定」、「何時」、「然後」 三部曲,其中給定反映設定、何時反映方法呼叫,而然後反映斷言部分。
這個模式也可以套用在其他更高級的測試中。在每種情況下,它們都能確保你的測試保持易讀且一致。此外,考量這個結構撰寫的測試往往較短且更具表達力。
實作單元測試
現在我們知道要測試什麼以及如何建構我們的單元測試,我們終於可以看到一個真實的範例。
我們來看看 ExampleController
類別的簡化版本
@RestController public class ExampleController { private final PersonRepository personRepo; @Autowired public ExampleController(final PersonRepository personRepo) { this.personRepo = personRepo; } @GetMapping("/hello/{lastName}") public String hello(@PathVariable final String lastName) { Optional<Person> foundPerson = personRepo.findByLastName(lastName); return foundPerson .map(person -> String.format("Hello %s %s!", person.getFirstName(), person.getLastName())) .orElse(String.format("Who is this '%s' you're talking about?", lastName)); } }
針對 hello(lastname)
函式的單元測試可以像這樣
public class ExampleControllerTest { private ExampleController subject; @Mock private PersonRepository personRepo; @Before public void setUp() throws Exception { initMocks(this); subject = new ExampleController(personRepo); } @Test public void shouldReturnFullNameOfAPerson() throws Exception { Person peter = new Person("Peter", "Pan"); given(personRepo.findByLastName("Pan")) .willReturn(Optional.of(peter)); String greeting = subject.hello("Pan"); assertThat(greeting, is("Hello Peter Pan!")); } @Test public void shouldTellIfPersonIsUnknown() throws Exception { given(personRepo.findByLastName(anyString())) .willReturn(Optional.empty()); String greeting = subject.hello("Pan"); assertThat(greeting, is("Who is this 'Pan' you're talking about?")); } }
我們使用 JUnit 撰寫單元測試,JUnit 是 Java 的事實標準測試架構。我們使用 Mockito 以一個 stub 取代真正的 PersonRepository
類別,以進行我們的測試。這個 stub 讓我們可以定義 stubbed 函式在這個測試中應該回傳的罐頭回應。stubbing 讓我們的測試更簡單、更可預測,而且讓我們可以輕鬆設定測試資料。
遵循 安排、動作、斷言 結構,我們撰寫兩個單元測試 - 一個正向案例和一個找不到搜尋人員的案例。第一個正向測試案例建立一個新的人員物件,並告訴被模擬的儲存庫在以 "Pan" 作為 lastName
參數的值呼叫它時回傳這個物件。然後測試繼續呼叫應該測試的函式。最後,它斷言回應等於預期的回應。
第二個測試運作方式類似,但測試的是測試方法找不到給定參數的人員的情況。
整合測試
所有非平凡的應用程式都會與其他部分(資料庫、檔案系統、對其他應用程式的網路呼叫)整合。在撰寫單元測試時,這些通常是你會省略的部分,以便獲得更好的隔離和更快速的測試。儘管如此,你的應用程式還是會與其他部分互動,這需要進行測試。整合測試 就是為了提供協助。它們測試你的應用程式與應用程式外部所有部分的整合。
對於你的自動化測試,這表示你不僅需要執行自己的應用程式,還需要執行你整合的元件。如果你要測試與資料庫的整合,你需要在執行測試時執行資料庫。要測試你可以從磁碟讀取檔案,你需要將檔案儲存到磁碟,並在整合測試中載入它。
我之前提到「單元測試」是一個模糊的術語,這對「整合測試」來說更為真實。對某些人來說,整合測試表示測試應用程式整個堆疊,連接到系統中的其他應用程式。我喜歡更狹義地處理整合測試,並一次測試一個整合點,透過測試替身取代獨立的服務和資料庫。搭配合約測試,並針對測試替身和實際實作執行合約測試,你可以提出更快速、更獨立且通常更容易推理的整合測試。
狹義的整合測試存在於你的服務邊界。在概念上,它們總是關於觸發一個動作,導致與外部部分(檔案系統、資料庫、獨立服務)整合。資料庫整合測試看起來像這樣

圖 6:資料庫整合測試將你的程式碼與真實資料庫整合
- 啟動資料庫
- 將你的應用程式連接到資料庫
- 在你的程式碼中觸發一個將資料寫入資料庫的函式
- 透過從資料庫讀取資料,檢查預期的資料是否已寫入資料庫
另一個範例,測試你的服務透過 REST API 與獨立服務整合,可能看起來像這樣

圖 7:這種整合測試檢查你的應用程式是否可以正確地與獨立服務通訊
- 啟動你的應用程式
- 啟動獨立服務的執行個體(或具有相同介面的測試替身)
- 在程式碼中觸發一個從獨立服務的 API 讀取的功能
- 檢查應用程式是否可以正確解析回應
整合測試(例如單元測試)可以相當白盒。有些架構允許您在啟動應用程式的同時,仍能模擬應用程式的其他部分,以便檢查是否發生正確的互動。
為所有序列化或反序列化資料的程式碼撰寫整合測試。這比您想像的更常發生。想想
- 呼叫服務的 REST API
- 從資料庫讀取和寫入資料
- 呼叫其他應用程式的 API
- 從佇列讀取和寫入資料
- 寫入檔案系統
在這些邊界周圍撰寫整合測試可確保寫入資料和從這些外部協作者讀取資料都能正常運作。
在撰寫「狹義整合測試」時,您應該嘗試在本地執行外部依賴項:啟動本機 MySQL 資料庫,針對本機 ext4 檔案系統進行測試。如果您要與獨立服務整合,請在本地執行該服務的執行個體,或建置並執行模擬實際服務行為的假版本。
如果無法在本地執行第三方服務,您應該選擇執行專用的測試執行個體,並在執行整合測試時指向此測試執行個體。避免在自動化測試中與實際生產系統整合。對生產系統發出數千個測試要求是激怒他人的一種萬無一失的方法,因為您會塞爆他們的記錄(在最好的情況下),甚至會對他們的服務發動 DoS 攻擊(在最壞的情況下)。透過網路與服務整合是「廣義整合測試」的典型特徵,會讓您的測試速度變慢,而且通常更難撰寫。
關於測試金字塔,整合測試的層級高於單元測試。整合檔案系統和資料庫等速度較慢的部分往往比執行這些部分被存根的單元測試慢得多。它們也可能比小型且獨立的單元測試更難撰寫,畢竟您必須在測試中負責啟動外部部分。儘管如此,它們的優點是讓您確信應用程式可以正確與所有需要通訊的外部部分協同運作。單元測試無法幫您做到這一點。
資料庫整合
PersonRepository
是程式碼庫中唯一的儲存庫類別。它依賴於 Spring Data,且沒有實際的實作。它僅延伸 CrudRepository
介面,並提供單一方法標頭。其餘都是 Spring 的魔法。
public interface PersonRepository extends CrudRepository<Person, String> { Optional<Person> findByLastName(String lastName); }
透過 CrudRepository
介面,Spring Boot 提供一個功能齊全的 CRUD 儲存庫,包含 findOne
、findAll
、save
、update
和 delete
方法。我們的自訂方法定義 (findByLastName()
) 延伸此基本功能,並提供我們一個透過姓氏擷取 Person
的方法。Spring Data 會分析方法的傳回類型及其方法名稱,並根據命名慣例檢查方法名稱,以找出它應該執行的動作。
儘管 Spring Data 負責執行實作資料庫儲存庫的繁重工作,但我仍撰寫了一個資料庫整合測試。你可能會辯稱這是「測試架構」,而且我應該避免這麼做,因為我們測試的不是我們的程式碼。儘管如此,我相信在此處至少有一個整合測試至關重要。首先,它測試我們的自訂 findByLastName
方法是否實際上按照預期運作。其次,它證明我們的儲存庫正確使用 Spring 的配線,並且可以連線到資料庫。
為了讓你在自己的機器上執行測試更輕鬆(無需安裝 PostgreSQL 資料庫),我們的測試會連線到內建記憶體的 H2 資料庫。
我在 build.gradle
檔案中將 H2 定義為測試依賴項。測試目錄中的 application.properties
沒有定義任何 spring.datasource
屬性。這會指示 Spring Data 使用內建記憶體資料庫。由於它在類別路徑中找到 H2,因此在執行我們的測試時,它會直接使用 H2。
當使用 int
設定檔執行實際應用程式時(例如,透過將 SPRING_PROFILES_ACTIVE=int
設定為環境變數),它會連線到 application-int.properties
中定義的 PostgreSQL 資料庫。
我知道,這需要了解和理解大量的 Spring 細節。為了達成此目標,你必須仔細閱讀 大量文件。產生的程式碼容易閱讀,但如果你不了解 Spring 的細微細節,則很難理解。
此外,使用內建記憶體資料庫是有風險的。畢竟,我們的整合測試執行的是與生產環境中不同的資料庫類型。繼續並自行決定你是否偏好 Spring 的魔法和簡單程式碼,而不是明確但更冗長的實作。
已經說明夠多了,以下是將 Person 儲存到資料庫並依據其姓氏來尋找的簡單整合測試
@RunWith(SpringRunner.class) @DataJpaTest public class PersonRepositoryIntegrationTest { @Autowired private PersonRepository subject; @After public void tearDown() throws Exception { subject.deleteAll(); } @Test public void shouldSaveAndFetchPerson() throws Exception { Person peter = new Person("Peter", "Pan"); subject.save(peter); Optional<Person> maybePeter = subject.findByLastName("Pan"); assertThat(maybePeter, is(Optional.of(peter))); } }
您可以看到我們的整合測試遵循與單元測試相同的安排、動作、斷言結構。告訴您這是一個通用的概念!
與個別服務整合
我們的微服務會與 darksky.net 進行通訊,這是一個天氣 REST API。當然我們希望確保我們的服務會正確地傳送要求並剖析回應。
我們希望在執行自動化測試時避免存取真實的darksky 伺服器。免費方案的配額限制只是原因之一。真正的理由是解耦。我們的測試應該獨立於 darksky.net 的可愛人員正在進行的任何事。即使您的機器無法存取darksky 伺服器或 darksky 伺服器因維護而關閉時也是如此。
我們可以在執行整合測試時執行我們自己的假darksky 伺服器,以避免存取真實的darksky 伺服器。這聽起來可能像是一項龐大的任務。感謝 Wiremock 等工具,這非常容易。請觀看這個
@RunWith(SpringRunner.class) @SpringBootTest public class WeatherClientIntegrationTest { @Autowired private WeatherClient subject; @Rule public WireMockRule wireMockRule = new WireMockRule(8089); @Test public void shouldCallWeatherService() throws Exception { wireMockRule.stubFor(get(urlPathEqualTo("/some-test-api-key/53.5511,9.9937")) .willReturn(aResponse() .withBody(FileLoader.read("classpath:weatherApiResponse.json")) .withHeader(CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) .withStatus(200))); Optional<WeatherResponse> weatherResponse = subject.fetchWeather(); Optional<WeatherResponse> expectedResponse = Optional.of(new WeatherResponse("Rain")); assertThat(weatherResponse, is(expectedResponse)); } }
要使用 Wiremock,我們在固定連接埠 (8089
) 上實例化一個 WireMockRule
。使用 DSL,我們可以設定 Wiremock 伺服器,定義它應該監聽的端點,並設定它應該回應的罐頭回應。
接下來,我們呼叫我們想要測試的方法,也就是呼叫第三方服務的方法,並檢查結果是否正確剖析。
了解測試如何知道它應該呼叫假的 Wiremock 伺服器而不是真實的darksky API 非常重要。祕密就在於我們包含在 src/test/resources
中的 application.properties
檔案。這是 Spring 在執行測試時載入的屬性檔案。在這個檔案中,我們使用適合於我們的測試目的的值來覆寫 API 金鑰和 URL 等組態,例如呼叫假的 Wiremock 伺服器而不是真實的伺服器
weather.url = https://127.0.0.1:8089
請注意,這裡定義的連接埠必須與我們在測試中實例化 WireMockRule
時定義的連接埠相同。在我們的測試中,透過在 WeatherClient
類別的建構函式中注入 URL,可以將真實天氣 API 的 URL 替換為假的 URL
@Autowired public WeatherClient(final RestTemplate restTemplate, @Value("${weather.url}") final String weatherServiceUrl, @Value("${weather.api_key}") final String weatherServiceApiKey) { this.restTemplate = restTemplate; this.weatherServiceUrl = weatherServiceUrl; this.weatherServiceApiKey = weatherServiceApiKey; }
這樣我們會告訴我們的 WeatherClient
從我們在應用程式屬性中定義的 weather.url
屬性讀取 weatherUrl
參數的值。
使用 Wiremock 等工具為獨立的服務撰寫狹義整合測試非常容易。不幸的是,這種方法有一個缺點:我們如何確保我們設定的假伺服器會像真實的伺服器一樣運作?使用目前的實作,獨立的服務可能會變更其 API,而我們的測試仍然會通過。現在我們僅測試我們的 WeatherClient
是否可以剖析假伺服器傳送的回應。這是一個開始,但非常脆弱。使用端對端測試並針對真實服務的測試實例執行測試,而不是使用假服務,將可以解決這個問題,但會讓我們依賴測試服務的可用性。幸運的是,對於這個兩難問題,有一個更好的解決方案:針對假伺服器和真實伺服器執行合約測試,可以確保我們在整合測試中使用的假伺服器是一個忠實的測試替身。讓我們看看這是如何運作的。
合約測試
更現代的軟體開發組織已找到方法,透過將系統開發分散到不同團隊,來擴展其開發工作。個別團隊建立個別、鬆散耦合的服務,而不會踩到彼此的腳,並將這些服務整合到一個龐大、有凝聚力的系統中。最近有關微服務的熱門話題,重點就在於此。
將系統拆分為許多小型服務通常表示這些服務需要透過特定(希望定義良好,有時意外產生)介面彼此通訊。
不同應用程式之間的介面可能採用不同的形式和技術。常見的有
- 透過 HTTPS 的 REST 和 JSON
- 使用類似 gRPC 的RPC
- 使用佇列建立事件驅動架構
每個介面都涉及兩個參與方:提供者和使用者。提供者向使用者提供資料。使用者處理從提供者取得的資料。在 REST 世界中,提供者會建立包含所有必要端點的 REST API;使用者會呼叫此 REST API 來擷取資料或觸發其他服務中的變更。在非同步、事件驅動的世界中,提供者(通常稱為發佈者)會將資料發佈到佇列;使用者(通常稱為訂閱者)會訂閱這些佇列並讀取和處理資料。

圖 8:每個介面都有提供(或發佈)方和使用(或訂閱)方。介面的規格可以視為合約。
由於你經常將使用和提供服務分散到不同團隊,因此你會發現自己處於必須清楚指定這些服務之間介面(所謂的合約)的情況。傳統上,公司以以下方式處理此問題
- 撰寫冗長且詳細的介面規格(合約)
- 根據定義的合約實作提供服務
- 將介面規格丟給使用團隊
- 等到他們實作使用介面的部分
- 執行一些大型手動系統測試,以查看一切是否正常運作
- 希望兩個團隊永遠堅持介面定義,不要搞砸
更現代的軟體開發團隊已用更自動化的方式取代步驟 5 和 6:自動化 契約測試 確保消費者和提供者方面的實作仍堅持已定義的契約。它們可作為良好的回歸測試套件,並確保會在早期注意到與契約的偏差。
在更敏捷的組織中,您應採取更有效率且浪費較少的路線。您在同一個組織內建置應用程式。實際上,直接與其他服務的開發人員交談,不應太困難,而非透過圍籬拋出過於詳細的文件。畢竟,他們是您的同事,而不是您只能透過客戶支援或法律防彈合約與之交談的第三方供應商。
消費者驅動契約測試 (CDC 測試) 讓 消費者驅動契約的實作。使用 CDC,介面的消費者撰寫測試,以檢查介面中他們從該介面所需的所有資料。然後,使用團隊會發布這些測試,以便發布團隊可以輕鬆擷取並執行這些測試。現在,提供團隊可以透過執行 CDC 測試來開發其 API。一旦所有測試通過,他們就知道已實作使用團隊所需的一切。

圖 9:契約測試可確保提供者和介面的所有消費者堅持已定義的介面契約。透過 CDC 測試,介面的消費者以自動化測試的形式發布其需求;提供者會持續擷取並執行這些測試
這種方法允許提供團隊僅實作真正必要的項目(保持簡單,YAGNI 等)。提供介面的團隊應持續擷取並執行這些 CDC 測試(在其建置管線中),以立即找出任何重大變更。如果他們中斷介面,其 CDC 測試將會失敗,防止重大變更上線。只要測試保持綠色,團隊就可以進行任何他們想要的變更,而不必擔心其他團隊。消費者驅動契約方法會讓您擁有類似這樣的流程
- 使用團隊撰寫包含所有消費者預期的自動化測試
- 他們為提供團隊發布測試
- 提供團隊持續執行 CDC 測試並保持它們為綠色
- 一旦 CDC 測試中斷,兩個團隊就會彼此交談
如果您的組織採用微服務方法,擁有 CDC 測試是朝向建立自主團隊邁出一大步。CDC 測試是促進團隊溝通的自動化方式。它們確保團隊之間的介面隨時運作。失敗的 CDC 測試是一個良好的指標,表示您應走到受影響的團隊,聊聊任何即將到來的 API 變更,並找出您希望如何前進。
CDC 測試的簡陋實作可以像對 API 發出要求並斷言回應包含您需要的所有內容一樣簡單。然後,您將這些測試封裝成可執行檔(.gem、.jar、.sh)並將其上傳到其他團隊可以擷取它的某個地方(例如,像 Artifactory 這樣的成品存放庫)。
在過去幾年,CDC 方法變得越來越流行,而且已經建置了幾個工具,讓撰寫和交換它們變得更容易。
Pact 可能是現今最顯著的一個。它有一種精密的測試撰寫方法,適用於消費者和提供者端,提供開箱即用的個別服務存根,並允許您與其他團隊交換 CDC 測試。Pact 已移植到許多平台,而且可以用於 JVM 語言、Ruby、.NET、JavaScript 和許多其他語言。
如果您想要開始使用 CDC 而且不知道如何使用,Pact 可以是一個明智的選擇。文件 起初可能令人不知所措。請耐心並仔細閱讀。這有助於對 CDC 有堅定的了解,進而讓您在與其他團隊合作時更容易提倡使用 CDC。
消費者驅動合約測試可以成為建立自主團隊的真正遊戲規則改變者,這些團隊可以快速且自信地行動。對自己好一點,閱讀那個概念並嘗試看看。一套穩固的 CDC 測試對於能夠快速行動而不中斷其他服務,並對其他團隊造成許多挫折,是無價的。
使用者測試(我們的團隊)
我們的微服務使用天氣 API。因此,我們有責任撰寫一個消費者測試,定義我們對微服務和天氣服務之間合約(API)的預期。
首先,我們在 build.gradle
中包含一個用於撰寫 pact 消費者測試的函式庫
testCompile('au.com.dius:pact-jvm-consumer-junit_2.11:3.5.5')
多虧了這個函式庫,我們可以實作消費者測試並使用 pact 的模擬服務
@RunWith(SpringRunner.class) @SpringBootTest public class WeatherClientConsumerTest { @Autowired private WeatherClient weatherClient; @Rule public PactProviderRuleMk2 weatherProvider = new PactProviderRuleMk2("weather_provider", "localhost", 8089, this); @Pact(consumer="test_consumer") public RequestResponsePact createPact(PactDslWithProvider builder) throws IOException { return builder .given("weather forecast data") .uponReceiving("a request for a weather request for Hamburg") .path("/some-test-api-key/53.5511,9.9937") .method("GET") .willRespondWith() .status(200) .body(FileLoader.read("classpath:weatherApiResponse.json"), ContentType.APPLICATION_JSON) .toPact(); } @Test @PactVerification("weather_provider") public void shouldFetchWeatherInformation() throws Exception { Optional<WeatherResponse> weatherResponse = weatherClient.fetchWeather(); assertThat(weatherResponse.isPresent(), is(true)); assertThat(weatherResponse.get().getSummary(), is("Rain")); } }
如果您仔細觀察,您會看到 WeatherClientConsumerTest
與 WeatherClientIntegrationTest
非常類似。這次我們使用 Pact,而不是使用 Wiremock 作為伺服器存根。事實上,消費者測試的工作方式與整合測試完全相同,我們使用存根取代真正的第三方伺服器,定義預期的回應,並檢查我們的客戶端是否可以正確解析回應。從這個意義上來說,WeatherClientConsumerTest
本身就是一個狹隘的整合測試。與基於 Wiremock 的測試相比,這個測試的優點是每次執行時都會產生一個pact 檔案(在 target/pacts/&pact-name>.json
中找到)。此 pact 檔案以一種特殊的 JSON 格式描述我們對合約的預期。然後可以使用此 pact 檔案來驗證我們的存根伺服器是否像真正的伺服器一樣運作。我們可以取得 pact 檔案並將其交給提供介面的團隊。他們會取得此 pact 檔案並使用其中定義的預期撰寫一個提供者測試。這樣,他們可以測試他們的 API 是否符合我們的所有預期。
您看到這就是 CDC 的消費者驅動部分的由來。消費者透過描述他們的預期來驅動介面的實作。提供者必須確保他們滿足所有預期,而且他們已經完成了。沒有鍍金、沒有 YAGNI 和其他東西。
取得契約檔案提供給團隊有多種方式。一種簡單的方式是將它們檢查到版本控制中,並告訴提供者團隊始終擷取契約檔案的最新版本。更進階的方式是使用人工製品儲存庫,例如 Amazon 的 S3 或契約仲介。從簡單開始,並根據需要逐步擴充。
在實際應用中,您不需要同時為客戶端類別執行「整合測試」和「消費者測試」。範例程式碼庫同時包含這兩種測試,以顯示如何使用任一種測試。如果您想要使用契約來撰寫 CDC 測試,建議堅持使用後者。撰寫測試的難度相同。使用契約的好處是,您可以自動取得包含對其他團隊可輕鬆用於實作其提供者測試的合約預期的契約檔案。當然,這只有在您能說服其他團隊也使用契約時才有意義。如果這行不通,使用「整合測試」和 Wiremock 組合是一個不錯的備用方案。
提供者測試(其他團隊)
提供者測試必須由提供天氣 API 的人員實作。我們使用 darksky.net 提供的公開 API。理論上,darksky 團隊會在他們端點實作提供者測試,以檢查他們並未破壞其應用程式和我們的服務之間的合約。
顯然,他們不在乎我們微不足道的範例應用程式,而且不會為我們實作 CDC 測試。這是公開 API 和採用微服務的組織之間的重大差異。公開 API 無法考慮所有單一消費者,否則他們將無法向前邁進。在您自己的組織中,您可以而且應該這麼做。您的應用程式很可能會服務少數,最多可能是幾十個消費者。您將可以順利為這些介面撰寫提供者測試,以維持系統穩定。
提供者團隊取得契約檔案,並針對其提供服務執行該檔案。為此,他們實作一個讀取契約檔案、建立一些測試資料,並針對其服務執行契約檔案中定義的預期的提供者測試。
契約人員已撰寫多個用於實作提供者測試的函式庫。其主要 GitHub 儲存庫 為您提供一份不錯的概觀,說明有哪些消費者和提供者函式庫可用。選擇最符合您的技術堆疊的函式庫。
為求簡潔,我們假設 darksky API 也以 Spring Boot 實作。在此情況下,他們可以使用 Spring 契約提供者,它可以順利連結到 Spring 的 MockMVC 機制。darksky.net 團隊可能會實作的假設提供者測試如下所示
@RunWith(RestPactRunner.class) @Provider("weather_provider") // same as the "provider_name" in our clientConsumerTest @PactFolder("target/pacts") // tells pact where to load the pact files from public class WeatherProviderTest { @InjectMocks private ForecastController forecastController = new ForecastController(); @Mock private ForecastService forecastService; @TestTarget public final MockMvcTarget target = new MockMvcTarget(); @Before public void before() { initMocks(this); target.setControllers(forecastController); } @State("weather forecast data") // same as the "given()" in our clientConsumerTest public void weatherForecastData() { when(forecastService.fetchForecastFor(any(String.class), any(String.class))) .thenReturn(weatherForecast("Rain")); } }
您會看到,所有提供者測試所要做的就是載入協定檔案(例如,使用 @PactFolder
註解來載入先前下載的協定檔案),然後定義預先定義狀態的測試資料應如何提供(例如,使用 Mockito 模擬)。沒有自訂測試需要實作。這些都是從協定檔案衍生的。提供者測試具有與使用者測試中宣告的提供者名稱和狀態相符的對應項目非常重要。
提供者測試(我們的團隊)
我們已經了解如何測試我們的服務與天氣提供者之間的合約。透過這個介面,我們的服務扮演使用者,天氣服務扮演提供者。再思考一下,我們會看到我們的服務也扮演其他人的提供者:我們提供一個 REST API,提供幾個端點供其他人使用。
由於我們剛學到合約測試非常流行,我們當然也會為這個合約撰寫合約測試。很幸運的是,我們使用使用者驅動合約,因此所有使用者團隊都會將其協定傳送給我們,我們可以使用這些協定來實作 REST API 的提供者測試。
讓我們先將 Spring 的協定提供者函式庫新增到我們的專案
testCompile('au.com.dius:pact-jvm-provider-spring_2.12:3.5.5')
實作提供者測試遵循前面所述的相同模式。為了簡化起見,我僅從我們的 簡單使用者 中檢查協定檔案到我們的服務儲存庫。這讓我們更容易達成目的,在實際情況中,您可能會使用更精密的機制來分發協定檔案。
@RunWith(RestPactRunner.class) @Provider("person_provider")// same as in the "provider_name" part in our pact file @PactFolder("target/pacts") // tells pact where to load the pact files from public class ExampleProviderTest { @Mock private PersonRepository personRepository; @Mock private WeatherClient weatherClient; private ExampleController exampleController; @TestTarget public final MockMvcTarget target = new MockMvcTarget(); @Before public void before() { initMocks(this); exampleController = new ExampleController(personRepository, weatherClient); target.setControllers(exampleController); } @State("person data") // same as the "given()" part in our consumer test public void personData() { Person peterPan = new Person("Peter", "Pan"); when(personRepository.findByLastName("Pan")).thenReturn(Optional.of (peterPan)); } }
顯示的 ExampleProviderTest
需要根據我們提供的協定檔案提供狀態,僅此而已。一旦我們執行提供者測試,協定就會擷取協定檔案,並針對我們的服務發出 HTTP 要求,然後根據我們設定的狀態回應。
UI 測試
大多數應用程式都具有一些使用者介面。在 Web 應用程式的背景下,我們通常會討論 Web 介面。人們常常忘記 REST API 或命令列介面與精美的 Web 使用者介面一樣都是使用者介面。
使用者介面測試測試應用程式的使用者介面是否運作正常。使用者輸入應該觸發正確的動作,資料應該呈現給使用者,使用者介面狀態應該如預期般變更。

使用者介面測試和端對端測試有時(例如在 Mike Cohn 的案例中)被認為是相同的事物。對我來說,這混淆了兩個相當正交的概念。
是的,端對端測試應用程式通常表示透過使用者介面驅動測試。然而,反之則不然。
測試使用者介面不必以端對端的方式進行。根據您使用的技術,測試使用者介面可以像為前端 javascript 程式碼撰寫一些單元測試一樣簡單,同時將後端程式碼存根化。
使用傳統的網路應用程式,可以使用類似 Selenium 的工具測試使用者介面。如果您將 REST API 視為使用者介面,您應該透過在 API 周圍撰寫適當的整合測試來獲得所需的一切。
對於網路介面,您可能希望在使用者介面周圍測試多個面向:行為、版面、可用性或是否符合公司設計,這只是其中幾個。
幸運的是,測試使用者介面的行為相當簡單。您在此處按一下,在那裡輸入資料,並希望使用者介面的狀態相應變更。現代單一頁面應用程式架構(React、Vue.js、Angular 等)通常附有自己的工具和輔助程式,讓您能夠以相當低階(單元測試)的方式徹底測試這些互動。即使您使用純 javascript 撰寫自己的前端實作,您也可以使用常規測試工具,例如 Jasmine 或 Mocha。對於更傳統的伺服器端呈現應用程式,基於 Selenium 的測試將是您的最佳選擇。
測試網路應用程式的版面是否保持完整稍微困難一些。根據您的應用程式和使用者的需求,您可能希望確保程式碼變更不會意外地破壞網站的版面。
問題在於電腦出了名的不擅長檢查某個東西「看起來好不好」(也許一些聰明的機器學習演算法可以在未來改變這一點)。
如果你想在建置管線中自動檢查網頁應用程式的設計,有一些工具可以嘗試。這些工具大多使用 Selenium 在不同的瀏覽器和格式中開啟你的網頁應用程式,擷取螢幕截圖並將其與先前擷取的螢幕截圖進行比較。如果舊的和新的螢幕截圖以意外的方式不同,該工具會讓你知曉。
Galen 是這些工具之一。但即使你有特殊需求,自己動手打造解決方案也不是太難。我合作過的一些團隊建置了 lineup 及其基於 Java 的表親 jlineup 來達成類似的目標。這兩個工具都採用了我之前描述的相同基於 Selenium 的方法。
一旦你想要測試 可用性 和「看起來不錯」的因素,你就會離開自動化測試的領域。這是你應該依賴 探索性測試、可用性測試(這甚至可以像 走廊測試 一樣簡單)以及與你的使用者展示以查看他們是否喜歡使用你的產品,以及是否可以在不感到沮喪或煩躁的情況下使用所有功能。
端對端測試
透過使用者介面測試已部署的應用程式是你測試應用程式的最端對端方式。先前描述的,由 Web 驅動程式驅動的 UI 測試是端對端測試的一個好範例。

圖 11:端對端測試測試你的整個、完全整合的系統
端對端測試(也稱為 廣泛堆疊測試)在你需要決定你的軟體是否運作時,能給你最大的信心。Selenium 和 WebDriver 協定 允許你透過自動驅動(無頭)瀏覽器針對已部署的服務,執行點擊、輸入資料和檢查使用者介面的狀態,來自動化你的測試。你可以直接使用 Selenium 或使用建構在其上的工具,Nightwatch 就是其中之一。
端對端測試會帶來它們自己的問題。它們出了名的不穩定,而且常常會因為意外和無法預見的原因而失敗。它們的失敗常常是誤報。你的使用者介面越複雜,測試就越容易不穩定。瀏覽器怪癖、時序問題、動畫和意外的彈出對話框只是讓我花費比我想承認的更多時間進行除錯的一些原因。
在微服務的世界中,還有一個大問題,那就是誰負責撰寫這些測試。由於它們跨越多個服務(你的整個系統),因此沒有單一團隊負責撰寫端對端測試。
如果你有一個集中式的品質保證團隊,他們看起來很合適。不過,擁有集中式的 QA 團隊是一個很大的反模式,不應該出現在你的團隊旨在真正跨職能的 DevOps 世界中。沒有簡單的答案來說明誰應該擁有端到端測試。也許你的組織有一個實務社群或品質公會可以負責這些。找出正確的答案高度取決於你的組織。
此外,端到端測試需要大量的維護,而且執行速度很慢。考慮到一個有超過幾個微服務的環境,你甚至無法在本地執行端到端測試,因為這也需要在本地啟動所有微服務。在你的開發機器上啟動數百個應用程式,而不會耗盡你的 RAM,祝你好運。
由於它們的高維護成本,你應該將端到端測試的數量減至最低限度。
想想使用者與你的應用程式將進行的高價值互動。嘗試提出定義產品核心價值的使用者歷程,並將這些使用者歷程中最重要的步驟轉換為自動化端到端測試。
如果你正在建立一個電子商務網站,你最有價值的客戶歷程可能是使用者搜尋產品、將其放入購物車並進行結帳。就是這樣。只要這個歷程仍然有效,你就不應該遇到太多麻煩。也許你會找到一或兩個可以轉換為端到端測試的關鍵使用者歷程。除此之外的所有內容都可能弊大於利。
請記住:你的測試金字塔中有許多較低層級,你已經在其中測試了各種邊緣案例和與系統其他部分的整合。沒有必要在較高層級重複這些測試。高維護工作和大量的假陽性會減慢你的速度,並導致你對測試失去信心,早晚會發生。
使用者介面端對端測試
對於端到端測試,Selenium 和 WebDriver 協定是許多開發人員的首選工具。使用 Selenium,你可以選擇你喜歡的瀏覽器,並讓它自動呼叫你的網站,在這裡和那裡按一下,輸入資料,並檢查使用者介面中的內容是否變更。
Selenium 需要一個瀏覽器,它可以啟動並用於執行測試。有許多不同的瀏覽器所謂的「驅動程式」可以使用。 選擇一個(或多個)並將其新增到你的 build.gradle
。無論你選擇哪個瀏覽器,你都需要確保團隊中的所有開發人員和你的 CI 伺服器都已在本地安裝正確版本的瀏覽器。保持同步可能很痛苦。對於 Java,有一個不錯的小程式庫稱為 webdrivermanager,它可以自動下載並設定你想要使用的瀏覽器正確版本。將這兩個相依性新增到你的 build.gradle
,你就可以開始了
testCompile('org.seleniumhq.selenium:selenium-chrome-driver:2.53.1') testCompile('io.github.bonigarcia:webdrivermanager:1.7.2')
在你的測試套件中執行一個功能齊全的瀏覽器可能會很麻煩。特別是在使用持續交付時,執行你的管線的伺服器可能無法啟動包括使用者介面在內的瀏覽器(例如,因為沒有可用的 X-Server)。你可以透過啟動一個虛擬 X-Server(例如 xvfb)來解決這個問題。
較新的方法是使用無頭瀏覽器(即沒有使用者介面的瀏覽器)來執行您的 WebDriver 測試。直到最近,PhantomJS 都是用於瀏覽器自動化的領先無頭瀏覽器。自從 Chromium 和 Firefox 宣布在他們的瀏覽器中實作無頭模式以來,PhantomJS 突然變得過時了。畢竟,使用您的使用者實際使用的瀏覽器(例如 Firefox 和 Chrome)來測試您的網站,會比僅因為對您作為開發人員來說方便而使用人工瀏覽器來得好。
無頭 Firefox 和 Chrome 都是全新的,尚未廣泛採用來實作 WebDriver 測試。我們希望讓事情簡單。我們不要忙著使用尖端的無頭模式,而要堅持使用 Selenium 和一般瀏覽器的傳統方式。一個啟動 Chrome、導覽到我們的服務並檢查網站內容的簡單端對端測試如下所示
@RunWith(SpringRunner.class) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) public class HelloE2ESeleniumTest { private WebDriver driver; @LocalServerPort private int port; @BeforeClass public static void setUpClass() throws Exception { ChromeDriverManager.getInstance().setup(); } @Before public void setUp() throws Exception { driver = new ChromeDriver(); } @After public void tearDown() { driver.close(); } @Test public void helloPageHasTextHelloWorld() { driver.get(String.format("http://127.0.0.1:%s/hello", port)); assertThat(driver.findElement(By.tagName("body")).getText(), containsString("Hello World!")); } }
請注意,如果您在執行此測試的系統(您的本機電腦、您的 CI 伺服器)上已安裝 Chrome,此測試才會在您的系統上執行。
測試很簡單。它使用 @SpringBootTest
在隨機埠上啟動整個 Spring 應用程式。然後,我們實例化一個新的 Chrome WebDriver,告訴它導覽到我們微服務的 /hello
端點,並檢查它是否在瀏覽器視窗上印出「Hello World!」很酷吧!
REST API 端對端測試
在測試您的應用程式時避免使用圖形使用者介面,會是不錯的方法,可以想出比完整端對端測試不那麼不穩定的測試,同時仍然涵蓋您應用程式堆疊的廣泛部分。當透過應用程式的 Web 介面進行測試特別困難時,這會很方便。也許您甚至沒有 Web UI,而是提供 REST API(因為您在某處有一個與該 API 對話的單一頁面應用程式,或只是因為您鄙視所有漂亮的東西)。無論如何,一個皮下測試僅在圖形使用者介面下方進行測試,而且可以在不影響信心的情況下讓您走得很遠。如果您像我們的範例程式碼一樣提供 REST API,這正是您需要的
@RestController public class ExampleController { private final PersonRepository personRepository; // shortened for clarity @GetMapping("/hello/{lastName}") public String hello(@PathVariable final String lastName) { Optional<Person> foundPerson = personRepository.findByLastName(lastName); return foundPerson .map(person -> String.format("Hello %s %s!", person.getFirstName(), person.getLastName())) .orElse(String.format("Who is this '%s' you're talking about?", lastName)); } }
讓我向您展示另一個在測試提供 REST API 的服務時派得上用場的函式庫。REST-assured 是一個函式庫,它為您提供一個漂亮的 DSL,用於對 API 發出真正的 HTTP 要求並評估您收到的回應。
首先:將相依項新增到您的 build.gradle
。
testCompile('io.rest-assured:rest-assured:3.0.3')
有了這個函式庫,我們可以為我們的 REST API 實作一個端對端測試
@RunWith(SpringRunner.class) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) public class HelloE2ERestTest { @Autowired private PersonRepository personRepository; @LocalServerPort private int port; @After public void tearDown() throws Exception { personRepository.deleteAll(); } @Test public void shouldReturnGreeting() throws Exception { Person peter = new Person("Peter", "Pan"); personRepository.save(peter); when() .get(String.format("https://127.0.0.1:%s/hello/Pan", port)) .then() .statusCode(is(200)) .body(containsString("Hello Peter Pan!")); } }
同樣地,我們使用 @SpringBootTest
啟動整個 Spring 應用程式。在這個情況下,我們 @Autowire
PersonRepository
,以便我們可以輕鬆地將測試資料寫入我們的資料庫。當我們現在要求 REST API 對我們的友人「Mr Pan」說「hello」時,我們會收到一個友善的問候。太棒了!如果您甚至沒有 Web 介面,這已經是一個非常足夠的端對端測試了。
驗收測試 — 您的功能是否運作正常?
在測試金字塔中往上移動得越高,您越有可能進入從使用者的觀點測試您正在建置的功能是否正確運作的領域。您可以將您的應用程式視為一個黑盒子,並將測試中的焦點從
當我輸入值 x
和 y
時,回傳值應為 z
朝向
假設 有登入使用者
且 有文章「自行車」
當 使用者導覽至「自行車」文章的詳細資料頁面
且 按下「加入購物車」按鈕
則 文章「自行車」應在他們的購物車中
有時你會聽到術語 功能測試 或 驗收測試 用於這類測試。有時人們會告訴你功能測試和驗收測試是不同的東西。有時這些術語會混為一談。有時人們會無止盡地爭論措辭和定義。通常這種討論會造成相當大的混淆。
重點在於:在某個時間點,你應確保從使用者的觀點測試你的軟體是否正確運作,而不僅僅從技術觀點。你如何稱呼這些測試並不太重要。然而,進行這些測試很重要。選擇一個術語,堅持使用,然後撰寫這些測試。
這也是人們談論 BDD 和允許你以 BDD 方式實作測試的工具的時刻。BDD 或 BDD 風格的測試撰寫方式可以是一種不錯的技巧,讓你將心態從實作細節轉移到使用者的需求。繼續嘗試看看。
你甚至不需要採用像 Cucumber 這樣的完整 BDD 工具(儘管你可以)。有些斷言函式庫(例如 chai.js 允許你使用 should
風格關鍵字撰寫斷言,這可以讓你的測試更像 BDD。即使你沒有使用提供此表示法的函式庫,靈巧且經過良好因式分解的程式碼仍能讓你撰寫以使用者行為為重點的測試。一些輔助方法/函式可以讓你走很長一段路
# a sample acceptance test in Python def test_add_to_basket(): # given user = a_user_with_empty_basket() user.login() bicycle = article(name="bicycle", price=100) # when article_page.add_to_.basket(bicycle) # then assert user.basket.contains(bicycle)
驗收測試可以有不同層級的詳細程度。大多數時候,它們的層級會比較高,並透過使用者介面來測試您的服務。不過,您最好了解到,在測試金字塔的最高層級撰寫驗收測試在技術上並非必要。如果您的應用程式設計和手邊的場景允許您在較低層級撰寫驗收測試,那就放手去做吧。低層級測試比高層級測試更好。驗收測試的概念(證明您的功能對使用者來說運作正常)與您的測試金字塔完全無關。
探索性測試
即使是最勤奮的測試自動化工作也並非完美。有時您會在自動化測試中遺漏某些邊界狀況。有時,透過撰寫單元測試幾乎不可能偵測到特定錯誤。某些品質問題甚至不會在您的自動化測試中顯現(想想設計或可用性)。儘管您對測試自動化抱持著最好的意願,但某種程度的手動測試仍然是個好主意。

圖 12:使用探索性測試找出您的建置管線未發現的所有品質問題
在您的測試組合中納入探索性測試。這是一種手動測試方法,強調測試人員的自由度和創造力,以找出執行中系統的品質問題。只要定期撥出一些時間,捲起袖子,並嘗試破壞您的應用程式。使用破壞性的心態,並想出方法來引發您的應用程式中的問題和錯誤。將您找到的所有內容記錄下來,以供日後參考。注意錯誤、設計問題、反應時間過慢、遺失或誤導的錯誤訊息,以及任何會讓您這個軟體使用者感到困擾的事項。
好消息是,您可以愉快地使用自動化測試自動化大部分的發現。針對您發現的錯誤撰寫自動化測試,可確保未來不會有該錯誤的回歸。此外,它還可以幫助您在修正錯誤時縮小該問題的根本原因。
在探索性測試期間,您會發現一些問題,這些問題會在您的建置管線中悄悄溜過。不要感到沮喪。這是對您的建置管線成熟度的絕佳回饋。與任何回饋一樣,請務必採取行動:想想您可以在未來做些什麼來避免這些類型的問題。也許您錯過了一組特定的自動化測試。也許您只是在這次反覆運算中對您的自動化測試草率行事,並需要在未來進行更徹底的測試。也許有一個新的工具或方法,您可以在管線中使用它來避免這些問題。請務必採取行動,這樣您的管線和您的整個軟體交付將會隨著時間的推移而變得更加成熟。
測試術語的混淆
談論不同的測試分類總是困難的。當我談論單元測試時,我的意思可能與你的理解略有不同。對於整合測試來說,情況更糟。對某些人來說,整合測試是一項非常廣泛的活動,它會測試整個系統的許多不同部分。對我來說,這是一件相當狹隘的事情,一次只測試與一個外部部分的整合。有些人稱它們為整合測試,有些人稱它們為元件測試,有些人則偏好服務測試這個術語。甚至還有人會爭辯說,這三個術語完全是不同的東西。沒有對錯之分。軟體開發社群只是無法就測試達成共識,使用定義明確的術語。
不要過於拘泥於模糊的術語。無論你稱它為端到端測試、廣泛堆疊測試或功能測試都沒有關係。你的整合測試對你來說是否與其他公司的員工有不同的意義並不重要。是的,如果我們的專業人員能夠就一些定義明確的術語達成共識並堅持下去,那將非常好。不幸的是,這還沒有發生。而且,由於在撰寫測試時有許多細微差別,因此它更像是一個光譜,而不是一堆離散的區塊,這使得一致的命名更加困難。
重要的結論是,你應該找到適合你和你的團隊的術語。明確你想要撰寫的不同類型的測試。在你的團隊中就命名達成共識,並就每種類型測試的範圍達成共識。如果你在你的團隊(甚至可能在你的組織內部)內部保持一致,那麼這就是你唯一應該關心的。當 Simon Stewart描述他們在 Google 使用的方法時,他很好地總結了這一點。而且我認為這完美地說明了過於糾結於名稱和命名慣例根本不值得大費周章。
將測試放入部署管線
如果您使用持續整合或持續交付,您將會有一個部署管線,它會在您每次變更軟體時執行自動化測試。通常,這個管線會分成幾個階段,逐漸讓您更確信您的軟體已準備好部署到生產環境。在聽聞所有這些不同類型的測試後,您可能會想知道您應該如何將它們放置在您的部署管線中。要回答這個問題,您只需要思考持續交付的其中一個非常基本的價值(的確也是極限編程和敏捷軟體開發的核心價值之一):快速回饋。
一個好的建置管線會盡快告訴您您搞砸了。您不希望等上一個小時才發現您最新的變更中斷了一些簡單的單元測試。如果您的管線花這麼久的時間才給您回饋,您可能已經回家了。您可以透過將快速執行的測試放在管線的較早階段,在幾秒鐘內取得這些資訊,也許幾分鐘內就可以取得。相反地,您將執行時間較長的測試(通常是範圍較廣的測試)放在較後面的階段,以不延遲快速執行的測試的回饋。您會發現,定義部署管線的階段並非由測試類型驅動,而是由它們的速度和範圍驅動。考量到這一點,將一些範圍非常窄且執行快速的整合測試放在與單元測試相同的階段,可能是一個非常合理的決定,原因很簡單,它們會給您更快的回饋,而不是因為您想要沿著測試的形式類型劃清界線。
避免測試重複
現在您知道您應該撰寫不同類型的測試,還有一個陷阱需要避免:在金字塔的不同層級中重複測試。雖然您的直覺可能會告訴您沒有太多測試這回事,但我向您保證,有的。測試套件中的每個測試都是額外的負擔,而且並非免費。撰寫和維護測試需要時間。閱讀和理解其他人的測試需要時間。當然,執行測試也需要時間。
與製作程式碼一樣,您應該力求簡潔並避免重複。在實作測試金字塔的過程中,您應該記住兩個經驗法則
- 如果較高層級的測試發現錯誤,而沒有較低層級的測試失敗,您需要撰寫較低層級的測試
- 將您的測試推到測試金字塔的最底層
第一個法則很重要,因為較低層級的測試讓您能更精確地縮小錯誤範圍,並以孤立的方式複製錯誤。當您除錯手邊的問題時,它們會執行得更快,而且不會那麼臃腫。而且它們將作為未來的良好回歸測試。第二個法則對於保持測試套件快速執行很重要。如果您已經在較低層級的測試中自信地測試了所有條件,就不需要在測試套件中保留較高層級的測試。它只是不會增加所有功能都正常運作的信心。在您的日常工作中,重複的測試會變得令人討厭。您的測試套件會執行得更慢,而且當您變更程式碼的行為時,您需要變更更多測試。
我們用不同的方式來說明這一點:如果較高層級的測試讓您更確信您的應用程式正常運作,您應該這麼做。為 Controller
類別撰寫單元測試有助於測試 Controller 本身的邏輯。不過,這不會告訴您此 Controller 提供的 REST 端點是否實際上會回應 HTTP 要求。因此,您向上移動測試金字塔,並新增測試來檢查這一點,但僅此而已。您不會在較高層級的測試中再次測試較低層級的測試已經涵蓋的所有條件邏輯和臨界狀況。請確定較高層級的測試專注於較低層級的測試無法涵蓋的部分。
在消除沒有提供任何價值的測試時,我非常嚴格。我會刪除較低層級已經涵蓋的高層級測試(假設它們沒有提供額外的價值)。如果可能,我會用較低層級的測試取代較高層級的測試。有時候這很難,特別是如果您知道想出測試是很辛苦的工作。小心沉沒成本謬誤,並按下刪除鍵。沒有理由在已經停止提供價值的測試上浪費更多寶貴的時間。
撰寫乾淨的測試程式碼
與一般編寫程式碼一樣,想出良好、乾淨的測試程式碼需要非常小心。以下提供一些建議,讓你在開始編寫自動化測試套件之前,就能想出可維護的測試程式碼
- 測試程式碼與生產程式碼一樣重要。給予它同等程度的關懷和注意力。「這只是測試程式碼」並非合理化草率編寫程式碼的有效藉口
- 每個測試測試一個條件。這有助於保持測試簡短且易於理解
- 「排列、動作、斷言」或「給定、何時、然後」是讓測試結構良好的良好助記符
- 可讀性很重要。不要過度追求DRY 。如果可以改善可讀性,重複是可以的。嘗試在 DRY 和 DAMP 程式碼之間取得平衡
- 有疑問時,請使用三的定律來決定何時重構。先使用再重複使用
結論
就是這樣!我知道這是一篇冗長且艱澀的文章,用來解釋為什麼以及如何測試軟體。好消息是,這些資訊相當永恆,而且與你建置的軟體類型無關。無論你是在微服務環境、IoT 裝置、行動應用程式或網頁應用程式上工作,這篇文章的教訓都可以應用於所有這些領域。
我希望這篇文章對你有幫助。現在,請繼續查看範例程式碼,並將這裡說明的一些概念納入你的測試組合。建立穩固的測試組合需要付出一些努力。從長遠來看,這將會得到回報,並讓你的開發人員生活更輕鬆,相信我。
致謝
感謝 Clare Sudbery、Chris Ford、Martha Rohte、Andrew Jones-Weiss David Swallow、Aiko Klostermann、Bastian Stein、Sebastian Roidl 和 Birgitta Böckeler 提供意見回饋和建議,協助完成本文的早期草稿。感謝 Martin Fowler 提供建議、見解和支援。
重大修訂
2018 年 2 月 26 日:發布包含 UI 測試的版本
2018 年 2 月 22 日:發布包含合約測試的版本
2018 年 2 月 20 日:發布包含整合測試的分期付款
2018 年 2 月 15 日:發布包含單元測試的分期付款
2018 年 2 月 14 日:第一期,介紹金字塔和範例應用程式