65.9K
CodeProject 正在变化。 阅读更多。
Home

使用 MVC 对 WPF 应用程序进行单元测试

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.98/5 (63投票s)

2008 年 1 月 27 日

CPOL

23分钟阅读

viewsIcon

376454

downloadIcon

2604

提供使用模型-视图-控制器 (MVC) 设计模式创建模块化且易于单元测试的 WPF 应用程序的指南

spaghetti.jpg

您是否好奇如何创建模块化、可维护且易于进行单元测试的 WPF 应用程序?您是否想知道如何在下一个 WPF 项目中避免陷入“意大利面条地狱”?您是否认为碗是糟糕的帽子?请继续阅读……

目录

引言

在撰写本文时,Windows Presentation Foundation (WPF) 是一个相对较新的创建 Windows 桌面应用程序的平台。当开发人员需要将其现有的应用程序设计技能和最佳实践“移植”到 WPF 时,可供遵循的指导并不多。一个关键但很少被提及的主题是如何利用 WPF 的特性和功能来创建可进行单元测试的应用程序。本文通过介绍我在两年内开发和演进的几种真实 WPF 应用程序中开发和演进的技术,开始填补这一空白。

背景

这篇文章在我脑海中盘旋了很久,一直渴望着发表。最初,我只想专注于如何在 WPF 中实现 Model-View-Controller 模式,但最终我意识到网上已经有很多关于这个主题的优秀资料。本文介绍了如何在 WPF 中实现 MVC,但这仅仅是解释应用程序单元测试工作原理的先决条件。在本文的末尾,我介绍了 Model-View-ViewModel 模式,这是微软的一些非常聪明的人专门为 WPF 发明的。MVVM 在设计 WPF 应用程序时是 MVC 的强大而有用的替代方案。

常见设计问题

归根结底,无论 UI 平台如何,我们都希望我们的应用程序设计具有相同的基本要素。本节简要回顾了一些常见的关注点,仅为本文的其余部分提供上下文。本节中的任何内容都不是新的,也不是 WPF 应用程序设计特有的。

任何明智的软件架构师都重视模块化。模块化系统由具有明确相互关系的自包含功能单元组成。对一个模块的更改应该对其他密切相关的模块产生有限且可预测的连锁反应。接口或基类通常正式定义两个模块之间的关系。

实现模块化的一种好方法是创建分层架构。这意味着系统的各个逻辑方面被分解为逻辑层,例如数据访问层、领域/业务层、表示层等。每个层将某些关注点封装到可重用和通用的抽象中,其他层可以依赖于此抽象。例如,数据访问层可能是系统中唯一实际与数据库交互的地方。所有其他层都直接或间接调用数据访问层来执行数据检索和持久化。

在正确分层的架构中,可以在用户界面标记/代码与响应用户交互的应用程序逻辑之间实现松散耦合。应用程序逻辑与 UI 松散耦合具有以下几个优点。在编译时或运行时替换 UI 变得容易实现,从而允许视觉设计师轻松创建和合并应用程序的新视图。维护和学习应用程序代码变得更容易,因为应用程序逻辑与 UI 的具体构造方式是分离的。

将应用程序逻辑与用户界面松散耦合的另一个巨大优势是,创建单元测试变得简单,这些单元测试可以测试应用程序功能,而不会纠缠于 UI 特定的问题。这使得开发团队能够创建一套单元测试,这些测试对于自动化回归测试来说非常宝贵,从而使质量保证团队能够专注于更高级别的测试,而不仅仅是确保基本的单元级别功能仍然正常工作。

放下喷火器

如果你把十个软件架构师关在一个房间里,让他们讨论什么是 Model-View-Controller 模式,你最终会得到十二种不同的意见。在本文的下一节中,我将提供我对 MVC 的定义。一些纯粹主义者不可避免地会对我说到的“MVC”感到不满。欢迎在这网页底部的留言板上留下激烈的评论。我很乐意接受关于 MVC 含义的不同观点,但请记住我不在乎。我忙于工作,顾不上“哲学上理想”的 MVC 定义和实现。如果我必须遵循某种哲学,我同意 Dr. WPF 的观点,即理想模式是 M-V-poo

MVC 简介

Model-View-Controller 模式当然不是什么新鲜事。它已经存在了几十年。它有很多变体,也有许多文章、书籍章节和博客文章讨论过它。该模式最初的目的是在 UI 控件相当原始的时期,帮助智能地构建应用程序以处理低级用户输入。随着 UI 控件的开发人员友好性和丰富性随着时间的推移而提高,MVC 已经承担了更广泛的含义。

如今,MVC 通常指关注点分离,其中您有一个模型来表示问题领域的逻辑和数据,一个视图来在屏幕上显示模型,以及一个控制器来处理用户交互事件并在需要处理时通知模型。控制器关注用户交互;例如键盘、鼠标或手写笔输入;针对整个表单或用户控件……而不仅仅是一个按钮或下拉列表。

现在我已经重复了 MVC 的通用解释,让我们看看它在实践中真正意味着什么。前面我们看到了将应用程序逻辑与 UI 松散耦合的许多优点。我们没有回顾的是**如何**实现这种松散耦合。正如您现在可能已经猜到的那样,MVC 模式是实现此目的的绝佳方式。

松散耦合系统的精髓归结为一个(奇怪的)我创造的术语:**无引用性**。松散耦合的系统没有不必要的对象引用直接连接其主要功能组件。如果系统各部分之间存在直接引用,它们通常应表示为接口或抽象基类,以便进行依赖注入和对象模拟(稍后会详细介绍)。系统中唯一有意义的地方是在控制器引用模型时,以及在某些情况下控制器和视图之间存在直接引用。当我们检查演示应用程序如何使用 MVC 时,这些语句背后的原因将变得显而易见。

在 WPF 中实现 MVC

本节回顾了 WPF 的基本特性,我们可以使用这些特性来促进 WPF 应用程序的无引用性和模块化。在后续章节中,我们将利用这些工具,并通过一个真实的示例了解这些部分如何组合在一起,形成一个简单的基础设施,在此之上真实应用程序可以蓬勃发展。

在 WPF 应用程序中实现 MVC 模式涉及四个基本支柱。下面简要概述了每个支柱以及它们对无引用性和模块化这一总体目标所做的贡献。

路由命令

命令模式是表示和执行应用程序中操作的成熟方法。命令表示应用程序可以执行的动作。当应用程序的各个部分需要执行该动作时,它们会要求命令执行。通常,命令对象包含执行逻辑,它将整个操作封装到一个方便且可重用的对象中。

命令是 WPF 中的一等公民特性。实现 ICommand 接口即可将对象转换为命令。然而,WPF 提供了比这更强大、更复杂的命令方式。通过利用 WPF 内置的事件路由基础设施,我们还能够创建和使用“路由命令”。路由命令与普通命令的不同之处在于,它不包含自己的执行逻辑。相反,它只是表示应用程序可能能够执行的动作,并将实际执行逻辑委托给外部方。

当应用程序中的某个东西请求路由命令执行时,命令的 Executed 路由事件将沿着元素树冒泡。如果树中的某个元素为该命令的 Executed 事件建立了处理程序,则处理程序方法将执行。元素还可以为命令的 CanExecute 路由事件建立处理程序,从而允许应用程序确定命令在任何给定时刻是否能够执行。WPF 会频繁地为所有路由命令引发该事件,并禁用任何设置为执行当前无法执行的命令的控件。

例如,假设您有一个用于编辑文档的 WPF 应用程序。该应用程序在工具栏上有一个“保存”按钮,但如果没有打开任何文档,WPF 将自动禁用“保存”按钮(假设该按钮连接到执行“保存”命令,并且当没有打开任何文档时,应用程序的“保存”命令 CanExecute 逻辑返回 false)。应用程序的“保存”菜单项和 Ctrl + S 快捷方式也将自动禁用,假设它们连接到使用相同的“保存”命令。

元素树中的元素可以通过创建 CommandBinding 对象来为路由命令的事件建立事件处理程序。如果您将 CommandBinding 添加到元素的 CommandBindings 集合中,您实际上已经钩住了某个命令的 Executed 和/或 CanExecute 事件。

路由命令帮助我们松散耦合控制器与视图。视图中的控件和元素可以执行控制器中内置了执行逻辑的路由命令。这意味着视图不引用其控制器,控制器也不关心视图中执行命令的是什么。控制器和视图仅通过这些“语义标识符”而非直接对象引用相关联。

在此处了解更多关于 WPF 命令的信息。

数据绑定

数据绑定是一种自动化模型和视图之间数据传输的方式。WPF 提供了一个非常全面和灵活的绑定系统,完全基于运行时类型信息(即 .NET 反射)。由于绑定本质上是后期绑定的,因此视图在编译时不需要直接引用模型。这允许您纯粹通过模型 API 中实体的名称将视图与模型关联,而不是与类的属性本身关联。数据绑定提供的这种间接层允许模型和视图保持松散耦合。如果需要,模型可以稍后与外观(façade)交换,视图可以在运行时或编译时轻松替换为新的或特定文化版本。

如果您的模型对象的属性在运行时由应用程序设置,则它们应该实现 INotifyPropertyChanged 接口。当模型对象上的属性设置为新值时,该对象应该引发其 PropertyChanged 事件,以告知绑定系统此更改。这将导致 UI 更新并显示新值,假设某些元素具有绑定到模型对象上该属性的属性。

有时,模型公开的数据在显示给用户之前需要转换、格式化或组合。反之,有时用户输入的数据在发送回相关模型对象之前需要进行处理。这些类型的数据转换通过创建一个实现 IValueConverter 的类并将其实例分配给 Binding 对象的 Converter 属性来处理。这些对象是“值转换器”,是任何 WPF 开发人员工具包中非常有用的补充。

您可以通过多种方式验证视图和模型之间传递的数据。使用哪种技术取决于具体场景和需求。最糟糕的选择是创建 ValidationRule 子类并将其添加到 BindingValidationRules 集合中。这是不可取的,因为您正在创建包含平台无关业务逻辑的平台特定类。

一般来说,验证逻辑属于领域/业务层。从 .NET 3.5 开始,WPF 绑定系统与 IDataErrorInfo 接口配合使用。此接口已使用多年,并允许验证逻辑保留在应用程序的适当层中。您可以在模型上实现 IDataErrorInfo,或者,如果您使用模型-视图-视图模型模式(稍后讨论),则在视图模型上实现它。如果您的模型类严格不允许您将其置于无效状态,或者如果您使用的模型类无法更改但必须添加验证逻辑,那么在视图模型类上而不是在模型上实现 IDataErrorInfo 才有意义。然而,我跑题了。我们稍后会探讨 MVVM 模式。

集合视图

当您在 WPF 中绑定到集合时,您实际上绑定到的是围绕该集合包装的视图。WPF 在后台创建了一个 ICollectionView 并绑定到它。集合视图允许您对基础集合中的项进行排序、过滤和分组。它还允许您以编程方式发现和操作“当前”项,这会转换为 UI 中的选定项(例如,ListBox 中的选定项)。

在我们在此创建的 WPF/MVC 世界中,集合视图是帮助将控制器与视图解耦的关键组成部分。为了使控制器不需要引用视图,它可以转而引用围绕 UI 中显示的模型对象列表包装的集合视图。如果视图恰好在 ListBoxCombobox 或第三方数据网格中显示该模型对象列表,控制器将永远不会知道或关心。控制器可以监听集合视图上的事件,以了解当前/选定项何时更改,甚至可以调用 MoveCurrentTo 方法来设置 UI 中的选定项。

此技术的一个缺点是 ICollectionView 无法表达一组选定项目;它只有 CurrentItem 属性。如果视图允许用户在列表中选择多个项目,则控制器需要通过接口引用视图。该接口将具有类似于 CurrentItems 属性和 CurrentItemsChanged 事件的功能。我希望未来能将此多选功能添加到 WPF 的 ICollectionView 中。

资源

这似乎是我们实现易于单元测试的 WPF 应用程序的“基本支柱”列表中的一个奇怪的补充。您可能想知道资源系统如何相关,因为它通常只存储样式、模板、画笔等。

事实证明,您可以将 WPF 资源系统用作一个简单轻量级的依赖注入框架。您可以将任何类型的对象存储在资源字典中,并且每个元素都通过其 Resources 属性公开一个资源字典。通过在任何元素上调用 FindResourceTryFindResource 方法,可以以编程方式发现资源。我们可以使用资源系统作为在运行单元测试时将模拟对象注入控制器的方法。关于对象模拟实践的讨论超出了本文的范围。

演示应用程序

在我们深入研究演示应用程序代码(可在本文顶部下载)之前,让我们先看看演示是什么样的。该应用程序允许您查看一些 X 射线图像。主屏幕包含一个简单的 X 射线列表和一个按钮,单击该按钮会打开一个对话框,显示与所选 X 射线关联的图像。此图片显示了当所选 X 射线是列表中的第一个项目时,单击“查看 X 射线”按钮后应用程序的外观。

screenshot1.png

请注意下一个截图,一旦图像查看器对话框关闭,您查看的 X 射线会更改其显示文本,以指示用户已经查看过该 X 射线图像。

screenshot2.png

请记住,我完全没有在医疗行业编程的经验,对 X 射线一无所知,并且不建议这是设计用于查看 X 射线的用户界面的“正确”方法。我只是喜欢看头骨的 X 射线图像!

演示应用的工作原理

本节回顾了大多数演示应用程序如何利用 WPF 的功能来实现 MVC。考虑到应用程序极其简单和愚蠢,为此简单演示应用程序使用 MVC 模式无疑是多余的。这是创建演示应用程序的长期问题:它们需要足够简单,以免混淆所呈现的重要信息,但又要足够复杂以演示当前主题。将此演示视为开发实际应用程序的起点。

架构概述

主窗口是 XRayWindow。它承载 XrayWindowView 用户控件,该控件是包含 UI 中看到的控件的视图。XrayWindow 在其构造函数中创建 XrayWindowController 的实例,并将所有决策委托给该控制器。在 XrayWindow 的构造函数中还创建了 XrayCollection 的实例,其中包含一组 Xray 对象。XrayCollectionXray 构成了应用程序的模型。XrayCollection 被设置为窗口的 DataContext,以便视图可以绑定到它。控制器引用了 XrayCollection,以便它可以获取围绕它包装的集合视图。这是 XrayWindow 构造函数,它似乎是此应用程序设计的核心

public XrayWindow()
{ 
    InitializeComponent();

    XrayCollection xrays = XrayCollection.Load();

    // Create the controller that  
    // handles user interaction.
    _controller = new XrayWindowController(this, xrays);

    // Use the list of Xray objects as 
    // this Window's data source.
    base.DataContext = xrays;
}

代码逐步讲解

现在我们将检查 X 射线列表如何显示,以及当用户单击“查看 X 射线”按钮时显示 X 射线图像的机制。这是 XrayWindowView 的 XAML(其代码隐藏为空)。

<UserControl 
  x:Class="TestableXrayDemo.Views.XrayWindowView"
  xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
  xmlns:demo="clr-namespace:TestableXrayDemo"
  xmlns:model="clr-namespace:TestableXrayDemo.Model"
  xmlns:views="clr-namespace:TestableXrayDemo.Views"
  >
  <UserControl.Resources>
  <!-- 
  The resources were omitted for the sake of clarity.
  -->
  </UserControl.Resources>

  <!--
  These are the controls seen in the main Window.
  -->
  <DockPanel Margin="2">
    <Button 
      Command="{x:Static demo:Commands.ShowSelectedXray}" 
      Content="View X-Ray"
      DockPanel.Dock="Bottom"
      HorizontalAlignment="Center" 
      Margin="0,4"
      />

    <ListBox 
      IsSynchronizedWithCurrentItem="True" 
      ItemsSource="{Binding Path=.}" 
      />
  </DockPanel>
</UserControl>

用户界面由一个 ListBox 和一个 Button 控件组成。ListBoxItemsSource 绑定到 “.”,这意味着它绑定到继承的 DataContext。由于 XrayWindow 构造函数将窗口的 DataContext 设置为 XrayCollection,并且此视图是该窗口的子级,因此该视图继承了 XrayCollection 作为其 DataContext。这意味着 ListBox 绑定到围绕 XrayCollection 包装的集合视图。此外,请注意 ListBoxIsSynchronizedWithCurrentItem 属性设置为 true。这确保它与它所绑定的集合视图的当前项保持同步。

现在,关注上面 XAML 中声明的 Button。注意它的 Command 属性是如何设置为引用 Commands 类的一个名为 ShowSelectedXray 的静态字段的。你在这里看到的是一个视图将其一个控件与应用程序定义的路由命令关联起来的示例。这是 Commands 类的定义

public static class Commands
{
    /// <summary>
    /// Executed when the selected Xray's image should be displayed.
    /// </summary>
    public static readonly RoutedUICommand ShowSelectedXray;

    static Commands()
    {
        ShowSelectedXray = new RoutedUICommand(
            Resources.ShowSelectedXrayCommandText,
            "ShowSelectedXray",
            typeof(Commands));
    }
}

到目前为止,我们已经看到了视图如何引用自定义路由命令和命令定义,但我们还没有看到命令执行时会发生什么。这是一个分为两部分的过程,从 XrayWindow 开始。这是 XrayWindow 的 XAML

<Window 
  x:Class="TestableXrayDemo.XrayWindow"
  xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
  xmlns:demo="clr-namespace:TestableXrayDemo"  
  xmlns:views="clr-namespace:TestableXrayDemo.Views"  
  FontSize="20"
  MaxWidth="800" MaxHeight="1000"
  MinWidth="250" MinHeight="200"
  SizeToContent="WidthAndHeight"
  Title="X-Ray Demo"
  WindowStartupLocation="CenterScreen"
  >

  <!-- 
  Establish handlers for the ShowSelectedXray command's events. 
  -->
  <Window.CommandBindings>
    <CommandBinding 
      Command="{x:Static demo:Commands.ShowSelectedXray}" 
      CanExecute="ShowSelectedXray_CanExecute" 
      Executed="ShowSelectedXray_Executed" 
      />
  </Window.CommandBindings>

  <!-- 
  This is the View applied to the Model.
  -->
  <views:XrayWindowView />

</Window>

窗口有一个 CommandBinding,它为我们自定义命令的 CanExecuteExecuted 路由事件建立了处理程序。这些事件处理方法在窗口的代码隐藏中定义,如下所示

void ShowSelectedXray_CanExecute(object sender, CanExecuteRoutedEventArgs e)
{
    e.CanExecute = _controller.CanShowSelectedXray;
}

void ShowSelectedXray_Executed(object sender, ExecutedRoutedEventArgs e)
{
    _controller.ShowSelectedXray();
}

如您所见,XrayWindow 只是将命令处理逻辑委托给其控制器。对于如此简单的应用程序来说,这可能看起来有些多余,而且确实如此。然而,正如前面所讨论的,随着应用程序规模和复杂性的增加,将应用程序交互逻辑与某些任意 UI 容器(例如 Window)分离会变得越来越有益。这样做也极大地简化了单元测试的创建,因为测试不必担心 Window 特定的问题和细微差别。

现在让我们检查控制器如何满足用户查看选定 X 射线图像的请求。此代码位于 XrayWindowController 中。首先,我们将检查确定用户是否可以查看图像的属性。

public bool CanShowSelectedXray
{
    // Return true if there is a selected Xray in the UI.
    get { return _xraysView.CurrentItem != null; }
}

CanShowSelectedXray 属性确定应用程序当前是否可以显示选定的 X 射线图像。它通过询问围绕 XrayCollection 包装的集合视图是否有当前项来确定这一点,这类似于询问用户是否已在 ListBox 中选择了 X 射线。这是如何使用 ICollectionView 接口可以将控制器与视图解耦的示例。控制器对 XrayWindowView 中的 ListBox 一无所知,也不应该知道。

如果用户可以查看图像并点击“查看 X 射线”按钮,将调用以下方法。

public void ShowSelectedXray()
{
    #region Disclaimer

    // This method does not perform any null checks
    // for the sake of clarity and simplicity. In a 
    // real app a method like this should more robust.

    #endregion // Disclaimer

    // Get the Xray object selected in the UI.
    Xray selectedXray = _xraysView.CurrentItem as Xray;

    // Find the UI object for displaying x-ray images.
    // If running in a unit test we get a mock object.
    IXrayImageViewer xrayViewer =
        _xrayWindow.FindResource("VIEW_XrayImageViewer")
        as IXrayImageViewer;

    Uri imageLocation = GetXrayImageLocation(selectedXray);
    xrayViewer.ShowImage(imageLocation);

    // The UI detects the new value of HasBeenViewed
    // because Xray implements INotifyPropertyChanged.
    if (!selectedXray.HasBeenViewed)
        selectedXray.HasBeenViewed = true;
}

此方法中有两个值得关注的地方。控制器利用 WPF 资源系统获取一个可用于显示 X 射线图像的对象。它通过在 XrayWindow 上调用 FindResource 来实现,传入一个众所周知的资源键,期望接收一个实现 IXrayImageViewer 接口的对象。在 *App.xaml* 文件中,您会看到 XrayImageViewer 窗口的一个实例已添加到应用程序的资源字典中,如下所示

<Application 
  x:Class="TestableXrayDemo.App"
  xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
  xmlns:views="clr-namespace:TestableXrayDemo.Views"
  StartupUri="XrayWindow.xaml"
  >
  <Application.Resources>
    <!-- 
    This is the Window used to display x-ray images.  
    It is marked as not being shared because you cannot
    show a Window that has already been closed. Making  
    it unshared means a new instance is created every 
    time it is requested by the XrayWindowController.
    -->
    <views:XrayImageViewer 
      x:Key="VIEW_XrayImageViewer" 
      x:Shared="False" 
      />
  </Application.Resources>
</Application>

在调用 IXrayImageViewer.ShowImage 返回后,控制器会告诉选定的 Xray 现已标记为“已查看”。它通过将 Xray 对象的 HasBeenViewed 属性设置为 true 来实现。该属性定义如下

public bool HasBeenViewed
{
    get { return _hasBeenViewed; }
    set 
    {
        if (value == _hasBeenViewed)
            return;

        _hasBeenViewed = value;

        this.OnPropertyChanged("HasBeenViewed");
    }
}

XrayWindowView 对其 ListBox 中的项目应用一个 Style。当绑定的 Xray 对象的 HasBeenViewed 属性返回 true 时,该 Style 会改变显示文本的渲染方式。由于 Xray 对象在 setter 中引发其 PropertyChanged 事件,因此 Style 会收到何时查看 Xray 的通知。该 Style 如下

<!-- 
This alters an Xray's display text 
after its image has been viewed. 
-->
<Style x:Key="XrayTextBlockStyle" TargetType="TextBlock">
  <Style.Triggers>
    <DataTrigger 
      Binding{Binding Path=HasBeenViewed}"=" 
      Value="True"
      >
      <Setter Property="FontStyle" Value="Italic" />
      <Setter Property="Foreground" Value="Gray" />
    </DataTrigger>
  </Style.Triggers>
</Style>

单元测试模型和控制器

至此,我们已经了解了如何创建使用 MVC 模式的 WPF 应用程序。这种设计实践的结果是,我们实现了应用程序数据模型、交互逻辑和用户界面之间的清晰分离。现在我们拥有一个由模块化组件构建的松散耦合系统,我们可以轻松创建专注于应用程序交互逻辑和数据模型的单元测试。在设计良好的系统中,应该没有必要对 UI 进行单元测试,因为它只是“消费”控制器。我们希望我们的单元测试和 UI 都“消费”控制器,这样以编程方式测试应用程序逻辑与手动测试应用程序逻辑是相同的。

演示应用程序有一个包含单元测试的独立项目。为了方便起见,我决定使用 Visual Studio 2008 中内置的单元测试框架。这些测试在 NUnit 或您使用的任何其他框架中都可以正常工作。它们不依赖于内置 Visual Studio 2008 测试框架的任何深奥功能。

测试模型

Xray 类的测试很简单。它只是确保当 HasBeenViewed 属性设置时,对象的 PropertyChanged 事件正确引发。这是该测试方法

[TestMethod]
public void HasBeenViewedTest()
{
    DateTime creationDate = new DateTime();
    string fileName = string.Empty;
    XraySide side = XraySide.Front;
    Xray xray = new Xray(creationDate, fileName, side);

    Assert.IsFalse(xray.HasBeenViewed, "HasBeenViewed should return false now.");

    bool eventIsCorrect = false;
    xray.PropertyChanged +=
        delegate(object sender, PropertyChangedEventArgs e)
        {
            eventIsCorrect = e.PropertyName == "HasBeenViewed";
        };

    xray.HasBeenViewed = true;

    Assert.IsTrue(
      eventIsCorrect, 
      "Setting HasBeenViewed to true did not raise PropertyChanged event correctly.");

    Assert.IsTrue(xray.HasBeenViewed, "HasBeenViewed should return true now.");
} 

模拟用户交互

更有趣的单元测试是那些测试 XrayWindowController 类的。首先,让我们检查验证 CanShowSelectedXray 属性输出的测试方法

[TestMethod]
public void CanShowSelectedXrayTest()
{
    XrayWindow xrayWindow = new XrayWindow();
    XrayCollection xrays = xrayWindow.DataContext as XrayCollection;                   
    XrayWindowController target = new XrayWindowController(xrayWindow, xrays);

    ICollectionView xraysView = CollectionViewSource.GetDefaultView(xrays);
    xraysView.MoveCurrentToPosition(-1);
    Assert.IsFalse(
      target.CanShowSelectedXray, 
      "Should not be able to show an image since no Xray is selected.");

    // Setting the position to zero is essentially the
    // same as selecting the first Xray in the ListBox.
    xraysView.MoveCurrentToPosition(0);
    Assert.IsTrue(
      target.CanShowSelectedXray, 
      "Should be able to show an image since an Xray is selected.");
} 

现在我们可以开始看到实现解耦系统如何带来丰富的单元测试能力的优点。由于控制器使用 ICollectionView 来查找视图中选定的 Xray,因此我们可以通过获取对相同集合视图的引用并设置其当前项来以编程方式模拟用户与视图的交互。我特指对 MoveCurrentToPosition 的调用,它指示当前项索引的集合视图。当控制器询问集合视图当前项是什么时,它将返回我们指定的任何索引处的 Xray(如果指定索引为 -1,则返回 null)。

注入模拟对象

最后一个单元测试稍微复杂一点。在此测试中,我们希望验证控制器的 ShowSelectedXray 方法执行两件事:显示图像并将 Xray 对象标记为已查看。

这带来了一个小问题。单元测试套件应该能够运行完成,而无需用户交互。这使得测试可以在持续集成服务器上运行,并防止在您的本地机器上运行它们成为一种烦恼。如果我们要从单元测试中测试控制器的 ShowSelectedXray 方法,它将打开图像查看器对话框并永远等待,直到有人过来关闭该对话框窗口。我们可以采取一些不好的做法,在 XrayImageViewer 的代码隐藏中加入特殊逻辑,如果是在单元测试中运行,则立即关闭窗口。这是一个糟糕的主意,因为它会用只与单元测试相关的代码污染您的应用程序。如果您相信破窗理论,那么这显然不是理想的解决方案。那么正确的做法是什么呢?

正如本小节标题所暗示的,这是关于一个单元测试,它将模拟对象注入到被测方法中。如前所述,ShowSelectedXray 方法使用资源系统来定位 IXrayImageViewer 的实例。为了帮助您回忆,这是该方法

public void ShowSelectedXray()
{
    #region Disclaimer

    // This method does not perform any null checks
    // for the sake of clarity and simplicity. In a 
    // real app a method like this should more robust.

    #endregion // Disclaimer

    // Get the Xray object selected in the UI.
    Xray selectedXray = _xraysView.CurrentItem as Xray;

    // Find the UI object for displaying x-ray images.
    // If running in a unit test we get a mock object.
    IXrayImageViewer xrayViewer =
        _xrayWindow.FindResource("VIEW_XrayImageViewer")
        as IXrayImageViewer;

    Uri imageLocation = GetXrayImageLocation(selectedXray);
    xrayViewer.ShowImage(imageLocation);

    // The UI detects the new value of HasBeenViewed
    // because Xray implements INotifyPropertyChanged.
    if (!selectedXray.HasBeenViewed)
        selectedXray.HasBeenViewed = true;
}

当应用程序正常运行时,调用 FindResource 会返回应用程序主资源字典中保存的 XrayImageViewer。但是,我们可以创建 IXrayImageViewer 的一个虚拟实现,并将其添加到 XrayWindow 的资源字典中。这样做会导致该方法使用我们的模拟对象,该对象不会打开窗口或显示任何内容。这是模拟查看器类

/// <summary>
/// An implementation of IXrayImageViewer
/// for unit testing purposes.
/// </summary>
private class MockXrayImageViewer : IXrayImageViewer
{
    public bool ShowImageWasCalled = false;

    public void ShowImage(Uri imageLocation)
    {
        this.ShowImageWasCalled = true;
    }
}

这是创建模拟对象,然后用它测试控制器的方法

[TestMethod]
public void ShowSelectedXrayTest()
{
    XrayWindow xrayWindow = new XrayWindow();

    // Add a fake image viewer to the window's resources
    // so that the controller being tested can use it.
    MockXrayImageViewer mockViewer = new MockXrayImageViewer();
    xrayWindow.Resources.Add(
        "VIEW_XrayImageViewer", 
        mockViewer);

    // Create the collection of x-rays and its default view.
    XrayCollection xrays = xrayWindow.DataContext as XrayCollection;
    ICollectionView xraysView = CollectionViewSource.GetDefaultView(xrays);

    // Select the first Xray object.
    Xray selectedXray = xrays[0];
    xraysView.MoveCurrentTo(selectedXray);

    // Perform the test.
    XrayWindowController target = new XrayWindowController(xrayWindow, xrays);
    target.ShowSelectedXray();

    Assert.IsTrue(mockViewer.ShowImageWasCalled, "ShowImage should have been invoked.");
    Assert.IsTrue(selectedXray.HasBeenViewed, "HasBeenViewed property should be true.");
}

当你运行这个测试套件时,你不会看到任何窗口弹出。由于控制器调用了我们模拟对象上的 ShowImage,因此测试很快完成,并且除了令人愉快的绿色成功指示灯之外什么也没有显示。

MVVM 简介

尽管本文重点介绍如何将我的 MVC 版本用于 WPF,但值得注意的是,我并不是第一个探索这个领域的人。微软的一些非常聪明的人为 WPF 量身定制了 MVC 的一个变体。他们将其称为 Model-View-ViewModel 模式,有时也称为 DataModel-View-ViewModel 模式。

在这种模式中,控制器演变为 ViewModel。ViewModel 本质上是底层模型的一个精巧的、WPF 友好的包装器。ViewModel 将数据集合作为可观察集合公开,从而实现丰富的数据绑定支持。它还允许您将领域验证和请求处理放置在介于视图和模型之间的一层中。当您使用 MVVM 时,视图绑定到 ViewModel,而不是模型。从这个意义上说,ViewModel 充当“真实”模型和用户界面之间的适配器。

要了解更多关于 MVVM 的信息,请查看以下链接

修订历史

  • 2008年1月27日——文章创建
© . All rights reserved.