Friday, November 2, 2007

Record and playback state in ASP.NET

Most ASP.NET applications hold various state objects in Session, Application, or other stateful mediums.  For regression testing and defect reproducing purposes, often I want to capture the exact state of these objects and replay them back at a later time, without needing to walk through the exact steps needed to setup that state.  For many defects, I don't know how the state got to where it was, I just know what the state is, so I need a mechanism to record it and play it back.

I can record and playback the state by first capturing the state by downloading a serialized version of the object, and then play it back later by uploading the serialized object file and resuming the application.

Recording state

The basic principle for recording state is that I'd like to serialize one of my state objects and allow the client to download the serialized object as a file.  With direct access to the output stream of the HttpResponse, this turns out to be fairly trivial.

Setting up the state

First, we need some sort of state object.  I'm not too creative, so here's a Cart and Item object, optimized for XML serialization:

public class Item
{
    public Guid Id { get; set; }
    public string Name { get; set; }
    public int Quantity { get; set; }
    public decimal Price { get; set; }
}

public class Cart 
{
    public Guid Id { get; set; }
    public string CustomerName { get; set; }

    [XmlArray(ElementName = "Items"), XmlArrayItem(ElementName = "Item")]
    public Item[] Items { get; set; }
}

Normally I don't put read/write arrays, but to demonstrate the easiest possible serialization scenario, that will do.  For more difficult scenarios, I usually implement IXmlSerializable or provide a separate DTO class strictly for serialization concerns.

In my Default.aspx page, I'll provide a simple button to download the cart, and some code in the code-behind to create a dummy cart and respond to the button click event:

public partial class _Default : System.Web.UI.Page
{
    protected void Page_Load(object sender, EventArgs e)
    {
        Cart cart = new Cart();

        cart.Id = Guid.NewGuid();
        cart.CustomerName = "Bob Sacamano";
       
        Item item = new Item();
        item.Id = Guid.NewGuid();
        item.Name = "Toothpicks";
        item.Price = 100.50m;
        item.Quantity = 99;

        Item item2 = new Item();
        item2.Id = Guid.NewGuid();
        item2.Name = "Ceramic mug";
        item2.Price = 5.49m;
        item2.Quantity = 30;

        cart.Items = new Item[] { item, item2 };

        Session["Cart"] = cart;
    }

    protected void btnDownloadCart_Click(object sender, EventArgs e)
    {
        Response.Redirect("~/download.aspx");
    }
}

Now that I have a Cart object, I need to fill in the "download.aspx" part to allow downloads of the cart.

Outputting state to HttpResponse

The HttpResponse object has a couple of ways to access the raw byte stream sent to the client.  The first is the OutputStream property, which gives me access to a raw Stream object.  Streams are used by a multitude of objects to write raw bytes, such as serializers, etc.

I don't normally use it when I want the end-user to save the file.  If I'm creating a dynamic image or such, I can go directly against the OutputStream, but an alternate way is to use the HttpResponse.BinaryWrite method.  This will allow me to buffer content as needed, and more importantly, provide buffer size information to the end user.  That's especially important for the various browser "download file" dialogs.

Here's my Page_Load event handler for the "download.aspx" page:

   1:  protected void Page_Load(object sender, EventArgs e)
   2:  {
   3:      Cart cart = Session["Cart"] as Cart;
   4:   
   5:      if (cart == null)
   6:      {
   7:          Response.Write("cart is null");
   8:          Response.End();
   9:          return;
  10:      }
  11:   
  12:      try
  13:      {
  14:          MemoryStream ms = new MemoryStream();
  15:          XmlSerializer xmlSerializer = new XmlSerializer(typeof(Cart));
  16:   
  17:          XmlTextWriter outputXML = new XmlTextWriter(ms, Encoding.UTF8);
  18:          xmlSerializer.Serialize(outputXML, cart);
  19:          outputXML.Flush();
  20:          ms.Seek(0, SeekOrigin.Begin);
  21:   
  22:          byte[] output = ms.ToArray();
  23:          outputXML.Close();
  24:          ms.Close();
  25:   
  26:          Response.ContentType = "application-octetstream";
  27:          Response.Cache.SetCacheability(HttpCacheability.NoCache);
  28:          Response.AppendHeader("Content-Length", output.Length.ToString());
  29:          Response.AppendHeader("Content-Disposition", "attachment; filename=cart.xml; size=" + output.Length);
  30:          Response.AppendHeader("pragma", "no-cache");
  31:          Response.BinaryWrite(output);
  32:          Response.Flush();
  33:      }
  34:      catch (Exception ex)
  35:      {
  36:          Response.Clear();
  37:          Response.ContentType = "text/html";
  38:          Response.Write(ex.ToString());
  39:      }
  40:  }

In the first section through line 10, I retrieve the state object (cart) from Session and make sure it actually exists.  If it doesn't, I'll just send an error message back to the client.

In lines 14 through 24, I create the XmlSerializer to serialize the "cart" variable, and call Serialize to serialize the cart out to the MemoryStream object.  MemoryStream provides an in-memory Stream object that doesn't require you to write to files, etc. for stream actions.  Instead, it basically uses an internal byte array to store stream data.

I could have passed the HttpResponse.OutputStream directly to the XmlTextWriter's constructor, but I would lose the ability to buffer or give size information back to the client, so I just dump it all into a byte array.

Finally, in lines 26 through 32, I set up the Response object appropriately and call BinaryWrite to write the raw byte array out to the client.  I disable caching as I want the client to bypass any server or client cache and always download the latest Cart.  Also, I set the appropriate headers to direct the client to download instead of just view the XML.  If I didn't include the "Content-Disposition" header, most likely the client would simply view the XML in their browser.  That's done through the value "attachment".

I can also control what filename gets created on the client side with the "filename" value in the "Content-Disposition" header.  Without that, a dummy or blank filename would be shown.  Here's what it looks like after I click the "Download cart" button:

Notice that both the filename and file size are populated correctly.  I can save this serialized Cart object anywhere I like now as a perfect snapshot of our state object, ready to be played back.  Also, the "cart.xml" file doesn't exist anywhere on the server.  It only exists in server memory and is streamed directly to the client.

Playing it back

Now that I have a page that allows me to download my Cart from Session, I need a page where I can upload it back up and continue through the application.  This can be accomplished through the FileUpload control and some de-serialization magic.

Uploading the saved state

First, I'll need to add a couple of controls to my "Replay.aspx" page to handle the file upload and to initiate the replay:

<asp:fileupload id="fuCart" runat="server" />
<br />
<asp:button id="btnReplay" onclick="btnReplay_Click" runat="Server" text="Replay" />

In the click handler, I need to read the file from the FileUpload control and deserialize that into a Cart object:

protected void btnReplay_Click(object sender, EventArgs e)
{
    XmlSerializer xmlSerializer = new XmlSerializer(typeof(Cart));
    Cart cart = (Cart)xmlSerializer.Deserialize(fuCart.PostedFile.InputStream);
    Session["Cart"] = cart;

    Response.Redirect("~/ShoppingCart.aspx");
}

Once the cart is downloaded and deserialized, I set the Session variable to the new cart instance and proceed directly to the ShoppingCart.aspx page.  I can verify that my Cart object has the exact same state as the one I saved earlier by examining it in the debugger:

I can see all of the IDs are correct, the number of items are correct, and all of the data values match the original cart saved out.  When I proceed to the cart page, my state will be exactly where it was when I originally downloaded the cart.

Conclusion

If one of our QA people encountered a defect, they can now save off their state and attach it to our defect tracking system, allowing us to replay exactly what their state was and reproduce the defect.  Record/playback is also perfect for regression test suites, which sometimes need to rely on specific inputs (instead of workflows) to reproduce defects.  Combining this ability with WatiN or Selenium can give me a very powerful testing tool.

When defects are found, we can download the state of the user when replaying their workflow is difficult, impossible, or we just don't know what happened.  Through replaying a user's exact state, we can quickly determine root causes for failures and create regression tests to ensure the defects do not surface again.

Just make sure you take care to secure the "record" and "playback" pages appropriately.  You probably won't want that available to customers.

No comments: