【WPF on .NET Core 3.0】 Stylet演示項目 - 簡易圖書管理系統(2) - 單元測試


上一章中我們完成了一個簡單的登錄功能, 這一章主要演示如何對Stylet工程中的ViewModel進行單元測試.

回憶一下我們的登錄邏輯,主要有以下4點:

  1. 當"用戶名"或"密碼"為空時, 是不允許登錄的("登錄"按鈕處於禁用狀態).
  2. 用戶名或密碼不正確時, 顯示"用戶名或密碼不正確"的消息框.
  3. 用戶名輸入"waku", 並且密碼輸入"123", 登錄成功窗口關閉, 回到主窗口.
  4. 點擊登錄窗口右上角的"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的類, 在其中編寫測試代碼.

  1. 配置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;
      
  2. 測試功能點: 當"用戶名"或"密碼"為空時, 是不允許登錄的("登錄"按鈕處於禁用狀態).

    先增加一個測試方法, 用來測試密碼未輸入時, 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的值

    測試"用戶名未輸入"和"用戶名和密碼都輸入"的代碼類似, 這里就不再詳細說明了, 可直接看代碼.

  3. 測試功能點: 用戶名或密碼不正確時, 顯示"用戶名或密碼不正確"的消息框.

    因為登錄邏輯中使用了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.
    • 調用了LoginViewModelLogin方法.
    • 使用Moq對象的Verify方法來驗證模擬方法被調用了. Times.Once代表只調用了一次, 如果未調用或調用次數不是一次, Veryify方法會拋出異常.

    還需要測試用戶名正確但是密碼不正確的情形, 就不詳細說明了.

  4. 測試功能點: 用戶名輸入"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方法, 將自身和窗口返回值做為參數傳遞進去.

    所以解決方案就出來了:

    1. 使用Moq來模擬一個IChildDelegate對象.
    2. Setup一個CloseItem(LoginViewModel, true)方法.
    3. 將測試對象LoginViewModel的Parent設置為該模擬對象.

    Mock相關的代碼如下, 與MockIWindowManager類似:

    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需要確保的事了:)

  5. 測試功能點: 點擊登錄窗口右上角的"X"按鈕,整個應用程序退出.

    首先我們回憶一下該功能的代碼是怎么寫的:

    protected override void OnViewLoaded()
    {
        var loginViewModel = _container.Get<LoginViewModel>();
        var result = _windowManager.ShowDialog(loginViewModel);
        if (result != true)
        {
            RequestClose();
        }
    }
    
    • 該功能是在ShellViewModelOnViewLoaded方法中實現的,所以這是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~


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM