上一章中我们完成了一个简单的登录功能, 这一章主要演示如何对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容器
因为我们的
使用了依赖注入,所以在测试代码中最好也是使用IoC来创建测试对象.在LoinViewModel
的构造方法中增加以下代码:LoginViewModelTest
public LoginViewModelTest() { // 向Stylet的IoC中注册服务 var builder = new StyletIoCBuilder(); builder.Bind<LoginViewModel>().ToSelf(); _container = builder.BuildContainer(); }
- Stylet的IoC容器需要使用
提供的API来创建, 所以首先我们创建了StyletIoCBuilder
的实例.StyletIoCBuilder
- 使用
范型方法注册服务, 这里我们将Bind<T>
LoginViewModel
的自身注册进去.
更多关于Stylet的IoC配置方法请浏览WIKI
- 最后使用
方法创建IoC容器, 由于我们需要在测试方法中使用该容器,所以需要定义一个成员变量来存储它:BuildContainer
private readonly IContainer _container;
- Stylet的IoC容器需要使用
-
测试功能点: 当"用户名"或"密码"为空时, 是不允许登录的("登录"按钮处于禁用状态).
先增加一个测试方法, 用来测试密码未输入时, 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要求所有测试方法需要有
-
测试功能点: 用户名或密码不正确时, 显示"用户名或密码不正确"的消息框.
因为登录逻辑中使用了
来显示消息框, 这里我们需要利用Moq来模拟它.在IWindowManager
构造方法中增加以下代码:LoginViewModelTest
public LoginViewModelTest() { // 使用Moq虚拟IWindowManager _mockWindowManager = new Mock<IWindowManager>(); _mockWindowManager.Setup(_showMessageBoxExpr).Returns(MessageBoxResult.OK); ... builder.Bind<IWindowManager>().ToInstance(_mockWindowManager.Object); // 注册IWindowManager ... }
-
来创建一个Mock对象,new Mock<T>
即是要Mock的实际类型. 后续我们需要使用Mock对象T
, 所以将其定义为一个成员变量:_mockWindowManager
private readonly Mock<IWindowManager> _mockWindowManager;
- 我们使用Moq的
方法来为指定的接口模拟一个方法, 该方法接收一个Expression类型的值. 为了简洁性, 我们将Expression定义为一个成员变量:Setup
可以看出, 该Expression的定义和我们在private readonly Expression<Func<IWindowManager, MessageBoxResult>> _showMessageBoxExpr = wm => wm.ShowMessageBox("用户名或密码不正确", "登录失败", MessageBoxButton.OK, MessageBoxImage.Exclamation, MessageBoxResult.None, MessageBoxResult.None, null, null, null);
方法中调用的形式是一致的.Login
Moq的Expression不允许使用可选参数, 所以这里我们将
的全部参数都明确写出来.ShowMessageBox
关于Moq的详细说明可浏览这里.
- 将模拟的
注册进IoC容器中, 这里使用了IWindowManager
来进行实例注册. 通过Mock对象的ToInstance
属性可以取得模拟对象.Object
/// <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)
- 将测试对象
的Parent设置为该模拟对象.LoginViewModel
类似: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)
我们只需要验证
被正确调用即可, 至于窗口是否能关闭那是Stylet需要确保的事了:)CloseItem
- 首先取得ViewModel的Parent, 这是一个实现了
-
测试功能点: 点击登录窗口右上角的"X"按钮,整个应用程序退出.
首先我们回忆一下该功能的代码是怎么写的:
protected override void OnViewLoaded() { var loginViewModel = _container.Get<LoginViewModel>(); var result = _windowManager.ShowDialog(loginViewModel); if (result != true) { RequestClose(); } }
- 该功能是在
ShellViewModel
方法中实现的,所以这是Shell中的功能, 所以我们需要创建一个新的测试类OnViewLoaded
, 来测试该功能.ShellViewModelTest
-
方法中同样也使用了OnViewLoaded
, 和IWindowManager
方法, 所以那些Moq的东西也少不了.RequestClose
是一个protected方法, 我们不能在测试代码中直接调用OnViewLoaded
ShellViewModel.OnViewLoaded
, 那么该怎么办呢? 我们的Act该怎么写呢?
这里介绍一个常用的技巧, 我们创建一个类继承
的类, 定义一个public方法, 并在该方法中调用ShellViewModel
. 因为该类是ShellViewModel.OnViewLoaded
的子类, 所以ShellViewModel
的protected方法也可在子类中调用.代码如下:ShellViewModel
至于其它的测试与Login中基本类似, 详细的请看代码./// <summary> /// 为了测试ShellViewModel.OnViewLoaded方法而创建的类 /// </summary> public class ShellViewModelForTest : ShellViewModel { public ShellViewModelForTest(IContainer container, IWindowManager windowManager) : base(container, windowManager) { } public void LoadView() { base.OnViewLoaded(); } }
- 该功能是在
至此, 我们的测试代码就写完了. 可以看出使用MVVM模式, 对于界面逻辑的测试是很简单的. 这也是MVVM备受推崇的原因.
本篇到此为止, 希望朋友们能多多留言. 源码托管在GITHUB上.
Happy Coding~