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

In part 1, I gave an overview of the pattern. In this part, I will describe some of the core classes. Let's start off with the PageModel itself. It's the most simple. It is an abstract class. Subclasses must implement OnLoaded. It has an associated IPageContext (see below) that the model will use to interface with the page.

     public abstract class PageModel
    {
        /// <summary>
        /// The context associated with the model.
        /// </summary>
        protected IPageContext PageContext
        {
            get { return _pageContext; }
        }

        /// <summary>
        /// Called to load the page model.
        /// </summary>
        /// <param name="pageContext">The page context.</param>
        public void Load(IPageContext pageContext)
        {
            _pageContext = pageContext;
            OnLoaded();
        }

        /// <summary>
        /// Override to provide behavior for the model.
        /// </summary>
        protected abstract void OnLoaded();

        private IPageContext _pageContext;
    }

The IPageContext will allow the model to get some Asp.net specific information about the page and to do some Asp.net specific operations. When unit testing, a mock implementation of IPageContext can be used to test the model in isolation. IPageContext looks like this:

     public interface IPageContext
    {
        /// <summary>
        /// Gets if the connection is secure.
        /// </summary>
        bool IsSecureConnection { get; }

        /// <summary>
        /// Gets if this is a post.
        /// </summary>
        bool IsPost { get; }

        /// <summary>
        /// Throws a page not found exception.
        /// </summary>
        void ThrowPageNotFound();

        /// <summary>
        /// Throws a server error.
        /// </summary>
        void ThrowServerError();

        /// <summary>
        /// Redirects to another page. Note: Execution continues after this call.
        /// </summary>
        /// <param name="destination">Page to redirect to.</param>
        void Redirect(string destination);

        /// <summary>
        /// Has a redirect taken place?
        /// </summary>
        bool HasRedirected { get; }
    }

Note: This interface can be extended as needed. The interface here is a simple starting point.

Now, let's look at PageModelBasedPage, which is a base class for pages that will use PageModels. It's a generic class, parametized by the PageModel subclass. Subclasses of PageModelBasedPage must implement CreateModel to actually create the model. When the page is loaded, it will create the model with an AspPageContext. If it's a post, it will call a function CollectPostData() which can be overridden to collect post data. Finally, when the page load process is finished, it will do data binding on the page, unless there's been a redirect, in which case it will hide the controls on the page to avoid any unnecessary rendering.

     public abstract class PageModelBasedPage<ModelType> : Page where ModelType : PageModel
    {
        protected PageModelBasedPage()
        {
            this.LoadComplete += OnLoad;
            this.PreRenderComplete += OnPreRenderComplete;
        }

        /// <summary>
        /// Derived class must implement CreateModel and return the model.
        /// </summary>
        /// <returns>PageModel subclass.</returns>
        protected abstract ModelType CreateModel();

        /// <summary>
        /// Base classes can override for any special data binding.
        /// </summary>
        protected virtual void DoDataBinding()
        {
            if (_pageContext.HasRedirected)
            {
                // No need to render the page on a redirect.
                this.Visible = false;
            }
            else
            {
                this.DataBind();
            }
        }

        /// <summary>
        /// Derived class can do any manipulation of the Form values and set properties
        /// on the PageModel after a post.
        /// </summary>
        protected virtual void CollectPostData()
        {
        }

        /// <summary>
        /// The current page model for the page.
        /// </summary>
        protected ModelType PageModel
        {
            get { return _model; }
        }

        /// <summary>
        /// Called by Asp.net when the page loads.
        /// </summary>
        private void OnLoad(object sender, EventArgs e)
        {
            _model = CreateModel();
            _pageContext = new AspPageContext(this);

            if (_pageContext.IsPost)
            {
                // WARNING: You may be vulerable to cross site request forgery attacks
                // if you do not implement some sort of canary. For more info, see:
                // https://en.wikipedia.org/wiki/Csrf
                CollectPostData();
            }

            _model.Load(_pageContext);
        }

        /// <summary>
        /// Called by Asp.net when page loading is complete (including any asynchronous
        /// tasks)
        /// </summary>
        private void OnPreRenderComplete(object sender, EventArgs e)
        {
            DoDataBinding();
        }

        private ModelType _model;
        private AspPageContext _pageContext;
    }

As mentioned in the code, as written, this code is vulnerable to cross site request forgery attacks. The solution to this issue will depend on your application.

By using OnPreRenderComplete, our pattern is compatible with Asp.net's asynchronous pattern. I can write more about that if there is interest.

AspPageContext, the IPageContext used by PageModelBasedPage is as follows:

     public class AspPageContext : IPageContext
    {
        public AspPageContext(Page page)
        {
            _page = page;
        }

        public bool IsSecureConnection
        {
            get { return _page.Request.IsSecureConnection; }
        }

        public bool IsPost
        {
            get { return _page.IsPostBack; }
        }

        public void ThrowPageNotFound()
        {
            throw new HttpException(404, "Page not found.");
        }

        public void ThrowServerError()
        {
            throw new HttpException(500, "Server error.");
        }

        public void Redirect(string destination)
        {
            _page.Response.Redirect(destination);
            _hasRedirected = true;
        }

        public bool HasRedirected
        {
            get { return _hasRedirected; }
        }

        private Page _page;
        private bool _hasRedirected;
    }

That's it for the basics! To use the pattern, you need to:

  1. Create a PageModel subclass with the business logic for the page.
  2. Derive your Page from PageModelBasedPage.
  3. Populate any data in the model at creation time or in CollectPostData.
  4. Use databinding on the page to bind the results to the UI.

The next part will contain a full example.