乌龟和长发:一个寓言






4.61/5 (13投票s)
.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
的字符串。在这里,他猜测了一下,使用了“/”。
最后一个参数(实际上是第一个参数)稍微棘手一些。它接受一个名为hostType
的Type
(它可以是实现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,金字塔式销售计划是非法的,所以项目被取消了。“那关于那个大晋升呢?”长发问道。“什么大晋升?”沃尔夫先生回答道。“我不知道你在说什么。”然后他走出了办公室。
注意:有关沃尔夫先生的更多历史,请单击此处。