上一章中我們完成了一個簡單的登錄功能, 這一章主要演示如何對Stylet工程中的ViewModel進行單元測試.
回憶一下我們的登錄邏輯,主要有以下4點:
- 當"用戶名"或"密碼"為空時, 是不允許登錄的("登錄"按鈕處於禁用狀態).
- 用戶名或密碼不正確時, 顯示"用戶名或密碼不正確"的消息框.
- 用戶名輸入"waku", 並且密碼輸入"123", 登錄成功窗口關閉, 回到主窗口.
- 點擊登錄窗口右上角的"X"按鈕,整個應用程序退出.
那么我們就嘗試編寫代碼來進行測試吧.
這里我們只測試ViewModel中的邏輯是否正確,對於UI測試則是另一個話題了,以后有機會再寫.
創建測試工程
VS2019支持三種測試框架: MSTest, Nunit和xUnit, 功能上差不多, 你可以選擇一個你喜歡的. 這里我們使用xUnit.
新建一個名為StyletBookStore.Test
的xUnit Test Project(.NET Core)工程:
然后對測試工程進行以下操作:
-
添加對
StyletBookStore
工程的引用, 這是我們測試的對象 -
添加Moq包,我們使用Moq模擬一些Stylet的組件
Install-Package Moq -Version 4.13.1
-
添加Shouldly包,方便我們寫Assert代碼
Install-Package Shouldly -Version 3.0.2
在StyletBookStore.Test
工程中新建一個名為LoginViewModelTest
的類, 在其中編寫測試代碼.
-
配置Stylet的IoC容器
因為我們的
LoinViewModel
使用了依賴注入,所以在測試代碼中最好也是使用IoC來創建測試對象.在LoginViewModelTest
的構造方法中增加以下代碼:public LoginViewModelTest() { // 向Stylet的IoC中注冊服務 var builder = new StyletIoCBuilder(); builder.Bind<LoginViewModel>().ToSelf(); _container = builder.BuildContainer(); }
-
Stylet的IoC容器需要使用
StyletIoCBuilder
提供的API來創建, 所以首先我們創建了StyletIoCBuilder
的實例. -
使用
Bind<T>
范型方法注冊服務, 這里我們將LoginViewModel
的自身注冊進去.更多關於Stylet的IoC配置方法請瀏覽WIKI
-
最后使用
BuildContainer
方法創建IoC容器, 由於我們需要在測試方法中使用該容器,所以需要定義一個成員變量來存儲它:private readonly IContainer _container;
-
-
測試功能點: 當"用戶名"或"密碼"為空時, 是不允許登錄的("登錄"按鈕處於禁用狀態).
先增加一個測試方法, 用來測試密碼未輸入時, CanLogin應該返回false:
/// <summary> /// 密碼未輸入, 不允許點擊登錄 /// </summary> [Fact] public void CanLoginTest_NoPassword() { // Arrange var vm = _container.Get<LoginViewModel>(); vm.UserName = "waku"; vm.Password = String.Empty; // Act bool canLogin = vm.CanLogin; // Assert canLogin.ShouldBe(false); }
- xUnit要求所有測試方法需要有
[Fact]
屬性. - 我們在測試方法中遵循AAA模式, 即Arrange, Act和Assert:
- Arrange: 設置測試對象並准備測試的先決條件
- Act: 執行測試的實際工作
- Assert: 驗證結果
- 使用Stylet的IoC容器取得
LoginViewModel
實例 - 因為用戶名和密碼都是公有屬性, 所以我們直接通過代碼來修改它們.
- 使用Shouldly提供的擴展方法
ShouldBe
來驗證canLogin
的值
測試"用戶名未輸入"和"用戶名和密碼都輸入"的代碼類似, 這里就不再詳細說明了, 可直接看代碼.
- xUnit要求所有測試方法需要有
-
測試功能點: 用戶名或密碼不正確時, 顯示"用戶名或密碼不正確"的消息框.
因為登錄邏輯中使用了
IWindowManager
來顯示消息框, 這里我們需要利用Moq來模擬它.在LoginViewModelTest
構造方法中增加以下代碼:public LoginViewModelTest() { // 使用Moq虛擬IWindowManager _mockWindowManager = new Mock<IWindowManager>(); _mockWindowManager.Setup(_showMessageBoxExpr).Returns(MessageBoxResult.OK); ... builder.Bind<IWindowManager>().ToInstance(_mockWindowManager.Object); // 注冊IWindowManager ... }
-
使用
new Mock<T>
來創建一個Mock對象,T
即是要Mock的實際類型. 后續我們需要使用Mock對象_mockWindowManager
, 所以將其定義為一個成員變量:private readonly Mock<IWindowManager> _mockWindowManager;
-
我們使用Moq的
Setup
方法來為指定的接口模擬一個方法, 該方法接收一個Expression類型的值. 為了簡潔性, 我們將Expression定義為一個成員變量:private readonly Expression<Func<IWindowManager, MessageBoxResult>> _showMessageBoxExpr = wm => wm.ShowMessageBox("用戶名或密碼不正確", "登錄失敗", MessageBoxButton.OK, MessageBoxImage.Exclamation, MessageBoxResult.None, MessageBoxResult.None, null, null, null);
可以看出, 該Expression的定義和我們在
Login
方法中調用的形式是一致的.Moq的Expression不允許使用可選參數, 所以這里我們將
ShowMessageBox
的全部參數都明確寫出來.關於Moq的詳細說明可瀏覽這里.
-
將模擬的
IWindowManager
注冊進IoC容器中, 這里使用了ToInstance
來進行實例注冊. 通過Mock對象的Object
屬性可以取得模擬對象.
有了Mock對象, 我們就可以來編寫驗證登錄邏輯的測試代碼了:
/// <summary> /// 用戶名錯誤 /// </summary> [Fact] public void LoginTest_WrongUserName() { // Arrange var vm = _container.Get<LoginViewModel>(); vm.UserName = "wrong_username"; vm.Password = "123"; // Act vm.Login(); // Assert _mockWindowManager.Verify(_showMessageBoxExpr, Times.Once); // 應該顯示消息框 }
- 我們設置了一個錯誤的用戶名
wrong_username
. - 調用了
LoginViewModel
的Login
方法. - 使用Moq對象的
Verify
方法來驗證模擬方法被調用了.Times.Once
代表只調用了一次, 如果未調用或調用次數不是一次,Veryify
方法會拋出異常.
還需要測試用戶名正確但是密碼不正確的情形, 就不詳細說明了.
-
-
測試功能點: 用戶名輸入"waku", 並且密碼輸入"123", 點擊"登錄"按鈕, 登錄窗口關閉, 回到主窗口.
在
Login
方法中, 當驗證用戶名和密碼成功后, 我們使用了RequestClose(true)
來請求關閉窗口. 我們怎么來測試窗口關閉呢?先看一下Stylet的
RequestClose
是如何實現的:/// <summary> /// Request that the conductor responsible for this screen close it /// </summary> /// <param name="dialogResult">DialogResult to return, if this is a dialog</param> public virtual void RequestClose(bool? dialogResult = null) { var conductor = this.Parent as IChildDelegate; if (conductor != null) { this.logger.Info("RequstClose called. Conductor: {0}; DialogResult: {1}", conductor, dialogResult); conductor.CloseItem(this, dialogResult); } else { var e = new InvalidOperationException(String.Format("Unable to close ViewModel {0} as it must have a conductor as a parent (note that windows and dialogs automatically have such a parent)", this.GetType())); this.logger.Error(e); throw e; } }
- 首先取得ViewModel的Parent, 這是一個實現了
IChildDelegate
的對象. 如未取到, 直接拋出異常. - 否則調用
IChildDelegate.CloseItem
方法, 將自身和窗口返回值做為參數傳遞進去.
所以解決方案就出來了:
- 使用Moq來模擬一個
IChildDelegate
對象. Setup
一個CloseItem(LoginViewModel, true)
方法.- 將測試對象
LoginViewModel
的Parent設置為該模擬對象.
Mock相關的代碼如下, 與Mock
IWindowManager
類似:public class LoginViewModelTest { ... private readonly Mock<IWindowManager> _mockWindowManager; ... public LoginViewModelTest() { ... // 使用Moq虛擬IChildDelegate _mockChildDelegate = new Mock<IChildDelegate>(); ... builder.Bind<IChildDelegate>().ToInstance(_mockChildDelegate.Object); // 注冊IChildDelegate ... }
測試方法:
/// <summary> /// 正確的用戶名和密碼 /// </summary> [Fact] public void LoginTest() { // Arrange var vm = _container.Get<LoginViewModel>(); var childDelegate = _container.Get<IChildDelegate>(); vm.UserName = "waku"; vm.Password = "123"; vm.Parent = childDelegate; // Act vm.Login(); // Assert _mockWindowManager.Verify(_showMessageBoxExpr, Times.Never); // 不應該顯示消息框 _mockChildDelegate.Verify(cd => cd.CloseItem(vm, true), Times.Once); // 應該關閉窗口,並返回true }
- 使用
Times.Never
指定模擬的方法不應該被調用.(登錄驗證成功, 不顯示消息框) - 驗證
CloseItem(LoginViewModel, true)
被調用了一次.
我們只需要驗證
CloseItem
被正確調用即可, 至於窗口是否能關閉那是Stylet需要確保的事了:) - 首先取得ViewModel的Parent, 這是一個實現了
-
測試功能點: 點擊登錄窗口右上角的"X"按鈕,整個應用程序退出.
首先我們回憶一下該功能的代碼是怎么寫的:
protected override void OnViewLoaded() { var loginViewModel = _container.Get<LoginViewModel>(); var result = _windowManager.ShowDialog(loginViewModel); if (result != true) { RequestClose(); } }
- 該功能是在
ShellViewModel
的OnViewLoaded
方法中實現的,所以這是Shell中的功能, 所以我們需要創建一個新的測試類ShellViewModelTest
, 來測試該功能. OnViewLoaded
方法中同樣也使用了IWindowManager
, 和RequestClose
方法, 所以那些Moq的東西也少不了.
接下來還有一個問題, 不知道你有沒有注意到, 就是
OnViewLoaded
是一個protected方法, 我們不能在測試代碼中直接調用ShellViewModel.OnViewLoaded
, 那么該怎么辦呢? 我們的Act該怎么寫呢?這里介紹一個常用的技巧, 我們創建一個類繼承
ShellViewModel
的類, 定義一個public方法, 並在該方法中調用ShellViewModel.OnViewLoaded
. 因為該類是ShellViewModel
的子類, 所以ShellViewModel
的protected方法也可在子類中調用.代碼如下:/// <summary> /// 為了測試ShellViewModel.OnViewLoaded方法而創建的類 /// </summary> public class ShellViewModelForTest : ShellViewModel { public ShellViewModelForTest(IContainer container, IWindowManager windowManager) : base(container, windowManager) { } public void LoadView() { base.OnViewLoaded(); } }
至於其它的測試與Login中基本類似, 詳細的請看代碼.
- 該功能是在
至此, 我們的測試代碼就寫完了. 可以看出使用MVVM模式, 對於界面邏輯的測試是很簡單的. 這也是MVVM備受推崇的原因.
本篇到此為止, 希望朋友們能多多留言. 源碼托管在GITHUB上.
Happy Coding~