天天看点

【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~

继续阅读