NUnit with ASP.NET Context
Recently a bug was found in one of our components. Okay, so finding a bug isn't unusual, however what made this one stand out was that the component in question had over 70% code coverage with NUnit tests and it still wasn't found. Turns out the bug was related to the very small percent of code that relies on ASP.NET Context. The code isn't covered by our NUnit tests because frankly it is really hard to deal with ASP.NET context through unit tests.
I decided I wanted a way to get better coverage. Specifically I wanted to execute the code dependent on ASP.NET context. A quick google search turned up a few examples of how other people have tackled this. I took some of the examples I found and extended them to meet my requirements.
Step 1 Create an AppDomain that can handle ASP.Net
The below code will create the appDomain. I placed this into a singleton. As I said I "borrowed" this code from someone's blog... and promptly forgot who's so if it is your code many thanks!
35 public object CreateApplicationHost(Type hostType, string virtualDir, string physicalDir)
36 {
37 if (!(physicalDir.EndsWith("\\")))
38 physicalDir = physicalDir + "\\";
39 string aspDir = HttpRuntime.AspInstallDirectory;
40 string domainId = DateTime.Now.ToString(DateTimeFormatInfo.InvariantInfo).GetHashCode().ToString("x");
41 string appName = (virtualDir + physicalDir).GetHashCode().ToString("x");
42 AppDomainSetup setup = new AppDomainSetup();
43
44 setup.ApplicationName = appName;
45 setup.ConfigurationFile = "web.config"; // not necessary except for debugging ///TODO: Never used not sure if this works
46 setup.ApplicationBase = physicalDir;
47 setup.DynamicBase = physicalDir;
48 setup.ShadowCopyFiles = "true";
49 AppDomain ad = AppDomain.CreateDomain(domainId, null, setup);
50 ad.SetData(".appDomain", "*");
51 ad.SetData(".appPath", physicalDir);
52
53 ad.SetData(".appVPath", virtualDir);
54 ad.SetData(".domainId", domainId);
55 ad.SetData(".hostingVirtualPath", virtualDir);
56 ad.SetData(".hostingInstallDir", aspDir);
57 ad.SetData(".relativeSearchpath", physicalDir);
58
59 ObjectHandle oh = ad.CreateInstance(hostType.Module.Assembly.FullName, hostType.FullName, true, BindingFlags.NonPublic | BindingFlags.Instance,null,null,null,null,null);
60
61 return oh.Unwrap();
62 }
Step 2 Execute a request
It turns out that this is harder than I thought it would be. What I ended up doing was creating a page on disk and then executing that .aspx page inside my new AppDomain. Now this whole process is rather ugly so I decided to wrap it all up in a neat little proxy
33 /// Usage:
34 /// To get the instance of the class call the static GetMethodProxy method. You must pass in the
35 /// typeof( class being tested ) and the exact string name of the method to be called:
36 /// ASPMethodProxy proxy = ASPMethodProxy.GetMethodProxy(typeof(CustomCache), "ClearCache");
37 /// The above method is going to be calling CustomCache.ClearCache(). In order to call a method you must first
38 /// pass in the text represenation of the method to the AddMethodString method:
39 /// proxy.AddMethodString("CustomCache.ClearCache();");
40 /// The above method is going to output into the aspx page the single method: CustomCache.ClearCache();
41 /// To execute the method you must call CallMethod()
42 /// bool results =proxy.CallMethod();
43 /// The above call actually creates the aspx page and executes it. The return value is true if the output HTML contain s
44 /// the expected marker value: Method Executed Correctly. Otherwise it will return false. The ASPMethodProxy
45 /// also exposes the results string to the caller for more detailed analysis if desired.
46 ///
47 /// The only "trick" to using this proxy is that the AddMethodString(xxx) has to be used correctly. Essentially all you
48 /// have to to is copy the existing method calls and convert them to string representation. The end page will look something like
49 /// this:
50 ///
51 ///
52 ///
53 ///
54 ///
55 ///
56 ///
57 ///protected void Page_Load(object o, EventArgs e)
58 ///{
59 ///try
60 ///{
61 /// CustomCache.ClearCache();
62 /// HttpContext.Current.Response.Output.WriteLine("Method Executed Correctly");
63 ///}
64 ///catch(Exception ex)
65 ///{
66 ///HttpContext.Current.Response.Output.WriteLine("Error! -- " + ex.Message + ""); }
67 ///HttpContext.Current.Response.Output.Flush();
68 ///}
69 ///
70 ///
71 ///
72 public class ASPMethodProxy:MarshalByRefObject
73 {
74 private MethodInfo _methodInfo;
75 private StringCollection _methodLines;
76 private AssemblyName [] _refNames;
77 private string _results;
78 private string _namespace;
79 #region Constructor
80 ///
81 /// Resets the _methodInfo upon instantiation
82 ///
83 protected ASPMethodProxy()
84 {
85 _methodInfo =null; //to ensure we throw an error from SetMethodInfo if we can't find the methodName
86 }
87 #endregion
88 #region Properties
89 #region ResultsString
90 ///
91 /// The results of a request to process a page
92 ///
93 public string ResultsString
94 {
95 get{ return _results;}
96 }
97 #endregion
98 #region CurrentMethodInf
99 ///
100 /// This is used to create the imports statements for the aspx page
101 ///
102 public MethodInfo CurrentMethodInfo
103 {
104 get
105 {
106 return _methodInfo;
107 }
108 set
109 {
110 _methodInfo = value;
111 _methodLines = new StringCollection();
112
113 }
114 }
115 #endregion
116 #endregion
117 #region SetMethodInfo
118 ///
119 /// Sets the method information in the instance class
120 ///
121 ///
122 ///
123 internal void SetMethodInfo(Type methodInfo, string methodName)
124 {
125 _refNames = methodInfo.Assembly.GetReferencedAssemblies();
126 _namespace = methodInfo.Namespace;
127 MethodInfo [] methods = methodInfo.GetMethods();
128 foreach(MethodInfo currMethod in methods)
129 {
130 if(currMethod.Name == methodName)
131 CurrentMethodInfo = currMethod;
132 }
133 if(CurrentMethodInfo==null)
134 throw new ArgumentOutOfRangeException("methodName", methodName, String.Format("Requested method name {1} doesn't exist in assembly of type {0}", methodInfo, methodName));
135
136 }
137 #endregion
138 #region CallMethod
139 ///
140 /// This will create the aspx page and then call the method defined by the methodLines collection
141 ///
142 /// bool - true if results contain expected value otherwise false
143 public bool CallMethod()
144 {
145 //clear results string
146 _results = string.Empty;
147 //delegate responsibility for the page creation to the CreatePage class
148 CreatePage.CreateNewPage(FixPageName(_methodInfo.Name), WriteMethodString(), WriteImportString());
149 _results = ProcessRequest(FixPageName(_methodInfo.Name));
150 ///if we get a compile error we will have Response.Write(... Method Executed Correctly...) in the results string so the <1000 handles that situation
151 if((_results.Length>0 && _results.IndexOf("Method Executed Correctly")>0) && _results.IndexOf("Method Executed Correctly")<1000)
152 return true;
153 else
154 return false;
155 }
156 #endregion
157 #region WriteMethods
158 #region WriteMethodString
159 ///
160 /// This will output the collection of method strings as a single string. Used
161 /// to create the function block on the ASPX page
162 ///
163 ///
164 public string WriteMethodString()
165 {
166 StringBuilder sb = new StringBuilder();
167 foreach(string line in _methodLines)
168 {
169 sb.Append(line);
170 sb.Append(Environment.NewLine);
171 }
172 return sb.ToString();
173
174 }
175 #endregion
176 #region WriteImportString
177 ///
178 /// This will output the collection of page import statements required by the
179 /// method assembly that will be called
180 ///
181 ///
182 public string WriteImportString()
183 {
184 StringCollection importString = CreateImportStringCollection();
185 StringBuilder sb = new StringBuilder();
186 foreach(string import in importString)
187 {
188 AddImportString(sb, import);
189 }
190 return sb.ToString();
191 }
192 #endregion
193 #region CreateImportStringCollection
194 ///
195 /// Creates a string collection of namespaces in the referenced
196 /// assemblies. Checks for duplicates and doesn't add them.
197 ///
198 ///
199 private StringCollection CreateImportStringCollection()
200 {
201 StringCollection importString = new StringCollection();
202 importString.Add(_namespace);
203 foreach(AssemblyName name in _refNames)
204 {
205 string fullName = name.FullName;
206 int comma = fullName.IndexOf(",");
207 if(comma>0 && fullName.IndexOf("mscorlib")==-1 && (!fullName.StartsWith("System")))
208 {
209
210 //Note that once an assembly is Loaded you can't unload it until the app domain is gone.
211 Assembly asm = Assembly.Load(fullName);
212 Module [] modules = asm.GetLoadedModules(false);
213 foreach(Type type in modules[0].GetTypes())
214 {
215 string importName= type.Namespace;
216
217 if(!importString.Contains(importName))
218 {
219 importString.Add(importName);
220 }
221 }
222 }
223 }
224 return importString;
225 }
226 #endregion
227 #region AddImportString
228 ///
229 /// This will add the actual import namespace string to the supplied stringbuilder
230 ///
231 ///
232 ///
233 private void AddImportString(StringBuilder sb, string import)
234 {
235 sb.Append("
236 sb.Append(import);
237 sb.Append("\" %>");
238 sb.Append(Environment.NewLine);
239
240 }
241 #endregion
242 #endregion
243 #region AddMethodString
244 ///
245 /// Adds the requested line to the method string collection.
246 ///
247 ///
248 public void AddMethodString(string method)
249 {
250 _methodLines.Add(method);
251 }
252 #endregion
253 #region FixPageName
254 ///
255 /// This will append .aspx to the name if it doesn't already exist
256 ///
257 ///
258 ///
259 private string FixPageName(string name)
260 {
261 if(name.IndexOf(".aspx")==-1)
262 name+=".aspx";
263
264 return name;
265 }
266 #endregion
267 #region ProcessRequest
268 ///
269 /// Setup the request to be processed. Makes sure we have a page name that is
270 /// formatted correctly.
271 ///
272 /// The string representation of the page
273 /// results string
274 private string ProcessRequest(String page)
275 {
276 page = FixPageName(page);
277 using(Stream returnStream = new MemoryStream())
278 {
279 using(StreamWriter output = new StreamWriter(returnStream))
280 {
281 HttpWorkerRequest hwr = new SimpleWorkerRequest(page, null, output);
282 HttpRuntime.ProcessRequest(hwr);
283 output.Flush();
284 return GetStringFromStream(returnStream);
285 }
286 }
287 }
288 #endregion
289 #region GetStringFromStream
290 ///
291 /// Reads the memorystream into a byte array and converts to a chararray
292 /// so we can return the string
293 ///
294 ///
295 ///
296 private string GetStringFromStream(Stream stream)
297 {
298 stream.Position=0;
299 byte[] byteArray = new byte[stream.Length];
300 int count = stream.Read(byteArray, 0, Convert.ToInt32(stream.Length));
301 return Encoding.Default.GetString(byteArray);
302
303 }
304 #endregion
305 #region ASPMethodProxyFactory
306 ///
307 /// Factory method that creates the app domain and returns the proxy class created inside that asp.net app domain
308 ///
309 /// type(somemethod) used to get references
310 /// string the method name to call
311 ///
312 public static ASPMethodProxy GetMethodProxy(Type method, string methodName)
313 {
314 ASPMethodProxy proxy = HostController.Instance.CreateApplicationHost(typeof(ASPMethodProxy),"/",Directory.GetCurrentDirectory()) as ASPMethodProxy;
315 if(proxy==null)
316 throw new Exception(String.Format("Unable to create the host object for {0} {1} {2}", typeof(ASPMethodProxy), "/", Directory.GetCurrentDirectory()));
317
318 proxy.SetMethodInfo(method,methodName);
319 return proxy;
320 }
321 #endregion
It turns out that creating the actual page was a little tricky as well. Ideally this should have been done using the CodeDom, but I never got around to refactoring it. When the page is created it is in the directory that the test assembly is currently in. ASP.NET will then probe for dependent assemblies in the /bin underneath that directory. The problem is that we don't have a /bin. There are a couple of ways to tackle this and I decided on the brute force approach. I create the /bin and then manually copy all the assemblies in the current directory to the bin thus ensuring that probing will find any dependencies. Not ideal, but it solved the issue.
15 internal class CreatePage
16 {
17 #region CreatePage
18 ///
19 /// Private constructor
20 ///
21 private CreatePage(){ }
22 #endregion
23 #region CreateNewPage
24 ///
25 /// Creates a new page and ensures that we have a bin directory. It also makes sure to move
26 /// over all dependent assemblies (just does a dumb copy)
27 ///
28 ///
29 ///
30 public static void CreateNewPage(string pageName, string method, string imports)
31 {
32 CreateDirectory();
33 MoveAssemblies();
34 ///create the HTML page
35 // Creates a text file to store the new aspx
36 StreamWriter streamWriter = File.CreateText(pageName);
37 streamWriter.WriteLine (imports);
38 streamWriter.WriteLine ("");
39 streamWriter.WriteLine (" ");
40 streamWriter.WriteLine (" ");
41 streamWriter.WriteLine ("protected void Page_Load(object o, EventArgs e)");
42 streamWriter.WriteLine ("{ try{ ");
43 streamWriter.WriteLine (method);
44 ///TODO: This could be enhanced to accept an expected value which could then be compared against the page results or the
45 ///call to the method. However that is beyond the scope of what I am currently doing
46 streamWriter.WriteLine ("HttpContext.Current.Response.Output.WriteLine(\"Method Executed Correctly\"); ");
47 streamWriter.WriteLine (" } catch(Exception ex) {");
48 streamWriter.WriteLine ("HttpContext.Current.Response.Output.WriteLine(\"Error! -- \" + ex.Message + \"\"); } ");
49 streamWriter.WriteLine ("HttpContext.Current.Response.Output.Flush(); ");
50 streamWriter.WriteLine ("} ");
51 streamWriter.WriteLine (" ");
52 streamWriter.WriteLine ("");
53 streamWriter.Close();
54 }
55 #endregion
56 #region CreateDirectory
57 ///
58 /// Creates the bin directory that must live under the execution path so that asp.net can find referenced components
59 ///
60 private static void CreateDirectory()
61 {
62 string directory = Directory.GetCurrentDirectory() + "\\bin";
63 if(!Directory.Exists(directory))
64 Directory.CreateDirectory(directory);
65 }
66 #endregion
67 #region MoveAssemblies
68 ///
69 /// Moves all the assemblies in the test directory to the sub \bin directory. This needs to be
70 /// done in order for the aspx to find the dependent assemblies
71 ///
72 [FileIOPermission(SecurityAction.Demand)]
73 private static void MoveAssemblies()
74 {
75 string [] files = Directory.GetFiles(Directory.GetCurrentDirectory(), "*.dll");
76 foreach(String currFile in files)
77 {
78 try
79 {
80 string newPath = Path.GetDirectoryName(currFile) + "\\bin\\" + Path.GetFileName(currFile);
81 //remove it in case it is old
82 if(File.Exists(newPath))
83 {
84 if(File.GetLastWriteTime(newPath) != File.GetLastWriteTime(currFile))
85 {
86 File.Delete(newPath);
87 }
88 }
89 if(!File.Exists(newPath))
90 File.Copy(currFile, newPath );
91
92 }
93 catch(Exception ex)
94 {
95 Console.WriteLine("Unable to copy file {0}", ex.Message);
96 }
97 }
98 }
99 #endregion
100 }
Step 3 Using our Proxy
The final step is calling our proxy. It turns out this is fairly easy, but it does require some thought. Below are a couple of examples. You notice that I don't actually check the response string for a specific value, which is probably necessary. All I do is check to see if the method in the created page was called without throwing an exception.
203 [Test]
204 public void TestClearCacheASPProxy()
205 {
206
207 Trace.WriteLine("Calling CustomCache.ClearCache()");
208 ASPMethodProxy proxy = ASPMethodProxy.GetMethodProxy(typeof(CustomCache), "ClearCache");
209 proxy.AddMethodString("CustomCache.ClearCache();");
210 bool results = proy.CallMethod();
211 Trace.WriteLine(proxy.ResultsString);
212 Trace.WriteLine("CustomCache.ClearCache() completed.");
213
214 }
The proxy class contains a ResultsString property which is the output of the aspx page execution. This result string could be used to determine validity of the test. One of the nice things about this is that you can actually step into the code that is running in the new AppDomain to verify that it is has ASP.NET context available. I haven't tried it against the Application object or any of the other intrinsic ASP.NET objects.