Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

The Tortoise and the Long Hair: A fable

0.00/5 (No votes)
28 Apr 2008 1  
A method for unit testing Views in the .NET MVC framework.

This is the story of the Tortoise and the Long Hair

Both Tortoise and Long Hair were developers at the same company, and were tasked with building a high profile application for the company. The application was supposed to be a sort of pyramid scheme for selling bricks that the CEO had thought up. He was quite sure it would drive the company's profits through the roof and revolutionize the brick selling industry.

Their boss, Mr. Wolf, sat them both down before the project got started to go over the details. Code quality and reliability were of utmost importance, so they would be expected to use the new ASP.NET MVC framework to enable extremely testable code.

"Lastly, I just wanted to let you both know that there's a huge promotion in this for one of you at the end of this project", explained Mr. Wolf, "which I fully expect to spur on a nice friendly rivalry between the two of you. Oh, and the other one of you will likely get canned."

So they both got to work right away. Things were going along well. They were making sure to thoroughly test all Model and Controller code. Like any project, the changes came pouring, but thanks to an ever growing suite of unit tests, the two developers weren't too worried. But then it came time to test the Views. Neither of them could find any examples of how to write a unit test for their View code. This seemed strange, so they decided to split up for the rest of the afternoon and see what they each could come up with.

The Tortoise started with the obvious, and wrote some code similar to the following:

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

The Tortoise sat back and looked at his masterpiece. He was clearly a superior coder. He set about writing up his findings for Mr. Wolf, but just before he clicked the "Submit" button, he thought to himself, "Maybe I should actually try running this code... just for good measure". Needless to say, he was sadly disappointed. It turned out that what he got back didn't include any of the HTML from the ASPX files. After further research, he learned that, at runtime, the ASP.NET framework dynamically created a new class that inherits from the code-behind file but also includes all the HTML from the ASPX file. That dynamic class is what actually handles the HTML rendering.

Long Hair, on the other hand, already knew something of this runtime compilation, and started with a bit more sophisticated solution. He decided to find the code ASP.NET uses to dynamically compile the page and use that code to render the view. Long Hair spent the next few hours Googling, looking at documentation, and decompiling code in the System.Web namespaces. Suddenly, there it was, HttpRuntime.ProcessRequest, and it was even a public method. Could such a thing be? Oh, the programming gods were clearly smiling on him this day. So he wrote some code:

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

Sadly, Long Hair's code failed too. He decompiled the source where the error occurred and took a look around. No problem. It was simply a configuration issue. He found the needed configuration, altered his code to set it to the required value, and tested again. Failure. No problem. Checked the decompiled source. Hmm, another configuration issue...

After repeating the above process some 30 times, Long Hair was starting to get the feeling that this wasn't a great solution. For one, his code was getting ugly. There were all sorts of crazy workarounds and hacks to set the needed configurations. Secondly, with all the configurations that needed to be made, who knows what state the application would be in by the time the test finished running. He started to wonder if getting his View test to pass wouldn't end up having unexpected side effects on the rest of the code.

The next day, the two developers came together with their boss and shared their findings. Mr. Wolf was furious. Why were they wasting there time goofing around instead of writing "real code", he demanded. After the meeting, both Tortoise and Long Hair were very discouraged. They cried on each other's shoulder for a few minutes and then got back to work.

Tortoise went online and found a few existing tools for testing web UIs. Some of them launched a browser and tested through its DOM, others sent an actual web request to a web server and parsed the response. All of them seemed promising, so Tortoise downloaded one and started working. Although he was able to quickly get a test up and running, it was really more of an integration test than a unit test. There was no way to test just the View without also running the Controller, the Model, and the database. Still, it was something. Best of all, it ran at Tortoise's favorite speed... veeeeeeeeeery sloooooooooooow.

Long Hair, on the other hand, felt sure he could get his original idea working. After some more sifting, he stumbled upon ApplicationHost.CreateApplicationHost. According to the documentation, this method "creates and configures an application domain for hosting ASP.NET". Not knowing too much about the AppDomain object, he pushed on and found that all .NET applications execute within the context of an AppDomain. However, an application can actually spin up multiple AppDomains. When this is done, each AppDomain has its own separate memory, loaded assemblies, and its own set of configurations.

This was exactly what was needed. ApplicationHost.CreateApplicationHost provided a simple way to set all of the needed configurations for ASP.NET to process a page, but it set them in a separate AppDomain. This meant that the configurations were completely isolated from the rest of his code, which alleviated the concern that changing a slew of configurations would have unintended side effects on other executing code. However, CreateApplicationHost was anything but intuitive at first glance. Digging in further, he found that CreateApplicationHost takes three arguments (which we'll discuss in reverse order).

The last argument is a string called physicalDir. This is the directory the newly created AppDomain will be running in (which becomes very important in just a second). Since Long Hair wanted to be able to test his web pages (i.e., Views), it seemed that the AppDomain really needed to be running at the root of the web directory containing those pages. Therefore, he used the path "C:\Inetpub\wwwroot\MVCTestWebApp".

The second argument is a string called virtualDir. Here, he took a wild guess and used "/".

The last argument (well, actually the first argument) was a little trickier. It takes a Type called hostType (which can be the Type for any object that implements MarshalByRefObject). The purpose of this argument wasn't immediately clear to him, but after further research, he found that code from one AppDomain can't directly call code in another AppDomain, which causes a problem. How was he supposed to execute any code in his beautiful new AppDomain? Indeed, what was the point of this new AppDomain if he couldn't do anything with it? This is where hostType comes in. When the new AppDomain is created, the first thing it does is create an instance of the Type passed in from the hostType argument. This class now exists in the new AppDomain. Next, a proxy to the newly created object is created in the original AppDomain. This proxy then uses Remoting to send method calls from the original AppDomain to the newly created AppDomain, thus solving the cross domain communication issue. So Long Hair created a proxy class:

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
    }
}

and then passed it to the CreateApplicationHost method:

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

Again, failure. For some reason, it couldn't find the assembly containing his CrossDomainProxy class. He stared blankly at the screen for a long, long time. How could it not find the assembly? It's the currently executing assembly! Long Hair began to hurl expletives at the screen, cursing the inferior developer who had created this CreateApplicationHost method.

Then all at once it hit him. The currently executing assembly was sitting in his current project folder, but the new ASP.NET AppDomain was executing at "C:\Inetpub\wwwroot\MVCTestWebApp". It was looking for the CrossDomainProxy assembly in the bin of his web app. Simple enough. He put the CrossDomainProxy assembly in the GAC, which allowed it to be found by any AppDomain, regardless of where it was executing. That done, Long Hair fired up his code again, and this time, a bit to his surprise, the code ran. Not only did it run, it ran fast. Really fast. "Eureka!", shouted Long Hair.

He was close now. This would work well for a traditional ASP.NET page, but it was still missing a critical piece for MVC Views. He still needed a way to set the ViewData property of the page before it was rendered. But how hard could that be? For now however, he was tired and his brain hurt from trying to grasp the concept of AppDomains, so he decided to call it a day.

Both Tortoise and Long Hair arrived at work the next day at the usual time, 10:00am, and walked into their office to find Mr. Wolf and a few other important looking men waiting for them. Apparently, Mr. Wolf had promised a demo, and the two were expected to show their progress.

Long Hair sheepishly explained that he didn't have anything he could show yet. At this, Mr. Wolf gave him a disapproving scowl as the important looking men whispered among themselves.

Tortoise, on the other hand, sensing the opportunity, quickly set about showing his work. However, his excitement quickly turned to horror as he immediately entered "demo hell". Nothing worked. All the pages that were working the day before were crashing today.

After the important looking men left and Mr. Wolf finished his 20 minute rant, the two developers regrouped. "What happened?" asked Long Hair. "Last night you said you had written tests for your UI." Tortoise frowned, "I have tests", he said. "The problem is they take so long to run that I don't run them every time I make a change."

So, while Tortoise set about fixing his broken views, Long Hair picked up where he left off. After a few more hours spent looking through the MVC class libraries, he updated his CrossDomainProxy class with the following code. He reasoned this should allow him to pass any object to the RenderView method, which would then be set as the ViewData of the page before it was rendered.

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);
}

The project already had a Person class in the Model, so he decided to pass this to his View. He then called his proxy class:

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

It failed again. On further investigation, it was yet another issue with cross AppDomain communication. He was trying to pass a reference to a Person object that existed in the original AppDomain to the ASP.NET AppDomain. Hmm, that would require any object being used as ViewData to be serializable, which didn't seem like a reasonable requirement. If only there was a way to pass a method to the new AppDomain that could be invoked to create the needed data in the correct domain. Maybe something almost like a delegate...

After a fair amount of fiddling and a bit of black magic, he came up with a solution. First, he created the following delegate:

public delegate object CreateViewData();

Then he created the following class to simplify interaction with the proxy:

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);
    }
}

Lastly, he added the following method to his CrossDomainProxy class:

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);
}

There's some crazy black magic here, so let me explain what's going on. The AspnetHost.RenderView method takes a CreateViewData delegate, but rather than passing the actual delegate to the proxy, it passes it the physical location of the assembly containing the method the delegate points to, the name of the Type containing the method, and the name of the method. CrossDomainProxy.RenderView then uses the physical path to load the assembly in the ASP.NET AppDomain. (Remember, each AppDomain has its own set of loaded assemblies. Having the assembly loaded in the original AppDomain doesn't mean it's loaded in the ASP.NET AppDomain.) Once the assembly is loaded, it then calls assembly.GetType to load the correct Type. Lastly, it calls type.GetMethod to get a reference to the method that will create our ViewData. This method can then be dynamically called using the methodInfo.Invoke method. Since this method was originally sent as a CreateViewData delegate, we know what the signature of this method will be. It will take zero arguments and return an object. This object is our ViewData. (Important note: For this to work, the delegate must point to a static method. Also, the ASP.NET AppDomain has its own memory, so any class data in the original AppDomain will not exist in the ASP.NET AppDomain.)

With everything in place, Long Hair was now able to write the following test:

[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 };
    }
}

At long last, success! They would now be able to unit test their Views. More importantly, the tests ran quite fast, so they could be run anytime a change was made. This ensured that any code change that caused a View to break was immediately found and fixed. Things were moving again. The project was being developed at a remarkable speed, but the code quality remained very high. Best of all, demos were going off without a hitch.

A few weeks later, Mr. Wolf came into their office. The company's legal team had just informed the CEO that pyramid schemes were illegal, so the project was being scrapped. "What about the big promotion?" asked Long Hair. "What big promotion?" responded Mr. Wolf. "I have no idea what you're talking about." And he walked out of the office.

Note: For more on the history of Mr. Wolf, click here.

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here