在使用 Windows 窗體時代的傳統應用程序的情況下,標准測試做法是布局一個視圖,在該視圖的代 碼隱藏文件中編寫代碼,然後運行該應用程序以進行測試。幸運的是,在那以後,相關做法有了一些變 化。
Windows Presentation Foundation (WPF) 的出現將數據綁定概念提升到了一個全新的水平。它使得 一種稱為“模型-視圖-視圖模型”(MVVM) 的新設計模式得到發展。通過 MVVM,您可以將表 示邏輯與實際表示分離開。基本上,這意味著您可以在極大程度上避免在視圖的代碼隱藏文件中編寫代 碼。
對於那些對開發可測試應用程序感興趣的人來說,這是一項重大改進。現在,您不必將表示邏輯附加 到視圖具有自身生命周期的代碼隱藏文件(從而使測試變得復雜),而是可以使用普通的舊 CLR 對象 (POCO)。視圖模型沒有視圖所具有的生命周期約束。您可以在單元測試中將某個視圖模型實例化並進行 測試。
在本文中,我將介紹如何著手使用 MVVM 編寫應用程序的可測試表示層。為了幫助說明我的方法,我 在這裡提供了我曾編寫的一個開放源框架(稱為 Charmed)中的示例代碼,以及隨附的示例應用程序( 稱為 Charmed Reader)。此框架和示例應用程序可在 GitHub 上找到 (github.com/brentedwards/Charmed)。
我曾在 2013 年 7 月的文章中將該 Charmed 框架作為 Windows 8 框架和示例應用程序進行了介紹 (msdn.microsoft.com/magazine/dn296512)。隨後,在 2013 年 9 月的文章 (msdn.microsoft.com/magazine/dn385706) 中,我又將其作為 Windows 8 和 Windows Phone 8 框架和 示例應用程序討論了如何實現跨平台應用。在這兩篇文章中,我都談到我為了保持該應用程序的可測試 性而做出的決定。現在,我將再次討論這些決定,並說明如何實際著手測試該應用程序。本文中的示例 采用 Windows 8 和 Windows Phone 8 代碼,但您可以將這些概念和方法應用於任何類型的應用程序。
關於示例應用程序
用於說明我如何著手編寫可測試表示層的示例應用程序名為 Charmed Reader。Charmed Reader 是一 個簡單的博客閱讀器應用程序,可在 Windows 8 和 Windows Phone 8 上運行。它具有說明我要涉及的 要點所需的最少功能。此應用程序可跨平台運行,並且在兩個平台上的運行方式幾乎相同,不同之處在 於,Windows 8 應用程序會利用某些 Windows 8 特有的功能。而應用程序基本上相同,具有單元測試所 需的足夠功能。
什麼是單元測試?
所謂單元測試,就是獲取離散代碼塊(單元)並編寫以預期方式使用代碼的測試方法,然後進行測試 ,看是否可以取得預期結果。此測試代碼使用某種測試工具框架運行。有多種適用於 Visual Studio 2012 的測試工具框架。在該示例代碼中,我使用了 Visual Studio 2012(以及更早版本)中內置的 MSTest。目標是讓單一單元測試方法面向某個具體方案。有時,需要使用多個單元測試方法才能涵蓋您 預期您的方法或屬性適用的所有方案。
單元測試方法應遵循一致的格式,以便於其他開發人員理解。以下格式通常被視為最佳做法:
Arrange
Act
首先,為創建被測試類的實例以及它可能具有的任何依賴關系,您可能需要編寫一些設置代碼。這是 單元測試的 Arrange 部分。
在完成為該單元測試設置實際測試階段之後,您可以執行相關方法或屬性。這是單元測試的 Act 部 分。您可以使用在 Arrange 部分中設置的參數(如果適用)執行相關方法或屬性。
最後,在執行了相關方法或屬性後,測試需要驗證該方法或屬性的運行結果是否與預期完全一致。這 是單元測試的 Assert 部分。在斷言階段,將會調用斷言方法以將實際結果與預期結果進行比較。如果 實際結果符合預期,則單元測試通過。如果不符合,則測試失敗。
我的測試遵循這種最佳實踐格式,通常看上去與下面類似:
[TestMethod] public void SomeTestMethod() { // Arrange // *Insert code to set up test // Act // *Insert code to call the method or property under test // Assert // *Insert code to verify the test completed as expected }
某些人雖然使用這種格式,但沒有包括用於調用該測試不同部分 (Arrange/Act/Assert) 的注釋。我 喜歡用注釋將這三個部分分開,只是為了確保保持對實際測試內容或何時進行設置的跟蹤。
擁有一整套編寫良好的單元測試的另一個好處是,它們可以充當該應用程序的活文檔。查看您的代碼 的新開發人員可以查看這些單元測試所涉及的不同方案,從而了解您期望如何使用該代碼。
規劃可測試性
如果您想編寫可測試的應用程序,事先規劃很有幫助。您需要對應用程序體系結構進行設計,使其有 利於進行單元測試。靜態方法、封裝類、數據庫訪問以及 Web 服務調用都可能使應用程序難於或無法進 行單元測試。但是,如果進行了某種規劃,您可以將這些方面對應用程序的影響降到最低。
Charmed Reader 專為閱讀博客文章而設計。下載這些博客文章涉及對 RSS 源的 Web 訪問,而對該 功能進行單元測試可能會相當困難。首先,您應能夠在斷開狀態下快速運行單元測試。在單元測試中依 賴 Web 訪問可能會違反這些原則。
並且,單元測試應該是可重復的。由於博客通常會定期更新,因此隨著時間的推移,可能無法下載相 同的數據。我事先就知道,如果不提前規劃,對用於加載博客文章的功能進行單元測試將是不可能的。
下面是我所知道的需要執行的步驟:
MainViewModel 需要加載用戶想要一次閱讀的所有博客文章。
需要從用戶已保存的各種 RSS 源下載這些博客文章。
下載之後,需要將這些博客文章解析為數據傳輸對象 (DTO) 並提供給視圖使用。
如果我將用於下載 RSS 源的代碼放在 MainViewModel 中,該代碼會立即開始負責更多事情,而不僅 僅是加載數據以及讓視圖與其進行數據綁定以供顯示。隨後,MainViewModel 將負責發出 Web 請求並分 析 XML 數據。我實際上想做的是讓 MainViewModel 通過調用一個幫助程序來發出 Web 請求並分析 XML 數據。隨後,應該為 MainViewModel 提供代表要顯示的博客文章的對象實例。這些對象實例稱為 DTO。
知道這一點後,我可以使加載並解析為 MainViewModel 可以調用的幫助程序對象的 RSS 源抽象化。 不過,要做的事不止這些。如果我只是創建一個負責 RSS 源數據工作的幫助程序類,則我圍繞此功能而 為 MainViewModel 編寫的任何單元測試最終也會調用該幫助程序類以執行 Web 訪問。如前所述,這就 違背了單元測試的目標。因此,我需要再向前邁一步。
如果我為 RSS 源數據加載功能創建一個界面,就可以將我的視圖模型用於該界面(而不是具體的類 )。然後,我可以針對何時運行單元測試(而不是運行應用程序)提供該界面的不同實現形式。這就是 模擬背後的原理。當我真正運行該應用程序時,我需要用於加載真實 RSS 源數據的真實對象。在我運行 單元測試時,我需要一個只是偽裝成加載 RSS 數據、但從不實際訪問 Web 的模擬對象。該模擬對象可 以創建可重復且永遠不會改變的一致數據。然後,我的單元測試就能准確確定每次的預期結果。
考慮到這一點,我編寫的用於加載博客文章的界面與下面類似:
public interface IRssFeedService { Task<List<FeedData>> GetFeedsAsync(); }
MainViewModel 僅可以使用一種方法(即 GetFeedsAsync)來加載博客文章數據。MainViewModel 無 需關心 IRssFeedService 加載數據或分析數據的方式。MainViewModel 只需要關心調用 GetFeedsAsync 將會異步返回博客文章數據。鑒於該應用程序的跨平台性質,這一點尤其重要。
Windows 8 和 Windows Phone 8 具有不同的下載和分析 RSS 源數據的方式。通過創建 IRssFeedService 界面並讓 MainViewModel 與該界面交互(而不是直接下載博客源),我避免了強制 MainViewModel 具有同一功能的多種實現形式。
使用依賴關系注入,我可以確保適時為 MainViewModel 提供正確的 IRssFeedService 實例。如前所 述,我將在單元測試期間提供 IRssFeedService 的一個模擬實例。有關將 Windows 8 和 Windows Phone 8 代碼用作單元測試討論基礎的一件有趣事情是,這些平台目前還沒有任何真正的動態模擬框架 。由於模擬是對我的代碼進行單元測試的重要部分,我必須自己想出創建模擬的簡便方法。圖 1 顯示了所得到的 RssFeedServiceMock。
圖 1 RssFeedServiceMock
public class RssFeedServiceMock : IRssFeedService { public Func<List<FeedData>> GetFeedsAsyncDelegate { get; set; } public Task<List<FeedData>> GetFeedsAsync() { if (this.GetFeedsAsyncDelegate != null) { return Task.FromResult<List<FeedData>>(this.GetFeedsAsyncDelegate()); } else { return Task.FromResult<List<FeedData>>(null); } } }
基本上,我需要能夠提供可以設置數據加載方式的委托。如果您不是面向 Windows 8 或 Windows Phone 8 進行開發,則您很有可能可以使用像 Moq、Rhino Mocks 或 NSubstitute 這樣的動態模擬框架 。無論是采用自己的模擬方法還是使用動態模擬框架,所采用的原理是相同的。
在創建了 IRssFeedService 界面並將其注入到 MainViewModel 中(在 IRssFeedService 界面上調 用 GetFeedsAsync 的 MainViewModel)以及創建了 RssFeedServiceMock 並可供使用之後,現在 應該測試 MainViewModel 與 IRssFeedService 的交互。在這種交互中,我要測試的重要方面是 MainViewModel 可正確調用 GetFeedsAsync,並且返回的源數據與 MainViewModel 通過 FeedData 屬性 提供的源數據相同。圖 2 中的單元測試會對此進行驗證。
圖 2 測試源加載功能
[TestMethod] public void FeedData() { // Arrange var viewModel = GetViewModel(); var expectedFeedData = new List<FeedData>(); this.RssFeedService.GetFeedsAsyncDelegate = () => { return expectedFeedData; }; // Act var actualFeedData = viewModel.FeedData; // Assert Assert.AreSame(expectedFeedData, actualFeedData); }
每當我對視圖模型(或任何其他對象)進行單元測試時,我喜歡用一種幫助程序方法,它可為我提供 要測試的視圖模型的實際實例。視圖模型可能會隨時間發生改變,從而可能會將不同的內容注入到視圖 模型中,這意味著會有不同的構造函數參數。如果我在所有單元測試中都創建該視圖模型的新實例,然 後更改該構造函數的簽名,那麼我必須隨該簽名一起更改一整批單元測試。但是,如果我創建一種幫助 程序方法以便創建該視圖模型的新實例,那麼我只需在一處進行更改。在本例中,GetViewModel 就是幫 助程序方法:
private MainViewModel GetViewModel() { return new MainViewModel(this.RssFeedService, this.Navigator, this.MessageBus); }
我還使用 TestInitialize 屬性來確保在運行每個測試之前重新創建 MainViewModel 依賴關系。下 面就是實現這種情況的 TestInitialize 方法:
[TestInitialize] public void Init() { this.RssFeedService = new RssFeedServiceMock(); this.Navigator = new NavigatorMock(); this.MessageBus = new MessageBusMock(); }
通過此方法,此測試類中的每個單元測試在模擬運行時都具有所有模擬的全新實例。
回來看一下該測試本身,下面的代碼可創建我所預期的源數據並設置用於返回該數據的模擬 RSS 源 服務:
var expectedFeedData = new List<FeedData>(); this.RssFeedService.GetFeedsAsyncDelegate = () => { return expectedFeedData; };
請注意,我沒有向 expectedFeedData 的列表中添加任何實際 FeedData 實例,因為我不需要這樣做 。 我只需要確保該列表本身就是 MainViewModel 最終使用的列表。 我不關心該列表中實際有 FeedData 實例時會發生什麼,至少這個測試是這樣。
該測試 Act 部分的代碼行如下:
var actualFeedData = viewModel.FeedData;
我隨後可以斷言,actualFeedData 是 expectedFeedData 的相同實例。 如果它們不是相同的實例, 則 MainViewModel 就不會完成其工作,單元測試就會失敗。
Assert.AreSame(expectedFeedData, actualFeedData);
可測試的導航
我想測試的該示例應用程序的另外一個重要部分就是導航。 Charmed Reader 示例應用程序使用基於 模型視圖的導航,因為我想要將視圖和視圖模型分開。 Charmed Reader 是一個跨平台應用程序,我所 創建的視圖模型在兩個平台上使用,但視圖對於 Windows 8 和 Windows Phone 8 來說應是不同的。 原 因有很多,但都會歸結為這樣一個事實,即每個平台都具有略微不同的 XAML。 因此,我並不想讓視圖 模型知道視圖的情況,因為這樣會將事情復雜化。
出於幾種原因,對界面背後的導航功能加以抽象不失為一個解決方案。 頭等重要的是,每個平台在 導航中都涉及不同的類,而我不想讓我的視圖模型牽扯到這些差異。 在這兩種情況下,都無法對導航中 涉及的類進行模擬。 這樣,我就從該視圖模型抽象出這些問題,並創建了 INavigator 界面:
public interface INavigator
{
bool CanGoBack { get; }
void GoBack();
void NavigateToViewModel<TViewModel>(object parameter = null);
#if WINDOWS_PHONE
void RemoveBackEntry();
#endif // WINDOWS_PHONE
}
我通過構造函數將 INavigator 注入到 MainViewModel 中,而 MainViewModel 在一個名為 ViewFeed 的方法中使用 INavigator:
public void ViewFeed(FeedItem feedItem)
{
this.
navigator.NavigateToViewModel<FeedItemViewModel>(feedItem);
}
當我查看 ViewFeed 如何與 INavigator 進行交互時,可以看到在編寫該單元測試時想要驗證的兩件 事:
傳遞到 ViewFeed 中的 FeedItem 就是傳遞到 NavigateToViewModel 中的同一個 FeedItem。
傳遞到 NavigateToViewModel 的視圖模型類型是 FeedItemViewModel。
在我實際編寫該測試之前,需要創建另一個模擬,此次是用於 INavigator。 圖 3 顯示了用於 INavigator 的模擬。 我遵循了之前針對每個方法使用委托時的相同模式,以作為在調用該實際方法時 執行測試代碼的方法。 同樣,如果使用支持模擬框架的平台,就不需要創建自己的模擬。
圖 3 用於 INavigator 的模擬
public class NavigatorMock : INavigator { public bool CanGoBack { get; set; } public Action GoBackDelegate { get; set; } public void GoBack() { if (this.GoBackDelegate != null) { this.GoBackDelegate(); } } public Action<Type, object> NavigateToViewModelDelegate { get; set; } public void NavigateToViewModel<TViewModel>(object parameter = null) { if (this.NavigateToViewModelDelegate != null) { this.NavigateToViewModelDelegate(typeof(TViewModel), parameter); } } #if WINDOWS_PHONE public Action RemoveBackEntryDelegate { get; set; } public void RemoveBackEntry() { if (this.RemoveBackEntryDelegate != null) { this.RemoveBackEntryDelegate(); } } #endif // WINDOWS_PHONE }
有了自己的模擬 Navigator 類,我就可以在單元測試中使用它,如圖 4 所示。
圖 4 使用模擬導航器測試導航
[TestMethod] public void ViewFeed() { // Arrange var viewModel = this.GetViewModel(); var expectedFeedItem = new FeedItem(); Type actualViewModelType = null; FeedItem actualFeedItem = null; this.Navigator.NavigateToViewModelDelegate = (viewModelType, parameter) => { actualViewModelType = viewModelType; actualFeedItem = parameter as FeedItem; }; // Act viewModel.ViewFeed(expectedFeedItem); // Assert Assert.AreSame(expectedFeedItem, actualFeedItem, "FeedItem"); Assert.AreEqual(typeof(FeedItemViewModel), actualViewModelType, "ViewModel Type"); }
這個測試實際所關心的是傳遞的 FeedItem 是否正確以及要導航到的視圖模型是否正確。 在使用模 擬時,請務必記住,您應該關注特定測試而不必關注其他無關的事情。 在這個測試中,由於我有 MainViewModel 所使用的 INavigator 界面,因此,無需關心實際上是否發生了導航。 這個問題是由針 對運行時實例實現 INavigator 的機制進行處理的。 我只需要關心進行導航時為 INavigator 提供正確 的參數。
可測試的輔助磁貼
測試時我要檢查的最後一個方面就是輔助磁貼。 Windows 8 和 Windows Phone 8 中均提供了輔助磁 貼,用戶可使用這些輔助磁貼將應用程序的元素固定到主屏幕上,從而創建與應用程序特定部分的深層 鏈接。 但是,這兩個平台對輔助磁貼的處理方式完全不同,這就意味著,我必須提供與平台相關的實現 方式。 盡管存在這種差異,我仍能夠為輔助磁貼提供在兩個平台上都可以使用的一致界面:
public interface ISecondaryPinner
{
Task<bool> Pin(TileInfo tileInfo);
Task<bool> Unpin(TileInfo tileInfo);
bool IsPinned(string tileId);
}
TileInfo 類是一個 DTO,它將適用於兩個平台的屬性結合在一起以創建輔助磁貼。 由於每個平台采 用 TileInfo 中屬性的不同組合,因此,需要以不同方式對每個平台進行測試。 我們具體看一看 Windows 8 版本。 圖 5 顯示了我的視圖模型使用 ISecondaryPinner 的方式。
在圖 5 中的 Pin 方法中,實際發生了兩件事。 第一是對輔助磁貼進行了實際固定。 第二是將 FeedItem 保存到本地存儲中。 因此,我需要對這兩方面進行測試。 由於此方法基於嘗試固定 FeedItem 的結果來更改視圖模型上的 IsFeedItemPinned 屬性,因此,我還需要對 ISecondaryPinner 測試 Pin 方法的兩個可能結果:true 和 false。 圖 6 顯示了我實現的第一個測試,該測試對成功方 案進行測試。
圖 5 使用 ISecondaryPinner
public async Task Pin(Windows.UI.Xaml.FrameworkElement anchorElement) { // Pin the feed item, then save it locally to make sure it's still available // when they return. var tileInfo = new TileInfo( this.FormatSecondaryTileId(), this.FeedItem.Title, this.FeedItem.Title, Windows.UI.StartScreen.TileOptions.ShowNameOnLogo | Windows.UI.StartScreen.TileOptions.ShowNameOnWideLogo, new Uri("ms-appx:///Assets/Logo.png"), new Uri("ms-appx:///Assets/WideLogo.png"), anchorElement, Windows.UI.Popups.Placement.Above, this.FeedItem.Id.ToString()); this.IsFeedItemPinned = await this.secondaryPinner.Pin(tileInfo); if (this.IsFeedItemPinned) { await SavePinnedFeedItem(); } }
圖 6 測試成功固定
[TestMethod] public async Task Pin_PinSucceeded() { // Arrange var viewModel = GetViewModel(); var feedItem = new FeedItem { Title = Guid.NewGuid().ToString(), Author = Guid.NewGuid().ToString(), Link = new Uri("http://www.bing.com") }; viewModel.LoadState(feedItem, null); Placement actualPlacement = Placement.Default; TileInfo actualTileInfo = null; SecondaryPinner.PinDelegate = (tileInfo) => { actualPlacement = tileInfo.RequestPlacement; actualTileInfo = tileInfo; return true; }; string actualKey = null; List<FeedItem> actualPinnedFeedItems = null; Storage.SaveAsyncDelegate = (key, value) => { actualKey = key; actualPinnedFeedItems = (List<FeedItem>)value; }; // Act await viewModel.Pin(null); // Assert Assert.AreEqual(Placement.Above, actualPlacement, "Placement"); Assert.AreEqual(string.Format(Constants.SecondaryIdFormat, viewModel.FeedItem.Id), actualTileInfo.TileId, "Tile Info Tile Id"); Assert.AreEqual(viewModel.FeedItem.Title, actualTileInfo.DisplayName, "Tile Info Display Name"); Assert.AreEqual(viewModel.FeedItem.Title, actualTileInfo.ShortName, "Tile Info Short Name"); Assert.AreEqual(viewModel.FeedItem.Id.ToString(), actualTileInfo.Arguments, "Tile Info Arguments"); Assert.AreEqual(Constants.PinnedFeedItemsKey, actualKey, "Save Key"); Assert.IsNotNull(actualPinnedFeedItems, "Pinned Feed Items"); }
查看本欄目
與前面的測試相比,這個測試中涉及的設置略多一些。首先,在控制器之後,我設置了一個 FeedItem 實例。請注意,我針對 Title 和 Author 都在 Guid 上調用 ToString。這是因為,我不關心 實際值是什麼,只關心它們具有我能夠與斷言部分中的值進行比較的值。由於 Link 是一個 Uri,我需 要一個有效 Uri 才能進行比較,因此我提供了一個 Uri。同樣,實際 Uri 是什麼並不重要,只要它是 有效的就可以。其余設置涉及到確保捕獲用於固定和保存的交互,以便與斷言部分進行比較。確保此代 碼實際測試成功方案的關鍵是 PinDelegate 返回 true(表示成功)。
圖 7 顯示的測試大部分是相同的,只不過是一個不成功方案。PinDelegate 返回 false 可確保該測試中關注不成功方案。在不成功方案中,還需要在斷言部分驗證沒有調用 SaveAsync 。
圖 7 測試不成功固定
[TestMethod] public async Task Pin_PinNotSucceeded()s { // Arrange var viewModel = GetViewModel(); var feedItem = new FeedItem { Title = Guid.NewGuid().ToString(), Author = Guid.NewGuid().ToString(), Link = new Uri("http://www.bing.com") }; viewModel.LoadState(feedItem, null); Placement actualPlacement = Placement.Default; TileInfo actualTileInfo = null; SecondaryPinner.PinDelegate = (tileInfo) => { actualPlacement = tileInfo.RequestPlacement; actualTileInfo = tileInfo; return false; }; var wasSaveCalled = false; Storage.SaveAsyncDelegate = (key, value) => { wasSaveCalled = true; }; // Act await viewModel.Pin(null); // Assert Assert.AreEqual(Placement.Above, actualPlacement, "Placement"); Assert.AreEqual(string.Format(Constants.SecondaryIdFormat, viewModel.FeedItem.Id), actualTileInfo.TileId, "Tile Info Tile Id"); Assert.AreEqual(viewModel.FeedItem.Title, actualTileInfo.DisplayName, "Tile Info Display Name"); Assert.AreEqual(viewModel.FeedItem.Title, actualTileInfo.ShortName, "Tile Info Short Name"); Assert.AreEqual(viewModel.FeedItem.Id.ToString(), actualTileInfo.Arguments, "Tile Info Arguments"); Assert.IsFalse(wasSaveCalled, "Was Save Called"); }
編寫可測試的應用程序有一定的挑戰性。測試涉及用戶交互的表示層挑戰性更大。事先知道將要編寫 可測試的應用程序,您就可以在每一步中做出有利於實現可測試性的決策。您還可以注意不利於應用程 序測試的各種情況,並提出解決這些問題的方法。
在這三篇文章中,我討論了如何使用 MVVM 模式編寫可測試的應用程序,具體是針對 Windows 8 和 Windows Phone 8 的應用程序。在第一篇文章中,我介紹了如何編寫可測試的 Windows 8 應用程序,同 時仍利用不易於由這些應用程序本身測試的 Windows 8 特有功能。第二篇文章介紹了如何開發適用於 Windows 8 和 Windows Phone 8 的可測試、跨平台的應用程序。在這篇文章中,我介紹了如何著手測試 我事先通過努力保證可測試性的應用程序。
MVVM 是一個廣泛的主題,具有許多不同的解釋。我很高興能夠分享我對這樣一個令人感興趣的主題 的解釋。我在使用 MVVM 的過程中發現了很多有價值的功能,尤其是在可測試性方面。我還發現對可測 試性的探索很有啟發性並且有實際用途,我很高興分享編寫可測試應用程序的方法。
下載代碼示例