Test-driven development leads to more reliable code, and anything that can be unit tested should be unit tested. Unfortunately, some frameworks, such as
ASP.NET, do not lend themselves to being mocked in order to support unit tests. This is a particular issue with HttpModules and HttpHandlers, which by
their nature must integrate heavily with difficult-to-mock ASP.NET objects that typically exist only within IIS. This article outlines one approach to
mocking ASP.NET and freeing your unit tests and your continuous integration server from dependencies on IIS and virtual directory configuration.
An HttpModule is usually designed to execute whenever a Web request begins, and complete its work before any HttpHandlers or ASPX pages begin executing.
It will nearly always need to look at one or more request headers and take action based on their values; thus, it cannot run without this information. In
order to unit test it, it is best if the modules that it depends on (chiefly the ASP.NET framework) can be mocked, so that test values can be passed to the
HttpModule within the process of a unit test framework such as NUnit and tests can be written to check its behavior with a wide variety of client requests.
However, it is nearly impossible to create valid well-behaved instances of HttpContext, HttpApplication, HttpRequest, or HttpResponse outside of an ASP.NET
application host. Fortunately, it is possible to create an ASP.NET application host, which can in turn create these ASP.NET objects for your tests.
The ASP.NET Application Host
An Application Host begins the entire ASP.NET request processing machinery. The Application Host lives in an AppDomain specially created for it, and it
creates an HttpWorkerRequest to encapsulate all of the details of getting request data from the client and sending response data back. The Application Host
then feeds the HttpWorkerRequest instance to
HttpRuntime.ProcessRequest, which creates an HttpContext and invokes your HttpModules and
Creating and using an Application Host
An Application Host must live in a specially configured AppDomain, and a single method is provided that creates the AppDomain, configures it for ASP.NET,
and creates the Application Host within that domain. These operations cannot be done individually.
The physical directory parameter must point to an ASP.NET application directory, the one that would be mapped to the corresponding virtual
directory in IIS. Within the newly-created AppDomain, assemblies will be looked up in the "bin" folder directly under the given physical directory.
ASP.NET will look for .aspx pages and other content relative to the given physical directory.
You are free to choose the virtual directory name; the
HttpRequest that gets created will include that virtual directory within the request URL that it reports.
The first parameter is a class that derives
from MarshalByRefObject. An instance of that class is created within the newly-created AppDomain, and a remote reference to that instance is returned from
the call to CreateApplicationHost.
Unit Testing with an Application Host
Since the Application Host must live in its own AppDomain which will look for assemblies within the bin folder of the ASP.NET application's physical
directory, the easiest way to get unit test code to run within that AppDomain is to put it inside of an ASP.NET project. So we'll create an ASP.NET project
called WebObjectTesting, and give that project a reference to nunit-framework.dll.
Now add a Test class to the project and decorate it with the
[TestFixture] attribute. Add an empty test method and compile the project.
Load WebObjectTesting.dll into NUnit and make sure that NUnit recognizes and successfully runs the test.
Testing the Application Host
Before we can test anything that runs inside an application host, we need to successfully get an application host running. The first step is to define
the class that will be instantiated within the Application Host AppDomain:
Since it lives in a separate AppDomain, it must extend MarshalByRefObject, and it will be called using .NET Remoting. This means that every
method you define on that class should have all of its parameter types and its return type be serializable.
It also means that NUnit cannot directly
interact with this instance; any unit test methods you define will have to run in the original AppDomain created by NUnit and interact with the TestDriver
class through .NET Remoting.
We'll start by giving TestDriver a simple
This simply sets up the worker request and calls
ProcessRequest with the given page and query string. To test it, we need to
add a test method to the Test class (not the TestDriver class) that calls it remotely:
Compile, load with NUnit, and run the test.
Figure 1: No failures and a red bar?
NUnit reports the test passing, and still gives us a red bar!
After the request is finished, NUnit unloads the AppDomain, but ASP.NET is not quite finished with it. To tell ASP.NET that all request processing is
done, you must call
HttpRuntime.Close() within the Application Host's App Domain. So let's give TestDriver another method to do just
And call it remotely after we're done with our test:
Recompile and try it with NUnit again:
Figure 2: Success
get our green bar.
Was the application processing successful?
However, just because we got a green bar doesn't mean that the whole thing went off without a hitch. Recall that when an exception occurs within
ASP.NET, the exception doesn't bubble all the way up and crash the Web server; instead, ASP.NET catches the exception and renders an error page with an HTTP
error response code. So in order to make sure that everything within ASP.NET ran successfully, we need to check the error code.
This is where Rhino
Mocks comes in. We can create a partial mock of the SimpleWorkerRequest and set expectations and explicit returns on certain method calls while allowing
other method calls to pass through to SimpleWorkerRequest. In this case, we know that ASP.NET will call
SendStatus on the worker request
object, and will pass it the HTTP status code. After adding a reference to Rhino Mocks, we simply set an expectation that that status code is equal to 200
(HTTP for "OK"), and the mock object will throw an exception if that expectation isn't met:
Now compile it and run NUnit:
Figure 3: Expected HTTP OK, got HTTP error!
The test is red! What went wrong?
One way to find out is to Debug->Attach to the NUnit process
(probably nunit-agent.exe, unless you've configured NUnit differently) and set a breakpoint right after ProcessRequest and look at the value of "sw".
Figure 4: Getting the page rendered by ASP.NET
It should give us the HTML text of the error page, which in this case tells us that Test.aspx doesn't
Figure 5: sw.ToString() run through Visual Studio's HTML visualizer
This gives us confidence that we're actually testing what
we think we're testing, and reminds us to create Test.aspx. Create Test.aspx, recompile, and run the test again. This time it should pass.
Adding an HttpModule
Now it's time to test an actual HttpModule. It needs to live in its own project so we can reference it from our unit test project and from the real
ASP.NET project that needs to use it. Create the MyHttpModule project as a Class Library.
In our example, the HttpModule will simply keep track of
how many requests the application has gotten from this particular client with the help of a cookie. If the client already has a RequestNumber cookie, it
will add one to the number and reset the cookie; otherwise it'll create a brand-new RequestNumber cookie with the value 1:
There's a bug in it, but we'll find and fix it later with the help of a unit test.
Now in the ASP.NET project, we need to add a
reference to the MyHttpModule project. Also, in order to actually use the HttpModule, we need to change the Web.config:
In IIS 7, the default is to run ASP.NET applications in the Integrated Pipeline. However, custom Application Hosts cannot use the
Integrated Pipeline; they can only run ASP.NET applications with the Classic Pipeline. The Web.config syntax for the Classic Pipeline is different from
that needed for the Integrated Pipeline. The example sets the same HttpModule for both pipelines.
Compile and run the unit test again. It should still be green.
Testing your HttpModule
At this point, the unit test tells us that ASP.NET didn't encounter any errors; it doesn't tell us that the HttpModule did the right thing, or that it
even executed. To check that, we need to add more expectations:
Here we make sure that the HttpModule executed and properly set the cookie in response to a request that didn't have the cookie. Compile it
and run NUnit.
Figure 6: Not quite working yet
Now it's red. The expectation wasn't met. What happened? What cookie value did we get?
A quick way to find out is to force
RhinoMock to call a custom delegate whenever
SendKnownResponseHeader is called with any other cookie value:
Now set a breakpoint, attach the debugger, and try the test again.
Figure 7: That's not supposed to be the cookie value!
The request number was set to two! How did that happen? Fortunately, we can set a
breakpoint in the HttpModule itself and try again:
Figure 8: Found it!
now see that in the case where the client doesn't have the cookie set, the HttpModule sets
requestNumber to one, and then adds one to it! It
needs to set requestNumber to zero instead. Fix the bug, recompile and run the test.
Figure 9: Bug is fixed
And back to green.
Now let's reorganize the test project so that we can quickly code multiple tests:
And extend our Go method so that it can optionally submit a request number cookie and check that we get the right cookie back:
Add NUnit test methods to call it twice, once with no cookie, and once with a RequestNumber cookie value. Compile and run the tests
Figure 10: Both tests passing
Following this pattern, you can unit test not only HttpModules, but any code that must run in ASP.NET. You can even unit-test existing code that's in
code-behind – and, with unit tests in place, refactor it without breaking it.
Sorry, no bio is available
This author has published 2 articles on DotNetSlackers. View other articles or the complete profile here.
Please login to rate or to leave a comment.