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

乌龟和长发:一个寓言

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.61/5 (13投票s)

2008 年 1 月 17 日

CPOL

12分钟阅读

viewsIcon

40240

downloadIcon

106

.NET MVC框架中用于单元测试视图的方法。

这是关于海龟与长发的故事

海龟和长发都在同一家公司做开发,他们接到任务要为公司构建一个备受瞩目的应用程序。这个应用程序是CEO想出来的,一种销售砖块的金字塔式销售计划。他非常有信心,认为这会使公司的利润飙升,并彻底改变砖块销售行业。

他们的老板,沃尔夫先生,在项目开始前召集了他们两个,讨论了项目细节。代码质量和可靠性至关重要,所以他们需要使用新的ASP.NET MVC框架来构建易于测试的代码。

“最后,我只是想让你们都知道,这个项目结束后,其中一个人将获得一次巨大的晋升,”沃尔夫先生解释道,“我完全希望这能激起你们俩之间一场友好的竞争。哦,另外一个人很可能会被解雇。”

于是,他们立刻着手工作。一切都很顺利。他们确保充分测试所有的模型和控制器代码。像任何项目一样,需求不断变化,但由于不断壮大的单元测试套件,他们两人并不太担心。但测试视图的时候到了。他们都找不到任何关于如何为视图代码编写单元测试的例子。这看起来很奇怪,所以他们决定下午分开,各自想办法。

海龟从最明显的地方入手,写了一些类似下面的代码

MyView view = new MyView();
HtmlTextWriter writer = new HtmlTextWriter();
view.Render(writer);

海龟坐下来,看着他的杰作。他显然是一位出色的程序员。他开始为沃尔夫先生写下他的发现,但在点击“提交”按钮之前,他自言自语道:“也许我应该真的运行一下这段代码……以防万一。”不用说,他非常失望。事实证明,他得到的结果并没有包含ASPX文件中的任何HTML。经过进一步研究,他了解到,在运行时,ASP.NET框架动态创建了一个新类,该类继承自代码隐藏文件,但也包含了ASPX文件中的所有HTML。那个动态类才是真正处理HTML渲染的。

而长发,另一方面,已经知道一些关于运行时编译的知识,并开始了一个更复杂的解决方案。他决定找到ASP.NET用来动态编译页面的代码,并用这段代码来渲染视图。长发接下来的几个小时都在谷歌搜索,查阅文档,并反编译System.Web命名空间中的代码。突然,他发现了HttpRuntime.ProcessRequest,而且它竟然是一个公共方法。这可能吗?哦,编程之神今天对他微笑。于是他写了一些代码

StringWriter output = new StringWriter();
SimpleWorkerRequest request = 
  new SimpleWorkerRequest("MyView.aspx", "", output);
HttpRuntime.ProcessRequest(request);

遗憾的是,长发的代码也失败了。他反编译了出现错误的源代码并仔细查看。没问题。这只是一个配置问题。他找到了所需的配置,修改了他的代码将其设置为所需值,并重新测试。失败。没问题。查看了反编译的源代码。嗯,又一个配置问题……

在重复了上述过程大约30次之后,长发开始觉得这不是一个好解决方案。首先,他的代码变得很难看。有各种各样的疯狂的变通方法和技巧来设置所需的配置。其次,考虑到需要进行的配置如此之多,谁知道在测试完成运行时,应用程序会处于什么状态。他开始想,通过视图测试可能不会对其他代码产生意外的副作用。

第二天,两位开发人员与老板会面,分享了他们的发现。沃尔夫先生非常愤怒。他质问道,为什么他们浪费时间胡闹而不是写“真正的代码”。会议结束后,海龟和长发都非常沮丧。他们互相依偎着哭了几分钟,然后又回去工作了。

海龟上网找到了几个用于测试Web UI的现有工具。其中一些启动浏览器并通过DOM进行测试,另一些则向Web服务器发送实际的Web请求并解析响应。所有这些似乎都有前景,所以海龟下载了一个并开始工作。虽然他很快就成功地运行了一个测试,但这更像是一个集成测试,而不是单元测试。没有办法只测试视图而不运行控制器、模型和数据库。尽管如此,也算是有所进展。最重要的是,测试是以海龟最喜欢的速度运行的……非常非常慢。

长发,另一方面,确信他能让他的原始想法奏效。经过一番仔细筛选,他偶然发现了ApplicationHost.CreateApplicationHost。根据文档,这个方法“创建并配置一个应用程序域以托管ASP.NET”。他并不太了解AppDomain对象,但他继续深入研究,发现所有.NET应用程序都在AppDomain的上下文中运行。然而,一个应用程序实际上可以启动多个AppDomain。当这样做时,每个AppDomain都有自己独立的内存、加载的程序集和自己的配置集。

这正是他们所需要的。ApplicationHost.CreateApplicationHost提供了一种简单的方法来设置ASP.NET处理页面所需的所有配置,但它将它们设置在一个单独的AppDomain中。这意味着配置与他代码的其余部分完全隔离,从而消除了更改大量配置可能对其他执行代码产生意外影响的担忧。然而,CreateApplicationHost一开始并不直观。深入研究后,他发现CreateApplicationHost接受三个参数(我们将按相反顺序讨论)。

最后一个参数是一个名为physicalDir的字符串。这是新创建的AppDomain将在其中运行的目录(这将在稍后变得非常重要)。由于长发想要测试他的网页(即视图),所以AppDomain似乎需要运行在包含这些页面的Web目录的根目录下。因此,他使用了路径“C:\Inetpub\wwwroot\MVCTestWebApp”。

第二个参数是一个名为virtualDir的字符串。在这里,他猜测了一下,使用了“/”。

最后一个参数(实际上是第一个参数)稍微棘手一些。它接受一个名为hostTypeType(它可以是实现MarshalByRefObject的任何对象的Type)。这个参数的用途起初并不清楚,但在进一步研究后,他发现一个AppDomain中的代码不能直接调用另一个AppDomain中的代码,这会造成问题。他该如何在他漂亮的新AppDomain中执行任何代码呢?事实上,如果他不能做任何事情,这个新AppDomain有什么意义呢?这就是hostType的作用。当创建新的AppDomain时,它首先要做的就是创建一个由hostType参数传入的Type的实例。这个类现在存在于新的AppDomain中。接下来,一个指向新创建对象的代理会在原始AppDomain中被创建。这个代理然后使用Remoting将方法调用从原始AppDomain发送到新创建的AppDomain,从而解决了跨域通信问题。于是长发创建了一个代理类

public class CrossDomainProxy : MarshalByRefObject
{
    public string ProcessRequest()
    {
         StringWriter output = new StringWriter();
         SimpleWorkerRequest request = 
           new SimpleWorkerRequest("MyView.aspx", "", output);
         HttpRuntime.ProcessRequest(request);
         return output.GetStringBuilder().ToString
    }
}

然后将其传递给CreateApplicationHost方法

CrossDomainProxy proxy = (CrossDomainProxy)ApplicationHost.CreateApplicationHost(
                                       typeof(CrossDomainProxy), 
                                       "/",
                                       "C:\Inetpub\wwwroot\MVCTestWebApp");

又一次失败。出于某种原因,它找不到包含他CrossDomainProxy类的程序集。他茫然地盯着屏幕看了很长时间。它怎么会找不到程序集呢?它就是当前正在执行的程序集!长发开始对屏幕大骂脏话,咒骂创建CreateApplicationHost方法的低劣程序员。

然后,他突然明白了。当前正在执行的程序集位于他当前的工程文件夹中,但新的ASP.NET AppDomain正在“C:\Inetpub\wwwroot\MVCTestWebApp”执行。它在Web应用程序的*bin*目录下寻找CrossDomainProxy程序集。很简单。他把CrossDomainProxy程序集放在了GAC中,这样任何AppDomain都可以找到它,无论它在哪里执行。完成后,长发重新启动了他的代码,这次,令他有些惊讶的是,代码运行了。不仅运行了,而且运行得很快。真的很快。“尤里卡!”,长发喊道。

他快完成了。这对于传统的ASP.NET页面来说效果很好,但对于MVC视图来说,仍然缺少一个关键部分。他仍然需要一种方法在页面渲染之前设置ViewData属性。但这有多难呢?不过,现在他累了,脑袋因为试图理解AppDomain的概念而疼痛,所以他决定今天到此为止。

第二天,海龟和长发像往常一样,上午10点来到公司,走进办公室,发现沃尔夫先生和几个看起来很重要的男人正在等着他们。显然,沃尔夫先生已经承诺要进行演示,他们两人需要展示他们的进展。

长发羞怯地解释说他还没有什么可以展示的。听到这个,沃尔夫先生皱起了眉头,而那些重要的人则窃窃私语。

海龟则看到了机会,迅速着手展示他的工作。然而,他的兴奋很快变成了恐惧,因为他立即陷入了“演示地狱”。什么都没用。前一天还能正常工作的页面今天都崩溃了。

那些重要的人离开后,沃尔夫先生也结束了他长达20分钟的训斥,两位开发人员重新聚集起来。“怎么回事?”长发问道。“昨天你说你为UI写了测试。”海龟皱着眉头说,“我有测试。问题是它们运行太慢了,所以我不会每次修改代码时都运行它们。”

于是,当海龟着手修复他损坏的视图时,长发继续了他的工作。在花了几个小时查阅MVC类库后,他更新了他的CrossDomainProxy类,代码如下。他认为这应该允许他将任何对象传递给RenderView方法,然后该对象将被设置为页面渲染前的ViewData

public static string RenderView(string controllerName, 
       string viewName, string queryString, object viewData)
{
    StringBuilder result = new StringBuilder();

    string virtualPath = string.Format("/{0}/{1}", controllerName, viewName);
    IHttpContext context = CreateHttpContext(result, virtualPath, queryString);
    ControllerContext controllerContext = 
        CreateControllerContext(context, controllerName);
    ViewContext viewContext = new ViewContext(controllerContext, 
                viewData, new TempDataDictionary(context));

    CreateView(viewName, viewData, controllerContext).RenderView(viewContext);
    context.Response.Flush();

    return result.ToString();
}

private static IHttpContext CreateHttpContext(StringBuilder result, 
               string virtualPath, string queryString)
{
    SimpleWorkerRequest wr = new SimpleWorkerRequest(virtualPath, 
                             queryString, new StringWriter(result));
    HttpContext.Current = new HttpContext(wr);            
    return new HttpContextWrapper(HttpContext.Current);
}

private static ControllerContext CreateControllerContext(IHttpContext context, 
                                 string controllerName)
{
    RouteData routeDate = new RouteData();
    routeDate.Values.Add("controller", controllerName);
    return new ControllerContext(context, routeDate, new Controller());
}

private static IView CreateView(string viewName, object viewData, 
                     ControllerContext controllerContext)
{
    IViewFactory viewFactory = new WebFormViewFactory();
    return viewFactory.CreateView(controllerContext, viewName, null, viewData);
}

模型中已经有一个Person类,所以他决定将其传递给他的视图。然后他调用了他的代理类

Person person = new Person() { Name="Eric", Age=31 }
proxy.RenderView("Test", "MyView", "", person);

他又一次失败了。进一步调查发现,这又是跨AppDomain通信的一个问题。他试图将一个存在于原始AppDomain中的Person对象的引用传递给ASP.NET AppDomain。嗯,这需要任何用作ViewData的对象都是可序列化的,这似乎不是一个合理的需要。如果有一种方法可以将一个方法传递给新的AppDomain,然后在正确域中调用以创建所需数据就好了。也许有点像一个委托……

经过大量的尝试和一些“黑魔法”,他想出了一个解决方案。首先,他创建了以下委托

public delegate object CreateViewData();

然后他创建了以下类来简化与代理的交互

public class AspnetHost
{
    private CrossDomainProxy proxy;

    public AspnetHost(string webDirectory)
    {
        proxy = (CrossDomainProxy)ApplicationHost.CreateApplicationHost(
          typeof(CrossDomainProxy), "/", Path.GetFullPath(webDirectory));
    }

    public string RenderView(CreateViewData createViewDataDelegate, 
           string controllerName, string viewName, string queryString)
    {
        Type type = createViewDataDelegate.Method.DeclaringType;
        string methodName = createViewDataDelegate.Method.Name;
        return proxy.RenderView(type.Assembly.Location, type.FullName, 
               methodName, controllerName, viewName, queryString);
    }
}

最后,他将以下方法添加到他的CrossDomainProxy类中

internal string RenderView(string sourceAssemblyPath, string typeName, 
         string createViewDataMethodName, string controllerName, 
         string viewName, string queryString)
{
    Assembly assembly = Assembly.LoadFile(sourceAssemblyPath);
    Type type = assembly.GetType(typeName);

    MethodInfo methodInfo = type.GetMethod(createViewDataMethodName,
               BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic);

    if (methodInfo == null)
    {
         throw new ApplicationException(
           "The provided delegate must point to a static method.");
    }

    object viewData = methodInfo.Invoke(null, null);
    return RenderView(controllerName, viewName, queryString, viewData);
}

这里有一些疯狂的黑魔法,让我来解释一下。AspnetHost.RenderView方法接受一个CreateViewData委托,但它不是将实际的委托传递给代理,而是传递包含该委托指向的方法的程序集的物理位置、包含该方法的类型的名称以及方法的名称。CrossDomainProxy.RenderView然后使用物理路径在ASP.NET AppDomain中加载程序集。(记住,每个AppDomain都有自己的已加载程序集集。在原始AppDomain中加载程序集并不意味着它在ASP.NET AppDomain中也被加载。)一旦程序集被加载,它就调用assembly.GetType来加载正确的Type。最后,它调用type.GetMethod来获取将创建我们ViewData的方法的引用。然后可以使用methodInfo.Invoke方法动态调用此方法。由于此方法最初是作为CreateViewData委托发送的,所以我们知道此方法的签名。它将不带任何参数并返回一个object。这个object就是我们的ViewData。(重要提示:为了使其正常工作,委托必须指向一个静态方法。此外,ASP.NET AppDomain有自己的内存,因此原始AppDomain中的任何类数据都不会存在于ASP.NET AppDomain中。)

一切就绪后,长发现在可以编写以下测试

[TestFixture]
public class PersonViewTests
{
    [Test]
    public void LoadEditView()
    {
        AspnetHost host = new AspnetHost("../../../WebApp");
        string result = host.RenderView(CreateViewData, 
                 "People", "Edit", "");
        Assert.IsTrue(result.Contains("Jack"));
        Assert.IsTrue(result.Contains("49"));
    }

    public static object CreateViewData()
    {
        return new Person() { Name = "Jack", Age = 49 };
    }
}

终于成功了!他们现在可以单元测试他们的视图了。更重要的是,这些测试运行得很快,所以可以在每次进行更改时运行。这确保了任何导致视图出错的代码更改都能被立即发现和修复。事情又开始运转了。项目的开发速度惊人,但代码质量依然很高。最重要的是,演示进行得非常顺利。

几周后,沃尔夫先生走进了他们的办公室。公司法务团队刚刚告知CEO,金字塔式销售计划是非法的,所以项目被取消了。“那关于那个大晋升呢?”长发问道。“什么大晋升?”沃尔夫先生回答道。“我不知道你在说什么。”然后他走出了办公室。

注意:有关沃尔夫先生的更多历史,请单击此处

© . All rights reserved.