雲栖号資訊:【 點選檢視更多行業資訊】
在這裡您可以找到不同行業的第一手的上雲資訊,還在等什麼,快來!
自動化測試是所有大型軟體項目不可或缺的一部分。它是提高品質、生産力和靈活性的一種手段。 是以,對系統架構進行合理地設計以便利後續的開發和自動化測試變得至關重要。
自動化測試的好處
-
品質得以提高
因為自動化測試讓我們能在開發階段早日發現并解決問題,這避免了在變更部署到生産環境并送出給最終使用者使用時發現問題。
-
生産力得到提高
因為在開發周期中發現問題的時間越早,修複該問題的成本越低,這不言而喻。如果軟體開發人員能夠在将代碼內建到主代碼倉庫前運作自動化測試套件,那麼可以快速發現新引入的 bug 并将其修複。但是,如果沒有這樣的測試套件,那麼新引入的 bug 可能僅在最終使用者使用測試階段中出現,甚至出現更晚,這會導緻開發人員暫停正常開發工作流程來對 bug 進行調查和修複,影響項目進度。
-
靈活性得到改善
有了測試套件的幫助,開發人員在進行代碼重構、更新代碼依賴包及修改系統特性時會更有信心,因為測試套件有非常高的測試覆寫率,可以友善地評估代碼變更帶來的影響。
在讨論自動化測試時,我也喜歡将風險管理的話題引入進來。作為首席軟體工程師,風險管理是我工作的重要組成部分,它涉及指導開發團隊進行工作和流程管理,減少産品技術退化的風險。 從上面列出的好處中可以明顯看出,進行充分的自動化測試非常必要,這可以幫助減輕軟體項目中的風險。
自動化測試類型
接下來,我們可以根據實作和運作自動化測試的政策将其分為至少三種不同類型,如下圖著名的測試金字塔所示:

從時間和資源使用而言,單元測試的開發及運作成本低,并且單元測試專注于測試與外部依賴項隔離的單個系統元件(例如,業務邏輯)。
內建測試向前更進一步,并且在不隔離外部依賴關系的情況下進行開發和運作。在這種情況下,我們有興趣評估所有系統元件建構在一起并面臨內建限制(例如:聯網、存儲、處理等)時是否按預期進行互動。
最後,在金字塔的頂端,GUI 測試是整個自動化測試中代價最高的。他們通常依靠 UI 輸入 / 輸出腳本以及回放工具來模仿最終使用者與系統圖形使用者界面的互動。
在本文中,我們将重點介紹測試金字塔的基礎——單元測試,以及采用單元測試的系統體系結構在建構時的注意事項。
有效單元測試的屬性
首先,讓我們說明一下什麼是有效的,設計良好的單元測試。
簡短——隻有一個測試目的
簡單——設定及拆卸友善
快速——可以快速執行
标準——遵循嚴格的約定
理想情況下,單元測試應具有所有上述這些屬性,下面将詳細說明原因。
如果單元測試不夠簡短,将很難閱讀并了解其目的,确切地說是很難了解測試内容。是以,出于這個原因,單元測試應該有一個明确目标,并且隻評估測試一件事,而不是嘗試同時執行多個測試目的。這樣,當某個單元測試失敗時,開發人員将更加輕松快捷地定位問題并進行修複。
如果單元測試需要大量精力來設定他們的測試環境,然後将其拆除,那麼開發人員通常會開始質疑,花費在編寫這些測試上的時間是否值得。是以,我們需要提供一個編寫單元測試的環境,該環境要管理測試上下文的所有複雜性,例如依賴注入,資料預加載,緩存清除等。編寫單元測試越容易,開發人員建立它們的動力就越大!
如果執行一組單元測試需要花費大量時間,則開發人員自然會減少執行頻率。這裡的問題在于擁有如此冗長的單元測試套件變得不切實際,開發人員會跳過運作單元測試或有選擇地運作,進而降低了其有效性。
最後,如果測試沒有一定的标準,不久之後你的測試套件開始看起來像未拓荒的美國西部一樣,編寫單元測試所使用的編碼風格有時會有所不同,甚至會發生沖突。是以,在整個單元測試的範圍内追求系統設計的連貫性在整個系統中都是有價值的。
一旦我們對有效的單元測試的架構達成共識,就可以開始定義提升其性能的系統架構準則,如以下各節所述。
一、軟體複雜度
除其他因素以外,軟體複雜度還源于系統内元件之間不斷增加的互動及其内部狀态的演變。随着複雜度的提高,無意識地幹擾複雜的元件互動網絡的風險也随之增加,這可能導緻在代碼變更時引入缺陷。
此外,通常情況下,系統的複雜性越高,維護和測試就越困難,這引出第一個(一般)準則:
密切關注軟體的複雜度并遵循設計原則來控制它
在提高測試性能的同時管理複雜性的方面,值得一提的一個實踐方法是,在系統設計中盡可能采用純函數和不變性。 純函數是具有以下屬性的函數:
對于相同的參數,其傳回值是相同的(不随局部靜态變量,非局部變量,可變引用參數或來自 I/O 裝置的輸入的變化而變化)。
它的評估測試不會産生副作用(局部靜态變量,非局部變量,可變引用參數或 I/O 流不會因測試受到影響)。
從其屬性可以明顯看出,純函數非常适合單元測試。它們的使用也消除了許多補充性實踐的需求,這些補充性實踐将在以下各節中讨論,以處理大部分為有狀态的元件。
不變性起着同等重要的作用。不可變對象是建立後狀态無法修改的對象。它們更易于互動和具有可預測性,進而有助于降低系統複雜性,消除全局狀态。
二、依賴隔離
按照單元測試定義,單元測試旨在隔離測試各個系統元件,因為我們不希望元件的單元測試結果受到其依賴項的影響。隔離程度會根據被測元件的具體情況以及每個開發團隊的偏好而有所不同。我個人不擔心隔離輕量級的内部業務類,因為我發現,用功能幾乎相同的測試元件替代它們不會顯示有什麼附加影響。這裡的政策可能很簡單:
在元件設計中應用依賴反轉模式
依賴反轉模式(DIP)指出,進階和低級對象都應依賴抽象(例如接口),而不是特定的具體實作。一旦将系統元件從其依賴關系中解耦出來,我們就可以在單元測試的上下文中通過簡化的、針對測試的具體實作輕松地替換它們。下面的類圖可以展示這種結構:
在此示例中,被測試的元件依賴 Repository和 FileStore 抽象類。當部署到生産環境中時,我們可能會為 Repository 類注入基于 SQL 的具體實作,并為檔案存儲元件注入基于 S3 的實作,以便在 AWS Cloud 中遠端存儲檔案。不過,在運作單元測試時,我們将希望注入不依賴外部服務的簡化功能實作,例如上圖中綠色标記的“In Memory”實作。
如果你不熟悉 DIP,我曾發表一篇關于如何使用 DIP 的文章: Integrating third-party modules ,這或許對你有所幫助。
三、Mocks vs Fakes
請注意,我沒有将這些“in memory”實作稱為“mocks”。mocks 指模拟對象,它以有限的受控方式模拟了真實對象的行為。我反對使用模拟對象,而贊成使用完全相容的“fake”實作,是因為後者為我們提供了編寫單元測試的更大靈活性,相比設定模拟對象,它以更加可靠的方式從多個單元測試類中進行重用。
為了更詳細地說明,假設我們正在為依賴FileStore抽象類的元件編寫單元測試。在此測試中,該元件将一條記錄添加到檔案存儲中,但并不擔心操作是否成功(例如,日志檔案),是以我們決定以“虛拟”方式模拟該操作。
現在,假設稍後需求發生變化,并且元件需要確定在繼續操作之前通過從檔案存儲中讀取檔案來建立檔案,進而迫使我們更新模拟的行為以通過測試。然後,想象需求又發生了變化,并且元件需要寫入多個檔案(例如:每個日志級别對應一個日志檔案),而不是隻寫入一個,進而迫使我們的模拟對象行為再次進行修改。你知道發生了什麼嗎?我們正在慢慢改進我們的模拟,使其代碼更趨近于具體的實作。
更糟糕的是,我們最終可能會在整個代碼庫中散布數很多獨立的,半成品的模拟實作,每個單元測試類對應一個,進而導緻測試環境更多的維護工作以及較低的内聚性。
為了解決這種情況,我提出以下準則:
依靠 Fakes 而不是 Mocks 來實施單元測試,将其視為一等的公民,并将其組織為可重用的子產品
由于 Fake 元件實作了業務行為,是以與設定模拟對象相比,它們本質上是更昂貴的初始投資。但是,它們的長期回報肯定更高,并且更符合有效的單元測試的特性。
四、編碼風格
每個自動化測試都可以描述為三步:
準備測試環境
執行關鍵操作
驗證結果
(Given) 給定已知的初始狀态,(When) 然後執行某項操作,(Then) 每次操作最終都應産生相同的預期結果,這是非常符合邏輯的思考過程。為了使結果變得不同,必須更改初始狀态,或者更改操作實作本身。
你可能對上面用黑體字标出的單詞很熟悉。它們代表了一種流行的 Given-When-Then 模式,利用該模式可以編寫可讀性高以及結構清晰的單元測試代碼。這一概念很簡單:
為單元測試定義和實施單一标準化的編碼風格
Given-When-Then 模式有多種實作方式。其中一個方法是将單元測試方法構造為三種不同的方法。例如,考慮使用者的密碼強度測試:
[TestMethod]
public void WeakPasswordStrengthTest()
{
var password = GivenAWeakPassowrd();
var score = WhenThePasswordStrengthIsEvaluated(password);
ThenTheScoreShouldIndicateAWeakPassword(score);
}
private string GivenAWeakPassowrd()
{
return "qwerty";
}
private int WhenThePasswordStrengthIsEvaluated(string password)
{
var calculator = new PasswordStrengthCalculator();
return (int)calculator.GetStrength(password);
}
private void ThenTheScoreShouldIndicateAWeakPassword(int score)
{
Assert.AreEqual((int)PasswordStrength.Weak, score);
}
使用這種方法時,主測試方法變成了對該單元測試的三行描述,即使是非開發人員也可以通過閱讀來輕松了解。實際上,單元測試的主方法最終會成為系統行為的低級文檔,不僅提供文本描述,還提供了執行代碼、調試代碼并定位内部問題的可能性。當新開發人員加入團隊時,這對于縮短系統架構學習曲線非常有價值。
需要強調一下,在編碼風格方面,沒有唯一正确的方法。我在上面提供的示例可能會使某些開發人員感到不滿,例如,因為代碼冗長而令人不悅,不過這沒關系。真正重要的是,應該在你的開發團隊内部就編碼規範約定達成一緻,每一位成員應始終堅持按照該規範編寫有意義的測試代碼。
五、測試上下文管理
單元測試上下文管理是一個讨論不夠多的話題。“測試上下文”是指成功運作單元測試所需的整個依賴注入以及初始狀态設定。
如前所述,當開發人員花費更少的時間來設定測試上下文環境并騰出時間編寫測試用例時,單元測試會更有效。我們從以下觀察得出我們的最後一個準則,即大量的測試案例可以共享一些測試上下文:
利用構造器類将測試上下文的建構與單元測試用例的實作分開
這個想法是将測試上下文的構造邏輯封裝在構造器類中,并在單元測試類中引用它們。然後,每個上下文構造器負責建立特定的測試方案,并可選擇地定義用于使其特定化的方法。
讓我們看一下另一個代碼示例。假設我們正在開發一個反作弊元件,以檢測移動應用程式使用者可疑的位置變化。測試上下文構造器可能如下所示:
public class MobileUserContextBuilder : ContextBuilder
{
public override void Build()
{
base.Build();
/*
The build method call above is used for
injecting dependencies and setting up generic
state common to all tests.
After it we would complete building the test
context with what's relevant for this scenario
such as emulating a mobile user account sign up.
*/
}
public User GetUser()
{
/*
Auxiliary method for returning the user entity
created for this test context.
*/
}
public void AddDevice(User user, DeviceDescriptior device)
{
/*
Auxiliary method for particularizing the test
context, in this case for linking another
mobile device to the test user's account
(deviceType, deviceOS, ipAddress, coordinates, etc)
*/
}
}
由MobileUserContextBuilder建立的測試上下文足夠通用,從應用程式注冊了移動使用者的狀态開始,任何測試用例都可以使用它。最重要的是,它定義了AddDevice方法,用于特定化測試上下文,以滿足我們虛拟的反作弊元件測試需求。
該反作弊元件稱為GeolocationScreener,它負責檢查移動使用者的位置是否改變得太快,如果變化太快,這表明使用者可能是在僞造自己的真實坐标。其中的一個單元測試可能如下所示:
public class GeolocationScreenerTests
{
[TestInitialize]
public void TestInitialize()
{
context = new MobileUserContextBuilder();
context.Build();
}
[TestMethod]
public void SuspiciousCountryChangeTest()
{
var user = GivenALocalUser();
var report = WhenTheUserCountryIsChangedAbruptly(user);
ThenAnAntiFraudAlertShouldBeRaised(report);
}
[TestCleanup]
public void TestCleanup()
{
context.Dispose();
}
private User GivenALocalUser()
{
return context.GetUser();
}
private SecurityReport WhenTheUserCountryIsChangedAbruptly(User user)
{
var device = user.CurrentDevice.Clone();
device.SetLocation(Location.GetCountry("Italy").GetCity("Rome"));
context.AddDevice(user, device);
var screener = new GeolocationScreener();
return screener.Evaluate(user);
}
private void ThenAnAntiFraudAlertShouldBeRaised(SecurityReport report)
{
Assert.AreEqual(RetportType.Geolocation, report.Type);
Assert.IsTrue(report.AlertRaised);
}
private MobileUserContextBuilder context;
}
可以看出,在此示例測試類中專用于設定測試上下文的代碼量很小,因為它幾乎完全包含在構造器類中,進而保留了代碼的可讀性群組織性。随着越來越多的測試用例利用友善的測試上下文構造器庫,設定測試上下文所需的時間經過攤銷後變得非常短。
總結
在這篇文章中,我讨論了單元測試的主題,提供了五個主要準則,以應對在不斷增長的測試用例中保持有效性的挑戰。這些準則對系統體系結構有重要影響,從軟體項目開始就應該考慮單元測試要求并營造這種環境,讓開發人員看到單元測試價值并激發開發人員編寫單元測試。
單元測試應被視為系統體系結構的組成部分,與它們所測試的元件一樣重要,而不應被視為二等公民,避免出現開發團隊僅僅為了應付編寫管理報告或提供名額而進行單元測試的現象。
最後,如果你在一個幾乎沒有單元測試的遺留項目中工作,且沒有使用 DIP,那麼本篇文章可能就沒有适合你的最佳政策,因為我有意避開談論那些複雜的模拟架構,而這些架構正是在遺留項目中将單元測試引入極端耦合代碼的可行選擇。
【雲栖号線上課堂】每天都有産品技術專家分享!
課程位址:
https://yqh.aliyun.com/zhibo立即加入社群,與專家面對面,及時了解課程最新動态!
【雲栖号線上課堂 社群】
https://c.tb.cn/F3.Z8gvnK
原文釋出時間:2020-06-22
本文作者:thomas vilhena
本文來自:“
InfoQ”,了解相關資訊可以關注“[InfoQ](
https://mp.weixin.qq.com/s/oUDJ7H7js2LpUMVzg5driA)