天天看點

【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)工程:

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

然後對測試工程進行以下操作:

  • 添加對

    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

      .
    • 調用了

      LoginViewModel

      Login

      方法.
    • 使用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相關的代碼如下, 與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需要確定的事了:)
  5. 測試功能點: 點選登入視窗右上角的"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~

繼續閱讀