Udostępnij za pośrednictwem


A pattern for unit testable Asp.net pages: Part 3

In this post, I'm going to give an example of the pattern in action, built on the classes described in the last post. For the example, I've chosen something similar to the rename page on Live Folders. Here's how it should work:

  • The Url should be of the form https://<server>/rename.aspx?path=<path to item>.
  • If the item does not exist or the user does not have access, a 404 error should be returned.
  • If there is any error talking to the back end storage, a 500 error should be returned.
  • The page should have a text box for the name, prepopulated with the current item name.
  • There should be a submit button.
  • On submit, it should rename the item.
    • If successful, it should redirect to another page (in my example, I just redirect to the rename page with the new path)
    • If the new name has illegal characters, it should show an error inline.

Let's start by looking at the .aspx. Note: The page actually looks horrible. I made no attempt to make it look pretty!

 <%@ Page Language="C#" AutoEventWireup="true" CodeBehind="Rename.aspx.cs" Inherits="PageModelPattern.RenamePage" %>
<%@ Import Namespace="Microsoft.Security.Application" %>

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "https://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">

<html xmlns="https://www.w3.org/1999/xhtml" >
<head runat="server">
    <title>Rename</title>
</head>
<body>
    <form id="form1" runat="server">
        <asp:Panel runat="server" Visible="<%# !string.IsNullOrEmpty(this.PageModel.ErrorString) %>">
            <span style="color: Red"><%# this.PageModel.ErrorString %></span>
        </asp:Panel>
    
        <div>Name: <input name="name" value="<%# AntiXss.HtmlAttributeEncode(this.PageModel.Name) %>" /></div>
        <div><asp:Button runat="server" Text="Submit" /></div>
    </form>
</body>
</html>

Notice that the page uses the <%# %> binding syntax. For more information about this, see my previous post. Basically, the expression inside will be evaluated when the page is data bound. This happens after the model has been loaded by PageModelBasedPage. In these expressions, we can access public properties exposed by the mode. We use two properties here. The first it the error string. We have an <asp:Panel> that's visible when the error string is non empty or null. In that case, it will render as a div. There is also an <input> field for the name which is populated from this.PageModel.Name. Since the name will untrusted input, we need to escape it to avoid cross site scripting attacks. I chose to use the Microsoft AntiXss Library.

Now, let's look at the code behind.

     public partial class RenamePage : PageModelBasedPage<RenamePageModel>
    {
        /// <summary>
        /// Create a RenamePageModel.
        /// </summary>
        /// <returns>RenamePageModel.</returns>
        protected override RenamePageModel CreateModel()
        {
            return new RenamePageModel(new DataAccess(), this.Request.QueryString["path"]);
        }

        /// <summary>
        /// Collect data about a post back.
        /// </summary>
        protected override void CollectPostData()
        {
            base.CollectPostData();

            this.PageModel.Name = this.Request.Form["name"];
        }
    }

It's very simple, but that's the point! This is the code we can't unit test. CreateModel creates the RenamePageModel. I'll go into the DataAccess part in a moment. The other thing it passes in is the path, which it grabs from the query string. The other thing it does is populate the name property in the model on a postback from the form value.

To abstract the access to the data layer, I created a simple interface for the purpose of this demo:

     public interface IDataAccess
    {
        /// <summary>
        /// Get the name of an item from its path.
        /// </summary>
        /// <param name="path">The path to the item.</param>
        /// <returns>The item name.</returns>
        /// <exception cref="FileNotFoundExcetion">If the item does not exist.</exception>
        /// <exception cref="UnauthorizedAccessException">If the user does not have read access to the item.</exception>
        /// <exception cref="IOException">General i/o exception.</exception>
        string GetItemName(string path);

        /// <summary>
        /// Sets the name of an item.
        /// </summary>
        /// <param name="path">The path to the item.</param>
        /// <param name="newName">The new name.</param>
        /// <returns>The new path.</returns>
        /// <exception cref="FileNotFoundExcetion">If the item does not exist.</exception>
        /// <exception cref="ArgumentException">If the new name is invalid.</exception>
        /// <exception cref="UnauthorizedAccessException">If the user does not have write access to the item.</exception>
        /// <exception cref="IOException">General i/o exception.</exception>
        string SetItemName(string path, string newName);
    }

The functions should be pretty self explanatory based on the comments above. The most important part is the exceptions they can throw because the model depends on these. It's abstracted through an interface so that it can be mocked out when unit testing the page model. DataAccess is a placeholder implementation I created for this sample. I won't include the source for it here, but I'll include it in the project that I'll post when I finish the series.

Now, here's the page model. It has the real business logic that we'll be able to unit test:

     public class RenamePageModel : PageModel
    {
        public RenamePageModel(IDataAccess dataAccess, string path)
        {
            _dataAccess = dataAccess;
            _path = path;
        }

        /// <summary>
        /// Gets the name to display in the UI or sets the name to rename to.
        /// </summary>
        public string Name
        {
            get { return _nameToDisplay; }
            set { _newName = value; }
        }

        /// <summary>
        /// The error string to display (or null)
        /// </summary>
        public string ErrorString
        {
            get { return _errorString; }
        }

        /// <summary>
        /// Called when the page loads.
        /// </summary>
        protected override void OnLoaded()
        {
            if (this.PageContext.IsPost)
            {
                HandlePost();

            }
            else
            {
                try
                {
                    // Get the name of the item.
                    _originalName = _dataAccess.GetItemName(_path);
                }
                catch (FileNotFoundException)
                {
                    // The item does not exist.
                    this.PageContext.ThrowPageNotFound();
                }
                catch (UnauthorizedAccessException)
                {
                    // The user does not have access.
                    this.PageContext.ThrowPageNotFound();
                }
                catch (IOException)
                {
                    // Some other error.
                    this.PageContext.ThrowServerError();
                }

                _nameToDisplay = _originalName;
            }
        }

        /// <summary>
        /// Handle a post by renaming the item.
        /// </summary>
        private void HandlePost()
        {
            try
            {
                string newPath = _dataAccess.SetItemName(_path, _newName);
                // Redirect to next page - just back to the rename page with the new path
                // for the purpose of this example.
                this.PageContext.Redirect("rename.aspx?path=" + AntiXss.UrlEncode(newPath));
            }
            catch (FileNotFoundException)
            {
                // The item does not exist.
                this.PageContext.ThrowPageNotFound();
            }
            catch (UnauthorizedAccessException)
            {
                // The user does not have access.
                this.PageContext.ThrowPageNotFound();
            }
            catch (IOException)
            {
                // There was some other error.
                this.PageContext.ThrowServerError();
            }
            catch (ArgumentException)
            {
                // The new name has illegal characters.
                _errorString = "The name has illegal characters.";
                _nameToDisplay = _newName;
            }
        }

        private IDataAccess _dataAccess;
        private string _path;
        private string _newName;
        private string _originalName;
        private string _nameToDisplay;
        private string _errorString;
    }

Hopefully the code is pretty straightforward to read. It basically follows the requirements I outlined at the beginning of the post.

The result is that we have straightforward data binding in the .aspx and .aspx.cs which we can't unit test, and a model that only depends on IPageContext and IDataAccess which we can easily mock out for unit testing. The next post will wrap things up by showing some unit tests.

Comments

  • Anonymous
    July 28, 2007
    PingBack from http://mhinze.com/7-links-today-2007-07-28/

  • Anonymous
    July 31, 2007
    Please post the final article, I want to learn from this example. Thanks

  • Anonymous
    July 31, 2007
    Okay, now for unit tests of the code from part 3 . The goal is to completely cover RenamePageModel. First

  • Anonymous
    July 31, 2007
    I previously blogged about the pattern we used in our WPF application to separate business logic from

  • Anonymous
    July 31, 2007
    Okay, now for unit tests of the code from part 3 . The goal is to completely cover RenamePageModel. First