MVC

ASP.NET Web 窗体导航框架中的单元测试

Graham Mendick

下载代码示例

下载库

ASP.NET Web 窗体导航框架是 navigation.codeplex.com 上提供的一个开源项目,它使用新的方法进行导航和传递数据,从而以全新的方式编写 Web 窗体应用程序。 在传统 Web 窗体代码中,传递数据的方式取决于执行的导航。 例如,在重定向期间,数据可能保存在查询字符串或路由数据中,但在回发期间,数据保存在控制值或视图状态中。 但在 ASP.NET Web 窗体导航框架(以下简称“导航框架”)中,将在所有情况下使用单一数据源。

在我的第一篇文章 (msdn.microsoft.com/magazine/hh975349) 中,我简要介绍了导航框架,并构建了一个网上抽样调查应用程序以说明导航框架的一些关键概念及其具有的优点。 特别是,我介绍了它如何支持生成一组动态的上下文相关 breadcrumb 超链接,以使用户能够返回到以前访问的问题并恢复用户的答案,从而克服了静态 ASP.NET 站点地图功能的局限性。

此外,我还在第一篇文章中指出,可以通过导航框架编写 Web 窗体代码,这是 ASP.NET MVC 应用程序非常羡慕的地方。 不过,抽样调查应用程序并没有体现出来这一点,因为代码放入了代码隐藏文件中而无法进行单元测试。

我将在这个第二篇文章中解决该问题,即,编辑调查应用程序以使其像典型 ASP.NET MVC 应用程序那样具有严谨的结构以及较高的单元可测试性。 我将标准 ASP.NET 数据绑定与导航框架一起使用以清除代码隐藏文件,并将业务逻辑提取到一个单独的类中,然后,我对该类进行单元测试。 此测试不需要进行任何模拟,并且代码范围包括导航逻辑,这是其他 ASP.NET 单元测试方法很少会提供的功能。

数据绑定

Web 窗体代码墓地将散落着代码隐藏文件的臃肿尸体,但其实不一定要这样。 虽然 Web 窗体以一开始就具有数据绑定功能,但直到 2005 年 Visual Studio 才推出数据源控件和 Bind 语法以进行双向可更新绑定,从而使 Web 窗体应用程序开发在结构上与典型 MVC 应用程序更相似。 此类代码的好处(特别是单元测试方面)得到了人们的广泛认可,这体现在下一 Visual Studio 版本的大部分 Web 窗体开发工作都针对这一方面。

为了说明这一点,我使用在第一篇文章中开发的调查应用程序,并将其转换为类似 MVC 的体系结构。 控制器类将保存业务逻辑,ViewModel 类将保存控制器和视图之间的通信数据。 这需要很少的开发工作,因为可以剪切代码隐藏文件中当前包含的代码,并将其几乎原样粘贴到控制器中。

从 Question1.aspx 入手,第一步是创建一个包含字符串属性的 Question ViewModel 类,以便将选择的答案传递到控制器以及从控制器传递该答案:

public class Question
{
  public string Answer
  {
    get;
    set;
  }
}

接下来是控制器类,我将其命名为 SurveyController;这是一个不同于 MVC 控制器的 Plain Old CLR Object。 Question1.aspx 需要两个方法,一个用于检索数据以返回 Question ViewModel 类,另一个用于更新数据以接受 Question ViewModel 类:

public class SurveyController
{
  public Question GetQuestion1()
  {
    return null;
  }
  public void UpdateQuestion1(Question question)
  {
  }
}

为了填充这些方法,我使用 Question1.aspx 代码隐藏文件中的代码,从而将页面加载逻辑移到 GetQuestion1 中,并将按钮单击处理程序逻辑移到 UpdateQuestion1 中。 由于控制器无法访问页面上的控件,因此,将使用 Question ViewModel 类获取和设置答案,而不是单选按钮列表。 需要进一步微调 GetQuestion1 方法以确保返回的默认答案是“Web Forms”:

public Question GetQuestion1()
{
  string answer = "Web Forms";
  if (StateContext.Data["answer"] != null)
  {
    answer = (string)StateContext.Data["answer"];
  }
  return new Question() { Answer = answer };
}

在 MVC 中,数据绑定位于请求级别并通过路由注册将请求映射到控制器方法,但在 Web 窗体中,数据绑定位于控制级别并使用 ObjectDataSource 完成映射。 因此,为了将 Question1.aspx 挂接到 SurveyController 方法,我添加一个连接到正确配置的数据源的 FormView:

<asp:FormView ID="Question" runat="server"
  DataSourceID="QuestionDataSource" DefaultMode="Edit">
  <EditItemTemplate>
  </EditItemTemplate>
</asp:FormView>
<asp:ObjectDataSource ID="QuestionDataSource" 
  runat="server" SelectMethod="GetQuestion1" 
  UpdateMethod="UpdateQuestion1" TypeName="Survey.SurveyController"  
  DataObjectTypeName="Survey.Question" />

最后一步是将问题(包含单选按钮列表和按钮)移到 FormView 的 EditItemTemplate 内。 同时,必须进行两处更改以使数据绑定机制正常工作。 第一处更改是使用 Bind 语法,以便显示从 GetQuestion1 返回的答案,并将新选择的答案传回到 UpdateQuestion1。 第二处更改是将按钮的 CommandName 设置为 Update,以便在按下该按钮时自动调用 UpdateQuestion1(您会注意到删除了第一个列表项的 Selected 属性,因为将默认答案设置为“Web Forms”现在是在 GetQuestion1 中管理的):

<asp:RadioButtonList ID="Answer" runat="server"
  SelectedValue='<%# Bind("Answer") %>'>
  <asp:ListItem Text="Web Forms" />
  <asp:ListItem Text="MVC" />
</asp:RadioButtonList>
<asp:Button ID="Next" runat="server" 
  Text="Next" CommandName="Update" />

对于 Question1.aspx,此过程现已完成,令人满意的是清除了其代码隐藏文件。 可以采用相同的步骤将数据绑定添加到 Question2.aspx 中,但无法完全清除其代码隐藏文件,因为与向后导航超链接相关的页面加载代码必须暂时保留在此处。 下一节讨论了将导航框架与数据绑定集成在一起,我们会将其移到标记和清空的代码隐藏文件中。

将数据绑定添加到 Thanks.aspx 是类似的,但并非重用未正确命名的 Question ViewModel 类,我创建一个名为 Summary 的新类并使用一个字符串属保存所选的答案:

public class Summary
{
  public string Text
  {
    get;
    set;
  }
}

由于 Thanks.aspx 是一个只读屏幕,因此,只需要在控制器上使用数据检索方法,与 Question2.aspx 一样,可以将向后导航逻辑以外的所有页面加载代码移到此方法中:

public Summary GetSummary()
{
  Summary summary = new Summary();
  summary.Text = (string)StateContext.Data["technology"];
  if (StateContext.Data["navigation"] != null)
  {
    summary.Text += ", " + (bool)StateContext.Data["navigation"];
  }
  return summary;
}

由于不需要使用更新功能,因此,使用 FormView ItemTemplate 而不是 EditItemTemplate,并使用单向绑定语法 Eval 替代 Bind:

<asp:FormView ID="Summary" runat="server" 
  DataSourceID="SummaryDataSource">
  <ItemTemplate>
    <asp:Label ID="Details" runat="server" 
      Text='<%# Eval("Text") %>' />
  </ItemTemplate>
</asp:FormView>
<asp:ObjectDataSource ID="SummaryDataSource" 
  runat="server"
  SelectMethod="GetSummary" 
  TypeName="Survey.SurveyController" />

单元测试工作已完成了一半,因为调查应用程序业务逻辑已提取到一个单独的类中。 不过,由于将代码隐藏文件中的代码几乎原样粘贴到控制器中,因而无法充分利用数据绑定的强大功能。

导航数据绑定

调查应用程序代码仍存在几个问题: 仅 SurveyController 中的更新方法应包含导航逻辑,并且代码隐藏文件不是空的。 在解决这些问题之前,不应开始进行单元测试,因为前者导致 get 方法的单元测试过于复杂,而后者完全禁止单元测试。

数据源控件的选择参数使访问数据绑定方法中的 HttpRequest 对象变得多余。 例如,通过使用 QueryStringParameter 类,您可以将查询字符串数据作为参数传递到数据绑定方法。 导航框架具有一个 NavigationDataParameter 类,它为 StateContext 对象上的状态数据执行等效的操作。

在实现此 NavigationDataParameter 后,我可以重新访问 GetQuestion,以便通过将答案指定为方法参数来删除访问状态数据的所有代码。 这会大大简化代码:

public Question GetQuestion1(string answer)
{
  return new Question() { Answer = answer ?? "
Web Forms" };
}

附带的 Question1.aspx 更改是将 NavigationDataParameter 添加到其数据源中。 这包括在页面上首次注册导航命名空间:

    <%@ Register assembly="Navigation" 
                           namespace="Navigation" 
                            tagprefix="nav" %>

然后,可以将 NavigationDataParameter 添加到数据源的选择参数中:

<asp:ObjectDataSource ID="QuestionDataSource" runat="server"
  SelectMethod="GetQuestion1" UpdateMethod="UpdateQuestion1" 
  TypeName="Survey.SurveyController" 
  DataObjectTypeName="Survey.Question" >
  <SelectParameters>
    <nav:NavigationDataParameter Name="answer" />
  </SelectParameters>
</asp:ObjectDataSource>

现在可以轻松对 GetQuestion1 方法(已去除所有 Web 特定的代码)进行单元测试。 可以为 GetQuestion2 执行同样的操作。

对于 GetSummary 方法,需要两个参数,每个答案对应一个参数。 第二个参数是布尔参数,用于匹配 UpdateQuestion2 传递数据的方式,并且它必须可以为 Null,因为并不总是回答第二个问题:

public Summary GetSummary(string technology, bool?
navigation)
{
  Summary summary = new Summary();
  summary.Text = technology;
  if (navigation.HasValue)
  {
    summary.Text += ", " + navigation.Value;
  }
  return summary;
}

对 Thanks.aspx 上的数据源的相应更改是添加两个 NavigationDataParameter:

<asp:ObjectDataSource ID="SummaryDataSource" runat="server"
  SelectMethod="GetSummary" TypeName="Survey.SurveyController" >
  <SelectParameters>
    <nav:NavigationDataParameter Name="technology" />
    <nav:NavigationDataParameter Name="navigation" />
  </SelectParameters>
</asp:ObjectDataSource>

调查应用程序代码的第一个问题已得到解决,因为现在仅控制器中的更新方法包含导航逻辑。

您会记得,导航框架改进了 Web 窗体站点地图提供的静态 breadcrumb 导航功能,它跟踪访问的状态及其状态数据,并积聚用户采用的实际路由的上下文相关 breadcrumb 痕迹。 要在标记中构造向后导航超链接(而不需要代码隐藏文件),导航框架提供了一个类似于 SiteMapPath 控件的 CrumbTrailDataSource。 在用作 ListView 的支持数据源时,CrumbTrailDataSource 返回一个项目列表(以前访问的每个状态一个项目),这些项目分别包含一个 NavigationLink URL 以便以上下文相关的方式导航回该状态。

我将使用该新数据源将 Question2.aspx 向后导航移到其标记中。 首先,我添加一个连接到 CrumbTrailDataSource 的 ListView:

<asp:ListView ID="Crumbs" runat="server" 
  DataSourceID="CrumbTrailDataSource">
  <LayoutTemplate>
    <asp:PlaceHolder ID="itemPlaceholder" runat="server" />
  </LayoutTemplate>
  <ItemTemplate>
  </ItemTemplate>
</asp:ListView>
<nav:CrumbTrailDataSource ID="CrumbTrailDataSource" runat="server" />

接下来,我从 Question2.aspx 代码隐藏文件中删除页面加载代码,将向后导航超链接移到 ListView ItemTemplate 内,然后使用 Eval 绑定填充 NavigateUrl 属性:

<asp:HyperLink ID="Question1" runat="server"
  NavigateUrl='<%# Eval("NavigationLink") %>' Text="Question 1"/>

您会注意到,HyperLink Text 属性硬编码到“Question 1”中。这非常适合 Question2.aspx,因为唯一可能的向后导航是返回到第一个问题。 不过,对于 Thanks.aspx 就不同了,因为它可能会返回到第一个或第二个问题。 所幸的是,在 StateInfo.config 文件中输入的导航配置可以将 title 属性与每个状态相关联,例如:

<state key="Question1" page="~/Question1.aspx" title="Question 1">

然后,CrumbTrailDataSource 可以将此 title 用于数据绑定:

<asp:HyperLink ID="Question1" runat="server"
  NavigateUrl='<%# Eval("NavigationLink") %>' 
  Text='<%# Eval("Title") %>'/>

通过为 Thanks.aspx 应用这些相同的更改,可以解决调查应用程序代码的第二个问题,因为所有代码隐藏文件现在都是空的。 不过,如果无法对 SurveyController 进行单元测试,所有这些努力都是徒劳的。

单元测试

由于调查应用程序现在具有正确的结构(代码隐藏文件是空的,所有 UI 逻辑都位于页面标记中),现在是时候对 SurveyController 类进行单元测试了。 很显然,可以对 GetQuestion1、GetQuestion2 和 GetSummary 数据检索方法进行单元测试,因为它们不包含 Web 特定的代码。 仅 UpdateQuestion1 和 Update­Question2 方法存在单元测试问题。 如果没有导航框架,这两个方法将包含路由和重定向调用(在 ASPX 页面之间移动和传递数据的传统方法),这两个方法在 Web 环境之外使用时会引发异常,从而导致单元测试在第一道关卡处失败。 不过,在实现了导航框架后,可以对这两种方法进行完全单元测试,而无需更改任何代码或使用模拟对象。

对于初学者,我为调查创建了一个单元测试项目。 通过在 SurveyController 类中的任何方法内右键单击并选择“创建单元测试...”菜单选项,将会创建一个包含所需引用的项目和一个 SurveyControllerTest 类。

您会记得,导航框架需要在 StateInfo.config 文件中配置状态和转换列表。 要使单元测试项目使用该相同导航配置,必须在执行单元测试时部署 Web 项目中的 StateInfo.config 文件。 考虑到这一点,我双击 Local.testsettings 解决方案项目,然后在“部署”选项卡下面选中“启用部署”复选框。 然后,我使用引用此 StateInfo.config 文件的 DeploymentItem 属性修饰 SurveyControllerTest 类:

[TestClass]
[DeploymentItem(@"Survey\StateInfo.config")]
public class SurveyControllerTest
{
}

接下来,必须将 app.config 文件添加到指向部署的该 StateInfo.config 文件的测试项目中(Web 项目中也需要使用此配置,但这是由 NuGet 安装自动添加的):

<configuration>
  <configSections>
    <sectionGroup name="Navigation">
      <section name="StateInfo" type=
        "Navigation.StateInfoSectionHandler, Navigation" />
    </sectionGroup>
  </configSections>
  <Navigation>
    <StateInfo configSource="StateInfo.config" />
  </Navigation>
</configuration>

在此配置准备就绪后,便可以开始进行单元测试了。 我遵循 AAA 模式以设置单元测试的结构:

  1. 准备: 设置先决条件和测试数据。
  2. 执行: 执行要测试的单元。
  3. 断言: 验证结果。

从 UpdateQuestion1 方法入手,我将说明在测试导航框架中的导航和数据传递时,这三个步骤分别需要满足哪些条件。

准备步骤设置单元测试,从而创建要测试的对象以及需要传递到要测试的方法的参数。 对于 UpdateQuestion1,这意味着创建一个 SurveyController 和填充了相关答案的 Question。 不过,还需要一个额外的导航设置条件,以便镜像在启动 Web 应用程序时发生的导航。 在启动调查 Web 应用程序时,导航框架将截获启动页 Question1.aspx 的请求,并导航到 StateInfo.config 文件中路径属性与该请求匹配的对话框:

<dialog key="Survey" initial="Question1" path="~/Question1.aspx">

通过使用对话框的键进行导航,可以进入其 initial 属性中提到的状态,因此,将到达 Question1 状态。 由于无法在单元测试中设置启动页,因此,必须手动执行此对话框导航,这是准备步骤中需要满足额外的条件:

StateController.Navigate("Survey");

执行步骤将调用要测试的方法。 这只涉及将填充了答案的 Question 传递到 UpdateQuestion1,因此,不需要任何导航特定的细节。

断言步骤将结果与预期值进行比较。 可以使用导航框架中的类完成导航和数据传递结果验证。 您会记得,StateContext 通过其 Data 属性访问状态数据,该属性是使用导航期间传递的 NavigationData 初始化的。 它可用于验证 UpdateQuestion1 是否将所选的答案传递到下一个状态。 因此,假设已将“Web Forms”传递到该方法,断言将变为:

Assert.AreEqual("Web Forms", (string) StateContext.Data["technology"]);

StateContext 还具有一个用于跟踪当前状态的 State 属性。 它可用于检查是否按预期方式进行导航,例如,将“Web Forms”传递到 UpdateQuestion1 将会导航到 Question2:

Assert.AreEqual("Question2", StateContext.State.Key);

虽然 StateContext 保存有关当前状态和关联的数据的详细信息,但 Crumb 是以前访问的状态及其数据的等效类,之所以叫这种名称是因为每次用户导航时,都会在 breadcrumb 痕迹中添加一个新的导航。 可通过 StateController 的 Crumbs 属性访问此 breadcrumb 痕迹或 crumb 列表(这是上一节的 CrumbTrailDataSource 的支持数据)。 在导航之前,我需要借助该列表来检查 UpdateQuestion1 是否在其状态数据中存储传递的答案,因为在进行导航后,将创建 crumb 以保存此状态数据。 假设传入的答案是“Web Forms”,可以验证第一个也是唯一一个 crumb 上的数据:

Assert.AreEqual("Web Forms", (string) StateController.Crumbs[0].Data["answer"]);

我们已针对导航框架介绍了用于编写结构化单元测试的 AAA 模式。 通过组合使用所有这些步骤,下面的单元测试将检查在将答案“Web Forms”传递到 UpdateQuestion1 后是否到达 Question2 状态的(为了清楚起见,我在不同步骤之间插入一个空行):

[TestMethod]
public void UpdateQuestion1NavigatesToQuestion2IfAnswerIsWebForms()
{
  StateController.Navigate("Survey");
  SurveyController controller = new SurveyController();
  Question question = new Question() { Answer = "Web Forms" };
  controller.UpdateQuestion1(question);
  Assert.AreEqual("Question2", StateContext.State.Key);
}

虽然您只需要使用这些步骤即可对不同的导航框架概念进行单元测试,但有必要继续处理 UpdateQuestion2,因为它的准备和执行步骤有几个不同之处。 它的准备步骤中所需的导航条件是不同的,因为要调用 UpdateQuestion2,当前状态应该为 Question2,而当前状态数据应该包含“Web Forms”技术答案。 在 Web 应用程序中,此导航和数据传递是由 UI 管理的,因为如果没有对第一个问题回答“Web Forms”,用户就无法转到第二个问题。 不过,在单元测试环境中,必须手动完成此操作。 这涉及 UpdateQuestion1 到达 Question1 状态所需的相同对话框导航,然后进行导航以在 NavigationData 中传递 Next 转换键和“Web Forms”答案:

StateController.Navigate("Survey");
StateController.Navigate(
  "Next", new NavigationData() { { "technology", "Web Forms" } });

UpdateQuestion2 断言步骤的唯一差别是,在导航之前,验证是否将其答案存储在状态数据中。 对于 UpdateQuestion1,在完成此检查后,将使用列表中的第一个 crumb,因为只访问了一个状态,即 Question1。 但对于 UpdateQuestion2,列表中有两个 crumb,因为已到达了 Question1 和 Question2。 将在列表中按访问顺序显示 Crumb,因此,Question2 是第二项,先决条件检查变为:

Assert.AreEqual("Yes", (string)StateController.Crumbs[1].Data["answer"]);

我们已实现了编写可进行单元测试的 Web 窗体代码的宏伟目标。 这是使用标准数据绑定并借助于导航框架完成的。 它不像其他 ASP.NET 单元测试方法那样要求严格,因为控制器不必继承或实现任何框架类或接口,并且其方法不必返回任何特定的框架类型。

这是否会让 MVC 羡慕嫉妒恨吗?

这会让 MVC 嫉妒得发疯,因为调查应用程序具有与典型 MVC 应用程序一样的严谨结构,但具有较高的单元测试级别。 调查应用程序的导航代码出现在控制器方法内,并与业务逻辑的其余部分一起进行测试。 在 MVC 应用程序中,不会测试导航代码,因为该代码包含在控制器方法的返回类型内,如 RedirectResult。 在我的下一篇介绍导航框架的文章中,我将按照“切勿重复 (DRY)”原则构建搜索引擎优化友好的单页应用程序(在 MVC 中很难实现),这会让 MVC 妒火中烧。

尽管如此,Web 窗体数据绑定也存在一些问题,而 MVC 中却没有。 例如,很难在控制器类上使用依赖关系注入,并且不支持 ViewModel 类上的嵌套类型。 但是 Web 窗体已从 MVC 学习了很多东西,下一 Visual Studio 版本将会大大改善 Web 窗体数据绑定体验。

导航框架与数据绑定的集成还有很多内容,远远不止此处介绍的这些。 例如,数据分页控件(与 ASP.NET DataPager 不同)不需要挂接到控件上或需要单独的计数方法。 如果有兴趣了解详细信息,请参阅 navigation.codeplex.com 上提供的综合文档和示例代码。

Graham Mendick 是 Web 窗体最忠实的粉丝,希望向大家展示 Web 窗体也能够像 ASP.NET MVC 一样拥有合理的架构。 他撰写了 ASP.NET Web 窗体导航框架,他相信将其与数据绑定结合使用一定能给 Web 窗体注入新的活力。

衷心感谢以下技术专家对本文的审阅: Scott Hanselman