Catel - 第 n 部分:使用 Catel 进行单元测试






4.55/5 (10投票s)
本文介绍如何使用 Catel 编写 MVVM 的单元测试。
Catel 是一个全新的框架(或企业库,您随意称呼它),它提供了数据处理、诊断、日志记录、WPF 控件和 MVVM 框架。因此,Catel 不仅仅是另一个 MVVM 框架或一些可以使用的便捷扩展方法。它更像是一个您希望在未来不久您将开发的(WPF)应用程序中包含的库。
本文介绍如何使用 Catel 编写 MVVM 的单元测试。
文章浏览器
- Catel - 第 n 部分 1:数据处理之道
- Catel - 第 n 部分 2:使用 WPF 控件和主题
- Catel - 第 3 部分 (共 n 部分):MVVM 框架
- Catel - 第 n 部分 4:使用 Catel 进行单元测试
- Catel - 第 5 部分 (共 n 部分):在 1 小时内构建一个 Catel WPF 示例应用程序
- Catel - 第 6 部分(
共 n 部分): WP7 的 Bing Maps 应用程序 - Catel - 第 n 部分 7:Catel 2.x 有什么新特性
- Catel - 第 n 部分 8:WP7 Mango 和相机单元测试
目录
1. 引言
欢迎阅读 Catel 系列文章的第 4 部分。本文介绍如何使用 Catel 编写 MVVM 的单元测试。
如果您尚未阅读 Catel 的前几篇文章,建议您阅读。它们已编号,因此查找它们应该不难。
我必须承认,本文的某些部分极大地受到 Sacha Barber 关于 Cinch 单元测试文章的启发,正是这篇文章促使我写了这篇关于 Catel 单元测试的文章。
如果您想知道为什么需要编写单元测试,您也应该想知道为什么您还没有住在笼子里,在墙上乱写乱画。编写单元测试可以确保您在进行更改时不会破坏应用程序中已有的功能。这降低了 QA 的成本(因为您不需要技术测试人员一直执行回归测试)。我并不是说不需要测试人员;我的观点是,至少除了开发人员之外,其他人应该在软件发布之前对软件进行人工审查。如果您仍然不相信为什么应该编写单元测试,请回到您的洞穴,为了您自己的乐趣,停止阅读本文。
在本文中,您将注意到编写视图模型单元测试是多么容易。这甚至可能很有趣,尽管我怀疑有很多开发人员觉得编写单元测试很有趣。在本文的结尾,我们将为“真实世界”应用程序编写单元测试,这样本文就不会过于抽象。
本文不涵盖单元测试的基础知识。它假设您已经知道单元测试是什么以及如何编写它们。本文专门介绍如何对 MVVM 模式的视图模型进行单元测试,特别是使用 Catel。
2. 测试命令
得益于命令(它们实现了 ICommand
接口),测试视图模型和 UI 逻辑从未如此简单。现在可以以编程方式调用命令,而无需自动化 UI;在单元测试中重现按钮单击非常简单。
测试命令时,测试状态也非常重要。Catel 的命令实现具有 CanExecute
和 Execute
方法,可以手动调用。因此,测试命令非常容易。下面的代码展示了一个检查 Remove
命令是否可以执行的测试。首先,由于没有选定人员,命令无法执行。设置选定人员后,命令应该可以执行。
Assert.IsFalse(mainWindowViewModel.Remove.CanExecute(null));
mainWindowViewModel.SelectedPerson = mainWindowViewModel.PersonCollection[0];
Assert.IsTrue(mainWindowViewModel.Remove.CanExecute(null));
要在代码中执行命令,应使用以下代码
mainWindowViewModel.Remove.Execute(null);
3. 测试服务
Catel 使用几个暴露给 ViewModelBase
类的服务。这些服务按接口注册,因此每个视图模型都可以不同。本章介绍如何在单元测试中使用这些服务。
默认情况下,ViewModelBase
会注册正确的服务实现(WPF 或 Silverlight 特定的实现)。然而,在单元测试中,您不会在那里点击消息框中的确认按钮。因此,Catel 还为所有服务提供了单元测试实现。
3.1. IoC 容器
Catel 使用 Unity 来实现 IoC 容器。IoC 容器使得可以在运行时通过配置更改依赖关系,而无需实际更改代码甚至重新编译。要为单元测试项目启用单元测试实现,我们必须创建一个 App.config 文件(如果尚不存在),并使用以下内容。请注意,如果您已有配置文件,请不要完全覆盖配置文件,而是合并它们。
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
<configSections>
<section name="unity"
type="Microsoft.Practices.Unity.Configuration.UnityConfigurationSection,
Microsoft.Practices.Unity.Configuration" />
</configSections>
<unity>
<containers>
<container>
<types>
<type type="Catel.MVVM.Services.IMessageService, Catel.Core"
mapTo="Catel.MVVM.Services.Test.MessageService, Catel.Windows"/>
<type type="Catel.MVVM.Services.IOpenFileService, Catel.Core"
mapTo="Catel.MVVM.Services.Test.OpenFileService, Catel.Windows"/>
<type type="Catel.MVVM.Services.IPleaseWaitService, Catel.Core"
mapTo="Catel.MVVM.Services.Test.PleaseWaitService, Catel.Windows"/>
<type type="Catel.MVVM.Services.ISaveFileService, Catel.Core"
mapTo="Catel.MVVM.Services.Test.SaveFileService, Catel.Windows"/>
<type type="Catel.MVVM.Services.IUIVisualizerService, Catel.Core"
mapTo="Catel.MVVM.Services.Test.UIVisualizerService, Catel.Windows"/>
</types>
</container>
</containers>
</unity>
</configuration>
上面的代码将所有已知接口映射到服务的测试实现。Unity 将确保正确的服务被注入到视图模型中。一旦在测试项目中创建了视图模型实例(不要忘记初始化它),您就可以像这样检索服务的测试实现。
var mainWindowViewModel = new MainWindowViewModel();
((IViewModel)mainWindowViewModel).Initialize();
var messageService =
(MVVM.Services.Test.MessageService)
mainWindowViewModel.GetService<IMessageService>();
3.2. IMessageService
此服务在测试实现中几乎是空的,因为大多数消息框只包含 OK 按钮,在单元测试中可以忽略。唯一实际返回值的 `方法是那些具有返回值的 `方法。
该实现添加了一个新的属性 ExpectedResults
。这样,单元测试就可以设置一个预期结果,以便服务知道下次调用时返回什么。如果没有可用的队列,将会抛出异常,因为单元测试没有正确编写。以下是该服务实现的最重要部分:
public MessageResult Show(string message, string caption,
MessageButton button, MessageImage icon)
{
if (ExpectedResults.Count == 0)
{
throw new Exception(Exceptions.NoExpectedResultsInQueueForUnitTest);
}
return ExpectedResults.Dequeue();
}
现在我们已经讨论了服务的实现,让我们看看如何使用它。
Test.MessageService service =
(Test.MessageService)GetService<IMessageService>();
// Queue the next expected result
service.ExpectedResults.Add(MessageResult.Yes);
3.3. IOpenFileService 和 ISaveFileService
IOpenFileService
和 ISaveFileService
的实现相同;因此,它们在同一段落中处理。
该实现添加了一个新的属性 ExpectedResults
。这样,单元测试就可以设置一个预期结果,以便服务知道下次调用时返回什么。如果没有可用的队列,将会抛出异常,因为单元测试没有正确编写。以下是该服务实现的最重要部分:
public bool DetermineFile()
{
if (ExpectedResults.Count == 0)
{
throw new Exception(Exceptions.NoExpectedResultsInQueueForUnitTest);
}
return ExpectedResults.Dequeue().Invoke();
}
现在我们已经讨论了服务的实现,让我们看看如何使用它。
Test.OpenFileService service =
(Test.OpenFileService)GetService<IOpenFileService>();
// Queue the next expected result
service.ExpectedResults.Add(() =>
{
service.FileName = @"c:\test.txt";
return true;
});
3.4. IPleaseWaitService
这个实现非常简单,在单元测试中根本不需要自动化。只有一个 `方法实现了以支持单元测试。代码如下:
public void Show(PleaseWaitWorkDelegate workDelegate, string status)
{
// Invoke work delegate
workDelegate();
}
该服务本身执行工作委托,然后返回,而不是显示 PleaseWaitWindow
并让窗口处理工作委托。
3.5. IProcessService
该实现添加了一个新的属性 ExpectedResults
。这样,单元测试就可以设置一个预期结果,以便服务知道下次调用时返回什么。如果没有可用的队列,将会抛出异常,因为单元测试没有正确编写。
但是,与其他服务不同,此服务仅在进程成功启动时才返回结果代码并调用回调。因此,需要一个中间类 ProcessServiceTestResult
,它定义在下面。
/// <summary>
/// Class representing the process result.
/// </summary>
public class ProcessServiceTestResult
{
#region Constructor & destructor
/// <summary>
/// Initializes a new instance of the
/// <see cref="ProcessServiceTestResult"/> class,
/// with <c>0</c> as default process result code.
/// </summary>
/// <param name="result">if set to <c>true</c>,
/// the process will succeed during the test.</param>
public ProcessServiceTestResult(bool result)
: this(result, 0) { }
/// <summary>
/// Initializes a new instance of the
/// <see cref="ProcessServiceTestResult"/> class.
/// </summary>
/// <param name="result">if set to <c>true</c>,
/// the process will succeed during the test.</param>
/// <param name="processResultCode">The process result
/// code to return in case of a callback.</param>
public ProcessServiceTestResult(bool result, int processResultCode)
{
Result = result;
ProcessResultCode = processResultCode;
}
#endregion
#region Properties
/// <summary>
/// Gets or sets a value indicating whether the process
/// should be returned as successfull when running the process.
/// </summary>
/// <value><c>true</c> if the process should be returned
/// as successfull; otherwise, <c>false</c>.</value>
public bool Result { get; private set; }
/// <summary>
/// Gets or sets the process result code.
/// </summary>
/// <value>The process result code.</value>
public int ProcessResultCode { get; private set; }
#endregion
}
下面显示了服务实现的最重要部分。
public void StartProcess(string fileName, string arguments,
ProcessCompletedDelegate processCompletedCallback)
{
if (string.IsNullOrEmpty(fileName))
{
throw new ArgumentException(
Exceptions.ArgumentCannotBeNullOrEmpty, "fileName");
}
if (ExpectedResults.Count == 0)
{
throw new Exception(Exceptions.NoExpectedResultsInQueueForUnitTest);
}
var result = ExpectedResults.Dequeue();
if (result.Result)
{
if (processCompletedCallback != null)
{
processCompletedCallback(result.ProcessResultCode);
}
}
}
现在我们已经讨论了服务的实现,让我们看看如何使用它。
Test.ProcessService service = (Test.ProcessService)GetService<IProcessService>();
// Queue the next expected result (next StartProcess will
// succeed to run app, 5 will be returned as exit code)
service.ExpectedResults.Add(new ProcessServiceTestResult(true, 5));
3.6. IUIVisualizerService
IUIVisualizerService
是所有中最复杂的,如果您甚至可以称之为复杂的话。原因是,对于显示为对话框或常规窗口的窗口,有一个单独的预期结果队列。以下代码显示了 Show
的实现方式,但 ShowDialog
的实现方式相同。
public bool Show(string name, object data,
EventHandler<UICompletedEventArgs> completedProc)
{
if (ExpectedShowResults.Count == 0)
{
throw new Exception(Exceptions.NoExpectedResultsInQueueForUnitTest);
}
return ExpectedShowResults.Dequeue().Invoke();
}
现在我们已经讨论了服务的实现,让我们看看如何使用它。
Test.UIVisualizerService service =
(Test.UIVisualizerService)GetService<IUIVisualizerService>();
// Queue the next expected result
service.ExpectedShowResults.Add(() =>
{
// If required, handle custom data manipulation here
return true;
});
4. 示例项目
现在您已经了解了进行 Catel 单元测试所需的所有知识,是时候将其付诸实践了。作为示例,我们创建了一个人员应用程序。它是一个非常简单的应用程序,除了在列表中添加、修改和删除人员之外,什么都不做。但是,尽管应用程序看起来很简单,但它包含了使用 MVVM 时需要测试的大部分方面。
在 Catel 的解决方案中,我们创建了两个新项目,名称都以 Catel.Articles.04 - Unit testing 开头。第一个是为此示例编写的实际应用程序。第二个是包含单元测试的测试项目。
4.1. 功能
该应用程序相当简单,应该不言自明。但是,这里有一些截图可以让你对应用程序的功能有一个大致的了解。
应用程序的主窗口由一个项目控件组成,所有人员都列在彼此下方。右侧是操作按钮,由于有图片,这些按钮应该很容易理解。
请注意,可以通过双击人员项目来编辑他们。这是由于 Catel 的 EventToCommand
触发器实现的。
当单击 Add 或 Edit 按钮时,详细信息窗口将弹出,其中包含一些字段用于编辑值。
正如前面所说,该应用程序非常简单,没有任何实用功能,但这实际上是为了尽可能保持简单。您可能会认出这些场景,并将它们映射回您自己应用程序中的问题。
4.2. 设置 IoC
在单元测试项目中,我们不希望使用与实际情况相同的服务。例如,为什么有人想在单元测试期间看到消息框?因此,Catel 提供了 Catel 提供的所有视图模型服务的单元测试实现。服务的实际行为已在本文前面讨论过,因此我们将使用它们,而不是解释它们。
为了确保单元测试使用正确的服务,我们将通过单元测试项目的应用程序配置来配置它。
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
<configSections>
<section name="unity"
type="Microsoft.Practices.Unity.Configuration.UnityConfigurationSection,
Microsoft.Practices.Unity.Configuration" />
</configSections>
<unity>
<containers>
<container>
<types>
<type type="Catel.MVVM.Services.IMessageService, Catel.Core"
mapTo="Catel.MVVM.Services.Test.MessageService, Catel.Windows"/>
<type type="Catel.MVVM.Services.IOpenFileService, Catel.Core"
mapTo="Catel.MVVM.Services.Test.OpenFileService, Catel.Windows"/>
<type type="Catel.MVVM.Services.IPleaseWaitService, Catel.Core"
mapTo="Catel.MVVM.Services.Test.PleaseWaitService, Catel.Windows"/>
<type type="Catel.MVVM.Services.ISaveFileService, Catel.Core"
mapTo="Catel.MVVM.Services.Test.SaveFileService, Catel.Windows"/>
<type type="Catel.MVVM.Services.IUIVisualizerService, Catel.Core"
mapTo="Catel.MVVM.Services.Test.UIVisualizerService, Catel.Windows"/>
</types>
</container>
</containers>
</unity>
</configuration>
正如您所见,我们将接口映射到服务的测试实现。Unity 框架将确保在单元测试期间使用正确的服务实例。
4.3. 测试模型
让我们从简单的开始。我们只有一个模型,并想对其进行一些测试,例如验证。我们还有一个名为 FullName
的自动属性,它只是所有已知名称(名、中间名和姓)的串联。
首先,让我们测试模型的验证。
[TestMethod]
public void Validation()
{
Person person = new Person();
IDataErrorInfo personAsErrorInfo = (IDataErrorInfo) person;
Assert.IsTrue(person.HasErrors);
Assert.IsFalse(string.IsNullOrEmpty(personAsErrorInfo[person.GenderProperty.Name]));
Assert.IsFalse(string.IsNullOrEmpty(personAsErrorInfo[person.FirstNameProperty.Name]));
Assert.IsTrue(string.IsNullOrEmpty(personAsErrorInfo[person.MiddleNameProperty.Name]));
Assert.IsFalse(string.IsNullOrEmpty(personAsErrorInfo[person.LastNameProperty.Name]));
person.Gender = Gender.Male;
person.FirstName = "John";
person.LastName = "Doe";
Assert.IsFalse(person.HasErrors);
Assert.IsTrue(string.IsNullOrEmpty(personAsErrorInfo[person.GenderProperty.Name]));
Assert.IsTrue(string.IsNullOrEmpty(personAsErrorInfo[person.FirstNameProperty.Name]));
Assert.IsTrue(string.IsNullOrEmpty(personAsErrorInfo[person.MiddleNameProperty.Name]));
Assert.IsTrue(string.IsNullOrEmpty(personAsErrorInfo[person.LastNameProperty.Name]));
}
上面的代码首先测试当创建新实例时,属性的必需验证是否正确。然后,它更改必需的值并检查错误是否消失。
我们还需要测试 FullName
属性。
[TestMethod]
public void FullName()
{
Person person = new Person();
Assert.IsTrue(string.IsNullOrEmpty(person.FullName));
person.FirstName = "Geert";
Assert.AreEqual("Geert", person.FullName);
person.MiddleName = "van";
Assert.AreEqual("Geert van", person.FullName);
person.LastName = "Horrik";
Assert.AreEqual("Geert van Horrik", person.FullName);
person.MiddleName = string.Empty;
Assert.AreEqual("Geert Horrik", person.FullName);
}
4.4. 测试视图模型
现在我们知道我们的模型没问题(这非常重要,源数据必须正确),我们可以继续编写视图模型的单元测试。在本文中,我将只介绍 MainWindowViewModel
类。PersonViewModel
类非常简单,您自己练习一下就完成了。
我们首先测试视图模型的初始化。在初始化期间,我们期望视图模型初始化一个人,即我。以下是单元测试:
[TestMethod]
public void Initialization()
{
var mainWindowViewModel = new MainWindowViewModel();
((IViewModel)mainWindowViewModel).Initialize();
Assert.AreEqual(1, mainWindowViewModel.PersonCollection.Count);
Assert.AreEqual("Geert van Horrik",
mainWindowViewModel.PersonCollection[0].FullName);
}
初始化似乎工作得很完美。让我们关注命令。首先,我们测试 Add 命令。我们需要测试两种情况,因为用户可以在我们的模态对话框中选择 OK 或 Cancel。如果用户单击 Cancel,我们不希望添加新人员。
[TestMethod]
public void AddPerson_Confirmed()
{
var mainWindowViewModel = new MainWindowViewModel();
((IViewModel)mainWindowViewModel).Initialize();
Assert.AreEqual(1, mainWindowViewModel.PersonCollection.Count);
Assert.IsTrue(mainWindowViewModel.Add.CanExecute(null));
var uiVisualizerService =
(MVVM.Services.Test.UIVisualizerService)
mainWindowViewModel.GetService<IUIVisualizerService>();
uiVisualizerService.ExpectedShowDialogResults.Enqueue(() => true);
mainWindowViewModel.Add.Execute(null);
Assert.AreEqual(2, mainWindowViewModel.PersonCollection.Count);
}
[TestMethod]
public void AddPerson_Canceled()
{
var mainWindowViewModel = new MainWindowViewModel();
((IViewModel)mainWindowViewModel).Initialize();
Assert.AreEqual(1, mainWindowViewModel.PersonCollection.Count);
Assert.IsTrue(mainWindowViewModel.Add.CanExecute(null));
var uiVisualizerService = (MVVM.Services.Test.UIVisualizerService)
mainWindowViewModel.GetService<IUIVisualizerService>();
uiVisualizerService.ExpectedShowDialogResults.Enqueue(() => false);
mainWindowViewModel.Add.Execute(null);
Assert.AreEqual(1, mainWindowViewModel.PersonCollection.Count);
}
接下来是 Edit 命令。Edit 命令也有两种情况需要测试。以下是单元测试:
[TestMethod]
public void EditPerson_Confirmed()
{
var mainWindowViewModel = new MainWindowViewModel();
((IViewModel)mainWindowViewModel).Initialize();
Assert.AreEqual(1, mainWindowViewModel.PersonCollection.Count);
Assert.IsFalse(mainWindowViewModel.Edit.CanExecute(null));
mainWindowViewModel.SelectedPerson = mainWindowViewModel.PersonCollection[0];
Assert.IsTrue(mainWindowViewModel.Edit.CanExecute(null));
var uiVisualizerService =
(MVVM.Services.Test.UIVisualizerService)
mainWindowViewModel.GetService<IUIVisualizerService>();
uiVisualizerService.ExpectedShowDialogResults.Enqueue(() =>
{
mainWindowViewModel.PersonCollection[0].FirstName = "New name";
return true;
});
mainWindowViewModel.Edit.Execute(null);
Assert.AreEqual(1, mainWindowViewModel.PersonCollection.Count);
Assert.AreEqual("New name", mainWindowViewModel.PersonCollection[0].FirstName);
}
[TestMethod]
public void EditPerson_Canceled()
{
var mainWindowViewModel = new MainWindowViewModel();
((IViewModel)mainWindowViewModel).Initialize();
Assert.AreEqual(1, mainWindowViewModel.PersonCollection.Count);
Assert.IsFalse(mainWindowViewModel.Edit.CanExecute(null));
mainWindowViewModel.SelectedPerson = mainWindowViewModel.PersonCollection[0];
Assert.IsTrue(mainWindowViewModel.Edit.CanExecute(null));
var uiVisualizerService =
(MVVM.Services.Test.UIVisualizerService)
mainWindowViewModel.GetService<IUIVisualizerService>();
uiVisualizerService.ExpectedShowDialogResults.Enqueue(() => false);
mainWindowViewModel.Edit.Execute(null);
Assert.AreEqual(1, mainWindowViewModel.PersonCollection.Count);
}
到目前为止,编写单元测试一直很轻松,对吧?我们只需要实例化和初始化一个视图模型,然后设置服务的预期结果并检查结果。Remove 命令也一样简单,但现在展示了如何使用 IMessageService
测试实现。
[TestMethod]
public void RemovePerson_ConfirmWithYes()
{
var mainWindowViewModel = new MainWindowViewModel();
((IViewModel)mainWindowViewModel).Initialize();
Assert.AreEqual(1, mainWindowViewModel.PersonCollection.Count);
Assert.IsFalse(mainWindowViewModel.Remove.CanExecute(null));
mainWindowViewModel.SelectedPerson = mainWindowViewModel.PersonCollection[0];
Assert.IsTrue(mainWindowViewModel.Remove.CanExecute(null));
var messageService = (MVVM.Services.Test.MessageService)
mainWindowViewModel.GetService<IMessageService>();
messageService.ExpectedResults.Enqueue(MessageResult.Yes);
mainWindowViewModel.Remove.Execute(null);
Assert.AreEqual(0, mainWindowViewModel.PersonCollection.Count);
}
[TestMethod]
public void RemovePerson_ConfirmWithNo()
{
var mainWindowViewModel = new MainWindowViewModel();
((IViewModel)mainWindowViewModel).Initialize();
Assert.AreEqual(1, mainWindowViewModel.PersonCollection.Count);
Assert.IsFalse(mainWindowViewModel.Remove.CanExecute(null));
mainWindowViewModel.SelectedPerson = mainWindowViewModel.PersonCollection[0];
Assert.IsTrue(mainWindowViewModel.Remove.CanExecute(null));
var messageService = (MVVM.Services.Test.MessageService)
mainWindowViewModel.GetService<IMessageService>();
messageService.ExpectedResults.Enqueue(MessageResult.No);
mainWindowViewModel.Remove.Execute(null);
Assert.AreEqual(1, mainWindowViewModel.PersonCollection.Count);
}
5. 结论
本文首先解释了 Catel 的单元测试功能以及如何使用它们。最后,我们编写了一个完整的示例应用程序,包括模型和视图模型的单元测试。我希望本文能向您展示编写单元测试有多么容易,并且您绝对应该开始为您的软件编写测试。
它还展示了 MVVM 模式的一个非常强大的优点。如果您不遵循创建松耦合系统的模式,您很快就会发现为软件和 UI 逻辑编写单元测试非常困难,如果不是不可能的话。
6. 历史记录
- 2011 年 1 月 22 日:添加了
IProcessService
的说明 - 2010 年 11 月 25 日:添加了文章浏览器和简短的介绍性摘要
- 2010 年 11 月 23 日:进行了一些小的文本更改
- 2010 年 11 月 22 日:初始版本