Introduction
Visual Studio 2008 comes with rich Web Testing support, but it’s not rich enough to test highly dynamic AJAX websites where the page content is generated dynamically from database and the same page output changes very frequently based on some external data source e.g. RSS feed. Although you can use the Web Test Record feature to record some browser actions by running a real browser and then play it back. But if the page that you are testing changes everytime you visit the page, then your recorded tests no longer work as expected. The problem with recorded Web Test is that it stores the generated ASP.NET Control ID, Form field names inside the test. If the page is no longer producing the same ASP.NET Control ID or same Form fields, then the recorded test no longer works. A very simple example is in VS Web Test, you can say “click the button with ID ctrl00_UpdatePanel003_SubmitButton002”
, but you cannot say “click the 2nd Submit button inside the third UpdatePanel
”. Another key limitation is in Web Tests, you cannot address Controls using the Server side Control ID like “SubmitButton
”. You have to always use the generated Client ID which is something weird like “ctrl_00_SomeControl001_SubmitButton
”. Moreover, if you are making AJAX calls where certain call returns some JSON or updates some UpdatePanel
and then based on the server returned response, you want to make further AJAX calls or post the refreshed UpdatePanel
, then recorded tests don’t work properly. You *do* have the option to write the tests hand coded and write code to handle such scenario but it’s pretty difficult to write hand coded tests when you are using UpdatePanel
s because you have to keep track of the page viewstates, form hidden variables, etc. across async post backs. So, I have built a library that makes it significantly easier to test dynamic AJAX websites and UpdatePanel
rich web pages. There are several ExtractionRule
and ValidationRule
available in the library which makes testing Cookies, Response Headers, JSON output, discovering all UpdatePanel
in a page, finding controls in the response body, finding controls inside some UpdatePanel
all very easy.
What are the Capabilities of this Web Test Framework
First, let me give you an example of what can be tested using this library. My open source project Dropthings produces a Web 2.0 Start Page where the page is composed of widgets.
Each widget is composed of two UpdatePanel
s. There’s a header area in each widget which is one UpdatePanel
and the body area is another UpdatePanel
. Each widget is rendered from database using the unique ID of the widget row, which is an INT IDENTITY
. Every page has unique widgets, with unique ASP.NET Control ID. As a result, there’s no way you can record a test and play it back because none of the ASP.NET Control IDs are ever the same for the same page on different visits. This is where my library comes to the rescue.
See the web test I did:
This test simulates an anonymous user's first visit experience and some activity on the page. When anonymous user visits Dropthings for the first time, two pages are created with some default widgets. You can also add new widgets on the page, you can drag & drop widgets, you can delete a widget.
This Web Test simulates the following behaviors:
- Visit the homepage
- Show the widget list which is an
UpdatePanel
. It checks if the UpdatePanel
contains the BBC World widget.
- Then it clicks on the “Edit” link of the “How to of the day” widget which brings up some options dynamically inside an
UpdatePanel
. Then it tries to change the Dropdown value inside the UpdatePanel
to 10.
- Adds a new widget from the Widget List. Ensures that the
UpdatePanel
postback successfully renders the new widget.
- Deletes the newly added widget and ensures the widget is gone.
- Logs user out.
Let’s build the Web Test step by step. First you add a Web Test plug-in:
This ASPNETWebTestPlugin
is supplied with the library. It does a lot of work for you. I will explain it later. For now, keep in mind that it will handle all the post back, viewstate, UpdatePanel
issues for you.
Then you add a request to visit some page. Here I am visiting the Default.aspx.
In case you are new to Visual Studio Web Tests, the {{Config.TestParameters.ServerURL}}
is a configuration key where the Url of the website is set, e.g. http://localhost:8000. The configuration file is an XML file defined as:
<TestParameters>
<ServerURL>http://localhost</ServerURL>
<AnonCookieName>.DBANON</AnonCookieName>
<AuthCookieName>.DBAUTH12</AuthCookieName>
<SessionCookieName>ASP.NET_SessionId</SessionCookieName>
</TestParameters>
Once I hit the page, I ensure the following things:
- A persistent anonymous cookie is generated using the
CookieValidationRule
. It checks if the .DBANON cookie is generated by the ASP.NET AnonymousIdentficationProvider
. It also ensures there’s no authenticated cookie produced like the .DBAUTH12 cookie. These are ASP.NET Membership cookies. Here I am testing both positive and negative scenarios. Positive scenario is what is expected – the anonymous cookie to be generated. Negative scenario is what is not expected – some authenticated cookie for some user should not be there. Here’s how the first Rule is configured in the Properties window:
- Ensure there’s no cache header produced which will mistakenly cache the page on browser. We do not want the page to be cached anywhere. The
CacheHeaderValidation
class does this check.
- Then I am using a couple of
FindText
validation rules to ensure the generated page has the default widgets by looking for some texts like “How to of the day”, “All rights reserved”, etc. A good practice is to ensure some texts from header, body and footer area is tested to ensure the whole page content is at least delivered properly.
Now I am going to click on a link that will open an UpdatePanel
and check for certain content inside the UpdatePanel
. The following test clicks on the “Add Stuff” link that you see on Dropthings which shows a widget list inside an UpdatePanel
dynamically rendered from server.
The exciting thing here is the AsyncPostbackRequestPlugin
. This request plug-in is specifically made to handle asynchronous postback made inside UpdatePanel
. It has only two properties that you need to define, the name of the Control that needs to be clicked or posted back and the UpdatePanel
which contains the Control.
Here’s the amazing part – the name of the Control is the server-side name, the name you used in the .aspx or .ascx. You don’t need to specify the Client ID which can be in weird form like ctrl00_MasterPage_ContentHandler001_ShowAddContentPanel
. Similarly you can address the UpdatePanel
using the server-side name of the UpdatePanel
. The prefix $UPDATEPANEL
is used to identify UpdatePanel
, followed by the server-side name and then the index number or the nth UpdatePanel
number. Here I am using “.1
” because I want to hit the first UpdatePanel
which has server-side ID OnPageMenuUpdatePanel
.
This step clicks the control named “ShowAddContentPanel
” inside the UpdatePanel
named “OnPageMenuUpdatePanel
”.
Now we are going to do some complicated form post. We want to click some link that will produce some new controls inside an UpdatePanel
. Then we will modify those controls, click some button which will save the controls and refresh the UpdatePanel
with new value.
If you do not have Dropthings open in front of you, go open it. Then click on the “edit” link on the “How to of the Day” widget. You will notice it shows a Dropdown list that defines how many items from the RSS feed to show. By default, it’s set to 5
. I am going to change it to 10
and save the setting and see if the widget now shows 10
feed links.
First is to click the “edit” link which refreshes the UpdatePanel
to show the Feed Count dropdown list.
Here’s the first AsyncPostbackRequestPlugin
properties:
Since the “How to of the day” widget is the first widget on the page, I am using “.1
” everywhere. If you want to test the second widget, change it to “.2
”
Now the second request sets the value of the dropdown to 10
and then clicks the “Close edit” link to apply the changes. Once clicked, it will refresh the feed links and show 10 links. The way I verify whether there are really 10 links generated on the UpdatePanel
is by using the FindText ValidationRule
:
Here I am checking if the FeedLink_ctl09_FeedLink
link is there. If it’s there, then 10 links have been produced. Key here is to check for the ctl09
.
The rest of the steps in the Web Test follow the same principle. They click something, expect some output, use the output to post something back to the server again and then check if the post was successful or not.
Code Walkthrough
Time to walk you through all the classes that work behind-the-scenes to bring you the simplified Web Test framework. First is the ASPNETWebTestPlugin
.
public class ASPNETWebTestPlugin : WebTestPlugin
{
#region Fields
public const string STEP_NO_KEY = "$WEBTEST.StepNo";
private const string TEMPORARY_STORE_EXTRACTION_RULE_KEY = "$TEMP.ExtractionRules";
#endregion Fields
#region Methods
public override void PostRequest(object sender, PostRequestEventArgs e)
{
base.PostRequest(sender, e);
int stepNo = (int)e.WebTest.Context[STEP_NO_KEY];
e.WebTest.Context[STEP_NO_KEY] = stepNo + 1;
foreach (Cookie cookie in e.Response.Cookies)
{
if (cookie.Domain.StartsWith("."))
{
CookieContainer container = e.WebTest.Context.CookieContainer;
cookie.Domain = cookie.Domain.TrimStart('.');
container.Add(cookie);
}
}
}
public override void PreRequest(object sender, PreRequestEventArgs e)
{
base.PreRequest(sender, e);
e.Request.ExtractValues += new EventHandler<ExtractionEventArgs>(
Request_ExtractValues);
if (!e.WebTest.Context.ContainsKey(STEP_NO_KEY))
e.WebTest.Context[STEP_NO_KEY] = 1;
}
void Request_ExtractValues(object sender, ExtractionEventArgs e)
{
RuleHelper.WhenAspNetResponse(e.Response, () =>
{
RuleHelper.NotAlreadyExtracted<ExtractHiddenFields>(
e.Request.ExtractionRuleReferences,
() =>
{
var extractionRule = new ExtractHiddenFields();
extractionRule.Required = true;
extractionRule.HtmlDecode = true;
extractionRule.ContextParameterName = "1";
extractionRule.Extract(sender, e);
});
RuleHelper.NotAlreadyExtracted<ExtractFormElements>(
e.Request.ExtractionRuleReferences,
() => new ExtractFormElements().Extract(sender, e));
RuleHelper.NotAlreadyExtracted<ExtractPostbackNames>(
e.Request.ExtractionRuleReferences,
() => new ExtractPostbackNames().Extract(sender, e));
RuleHelper.NotAlreadyExtracted<ExtractUpdatePanels>(
e.Request.ExtractionRuleReferences,
() => new ExtractUpdatePanels().Extract(sender, e));
});
}
The PreRequest
method introduces a handy StepNo
in the Context
which makes it easy to identify at which step certain request failed. You can then map it with the Web Test requests and see exactly which step failed. This is handy when you have several same type of requests but can’t easily figure out which one failed. For example, you are hitting default.aspx on many places and one failed. From the step count, you can see which one failed:
Then on the Request_ExtractValues
, it extracts the following items:
- Extracts all hidden fields e.g.
__VIEWSTATE
.
- Extracts all form elements like
INPUT
and SELECT
tags and their selected value.
- Finds all
__doPostBack
calls in buttons, links that can result in a postback or async-postback
- Finds all
UpdatePanel
names.
First step is to extract the hidden fields. It uses Microsoft.VisualStudio.TestTools.WebTesting.Rules.ExtractHiddenFields
to extract the hidden fields. Nothing special here. This class creates entries like:
The next step is to use my own class ExtractFormElements
to extract all input, select tags and find their value.
[DisplayName("Extract Form Elements")]
public class ExtractFormElements : ExtractionRule
{
#region Fields
public const string FORM_ELEMENT_KEYS = "$FORM_ELEMENTS";
public const string INPUT_PREFIX = "$INPUT.";
public const string SELECT_PREFIX = "$SELECT.";
public const string VALUE_SUFFIX = ".VALUE";
private static Regex _FindInputTags = new Regex(
@"<(input)\s*[^>]*(name)=""(?<name>([^""]*))""\s*[^>]*(value)="""
+ @"(?<value>([^""]*))""",
RegexOptions.IgnoreCase
| RegexOptions.Multiline
| RegexOptions.IgnorePatternWhitespace
| RegexOptions.Compiled
);
private static Regex _FindSelectTags = new Regex(
@"<select\s*[^>]*name=""(?<name>([^""]*))""\s*[^>]*.*<option\s"
+ @"*[^>]*selected=""[^""]*""\s*[^>]*value=""(?<value>([^""]*))""",
RegexOptions.IgnoreCase
| RegexOptions.Singleline
| RegexOptions.IgnorePatternWhitespace
| RegexOptions.Compiled
);
#endregion Fields
#region Methods
public override void Extract(object sender, ExtractionEventArgs e)
{
string body = e.Response.BodyString;
List<string> formElements = new List<string>();
var processMatches = new Action<MatchCollection, string>((matches, prefix) =>
{
foreach (Match match in matches)
{
string name = match.Groups["name"].Value;
string value = match.Groups["value"].Value;
string lastPartOfName = name.Substring(name.LastIndexOf('$') + 1);
string keyName = RuleHelper.PlaceUniqueItem(e.WebTest.Context,
prefix + lastPartOfName, name);
e.WebTest.Context[keyName + VALUE_SUFFIX] = value;
e.WebTest.Context[name] = value;
formElements.Add(name);
}
});
processMatches(_FindInputTags.Matches(body), INPUT_PREFIX);
processMatches(_FindSelectTags.Matches(body), SELECT_PREFIX);
e.WebTest.Context[FORM_ELEMENT_KEYS] = formElements.ToArray();
}
#endregion Methods
}
This results in the following Context entries which you can use in the web test:
These are all INPUT buttons that have the same server-side name but ASP.NET generated unique ID for them. So, you can get both the unique ID and the value using the server-side name that you have used in your code.
Next step is to extract all the postback links, buttons, dropdown changes, etc. Anything that calls the ASP.NET’s __doPostback
function to postback to server. We need to know this in order to get the name of the control that needs to be posted back. This helps us simulate clicks on links, buttons.
[DisplayName("Extract PostBack Names")]
public class ExtractPostbackNames : ExtractionRule
{
#region Fields
private static Regex _FindPostbackNames = new Regex(@"__doPostBack\('(.*?)'",
RegexOptions.Compiled | RegexOptions.Multiline | RegexOptions.IgnoreCase
| RegexOptions.CultureInvariant);
#endregion Fields
#region Methods
public static void ExtractPostBackNames(string bodyHtml, WebTestContext context)
{
RuleHelper.NotAlreadyDone(context, "$POSTBACK.EXTRACTED", () =>
{
var matches = _FindPostbackNames.Matches(bodyHtml);
foreach (Match match in matches)
{
string fullID = match.Groups[1].Value;
string lastPartOfID = fullID.Substring(fullID.LastIndexOf('$') + 1);
string contextKeyName = "$POSTBACK." + lastPartOfID;
RuleHelper.PlaceUniqueItem(context, contextKeyName, fullID);
}
});
}
public override void Extract(object sender, ExtractionEventArgs e)
{
string bodyHtml = e.Response.BodyString;
ExtractPostBackNames(bodyHtml, e.WebTest.Context);
}
#endregion Methods
}
Finally, the most complicated ones, finding all the UpdatePanel
on the page and the controls that belong to the UpdatePanel
. We need to know the UpdatePanel
before simulating click on any control because we need to send the UpdatePanel
name to the server so that it knows which updatepanel
is posted back and needs to be refreshed.
public class ExtractUpdatePanels : ExtractionRule
{
#region Fields
public const string UPDATEPANEL_EXTRACTED_KEY = "$UPDATEPANEL.EXTRACTED";
public const string UPDATE_PANEL_COUNT_KEY = UPDATE_PANEL_PREFIX + ".COUNT";
public const string UPDATE_PANEL_DECLARATION =
"Sys.WebForms.PageRequestManager.getInstance()._updateControls([";
public const string UPDATE_PANEL_KEY = "$UPDATEPANEL";
public const string UPDATE_PANEL_POS_KEY = ".$POS";
public const string UPDATE_PANEL_PREFIX = UPDATE_PANEL_KEY + ".";
private static Regex _FindUpdatePanelRegex = new Regex(
@"\|updatePanel\|(?<name>(.*?))\|",
RegexOptions.IgnoreCase
| RegexOptions.Multiline
| RegexOptions.IgnorePatternWhitespace
| RegexOptions.Compiled
);
#endregion Fields
#region Methods
public static void ExtractUpdatePanelNamesFromHtml(string body, WebTestContext context)
{
RuleHelper.NotAlreadyDone(context, UPDATEPANEL_EXTRACTED_KEY, () =>
{
int pos = body.IndexOf(UPDATE_PANEL_DECLARATION);
if (pos > 0)
{
pos += UPDATE_PANEL_DECLARATION.Length;
int endPos = body.IndexOf(']', pos);
string updatePanelNamesDelimited = body.Substring(pos, endPos - pos);
string[] updatePanelNames = updatePanelNamesDelimited.Split(',');
int updatePanelCounter = 1;
foreach (string updatePanelName in updatePanelNames)
{
string updatePanelFullId =
updatePanelName.TrimStart('\'').TrimEnd('\'').TrimStart('t');
string updatePanelIdLastPart =
updatePanelFullId.Substring(updatePanelFullId.LastIndexOf('$') + 1);
string contextKeyName = UPDATE_PANEL_PREFIX + updatePanelIdLastPart;
string keyName =
RuleHelper.PlaceUniqueItem(context, contextKeyName, updatePanelFullId);
context[UPDATE_PANEL_PREFIX + updatePanelCounter] = updatePanelFullId;
string updatePanelDivId = updatePanelFullId.Replace('$', '_');
string lookingFor = "<div id=\"" + updatePanelDivId + "\"";
int updatePanelDivIdPos = body.IndexOf(lookingFor);
context[UPDATE_PANEL_PREFIX + updatePanelCounter +
UPDATE_PANEL_POS_KEY] = updatePanelDivIdPos;
context[keyName + UPDATE_PANEL_POS_KEY] = updatePanelDivIdPos;
updatePanelCounter++;
}
context[UPDATE_PANEL_COUNT_KEY] = updatePanelCounter;
}
});
}
public override void Extract(object sender, ExtractionEventArgs e)
{
string body = e.Response.BodyString;
if (e.Response.ContentType.Contains("text/html"))
ExtractUpdatePanelNamesFromHtml(body, e.WebTest.Context);
else ExtractUpdatePanelNamesFromAsyncPostback(body, e.WebTest.Context);
}
private void ExtractUpdatePanelNamesFromAsyncPostback
(string body, WebTestContext context)
{
RuleHelper.NotAlreadyDone(context, "$UPDATEPANEL.EXTRACTED", () =>
{
int newUpdatePanelsAdded = 0;
foreach (Match match in _FindUpdatePanelRegex.Matches(body))
{
string updatePanelDivID = match.Groups["name"].Value;
string updatePanelFullId = updatePanelDivID.Replace('_', '$');
string updatePanelIdLastPart =
updatePanelFullId.Substring(updatePanelFullId.LastIndexOf('$') + 1);
string contextKeyName = UPDATE_PANEL_PREFIX + updatePanelIdLastPart;
string keyName = RuleHelper.PlaceUniqueItem
(context, contextKeyName, updatePanelFullId);
int countOfKeys = context.Count;
RuleHelper.PlaceUniqueItem(context, UPDATE_PANEL_KEY, updatePanelFullId);
if (context.Count > countOfKeys)
newUpdatePanelsAdded++;
}
context[UPDATE_PANEL_COUNT_KEY] =
((int)context[UPDATE_PANEL_COUNT_KEY]) + newUpdatePanelsAdded;
});
}
#endregion Methods
}
This is a complex class. It finds the position of each UpdatePanel div
from the HTML output and the text output from async-postback. Regular postback output is HTML and easy to parse. But output from an async-postback is in text format and in a special format that needs different parsing logic.
This results in following entries in Context:
This helps you identify UpdatePanel
from server-side name. For example, you can find the first WidgetBodyUpdatePanel
using $UPDATEPANEL.WidgetBodyUpdatePanel.1
.
That’s all about the ASPNETWebTestPlugin
. This plugin intercepts every request and response and prepares the Context with useful entries which you can use to prepare your test steps.
Next important plugin is the request specific plugin – AsyncPostbackRequestPlugin
. You already know how to use it, now see how it does its job:
public class AsyncPostbackRequestPlugin : WebTestRequestPlugin
{
#region Properties
public string ControlName
{
get; set;
}
public string UpdatePanelName
{
get; set;
}
#endregion Properties
#region Methods
public override void PostRequest(object sender, PostRequestEventArgs e)
{
base.PostRequest(sender, e);
}
public override void PreRequest(object sender, PreRequestEventArgs e)
{
base.PreRequest(sender, e);
e.Request.Headers.Add("x-microsoftajax", "Delta=true");
FormPostHttpBody formBody = e.Request.Body as FormPostHttpBody;
if (null == formBody)
{
formBody = (e.Request.Body = new FormPostHttpBody()) as FormPostHttpBody;
e.Request.Method = "POST";
}
string controlName = RuleHelper.ResolveContextValue(e.WebTest.Context,
this.ControlName);
string updatePanelName = RuleHelper.ResolveContextValue(e.WebTest.Context,
this.UpdatePanelName);
string[] hiddenFieldKeyNames = e.WebTest.Context["$HIDDEN1"] as string[];
if (null != hiddenFieldKeyNames)
{
foreach (string hiddenFieldKeyName in hiddenFieldKeyNames)
formBody.FormPostParameters.Add(hiddenFieldKeyName,
e.WebTest.Context["$HIDDEN1." + hiddenFieldKeyName] as string);
RuleHelper.SetParameter(formBody.FormPostParameters, "ScriptManager1",
updatePanelName + "|" + controlName, true);
RuleHelper.SetParameter(formBody.FormPostParameters, "__EVENTTARGET",
controlName, true);
RuleHelper.SetParameter(formBody.FormPostParameters, "__ASYNCPOST",
"true", true);
}
}
#endregion Methods
}
First it sets the proper request headers for async postback. Then it collects all the hidden fields and stores in the FormPostParameters
collection. Then it adds the three key entries that identifies which UpdatePanel
to use, which control is being posted back and identify the postback as async postback.
There you have it, a super convenient way to test AJAX websites that is reusable, works even if the control IDs are auto generated or moved somewhere else, works over async postback, allows you to write tests with very little plumbing.
There are couple of other handy extraction rules and request plugins that you can use. Check out the code documentation to find out how they work.
Source Code
The source code of this project is available in the Dropthings codebase. Check out the Dropthings.Test
project.
Conclusion
This Web Test library will greatly simplify your automated Web Test. The benefit of using automated Web Test is that, you don't have to use some browser based recording tools which can only playback using a browser. Web Tests are fully executable without a browser and you can make Web Tests part of your Load Test. As a result, you write Web Test once, and then you can perform Load Tests using the same Web Tests. This is the single most important reason I use Web Tests instead of browser based tools like Selenium because I can do Load Test using the same Web Tests. But without this library, it's a super pain to write useful Web Tests. So, hope this library helps you write great Web Tests.