Unit Tests in ASP.NET WebForms

Unit tests


Unit tests, as you know, is a pill for the headache of a software developer. If used correctly and according to the instructions, then a healthy complexion and shine in the eyes are provided without stimulants. They say different things about unit tests: with aspiration those who read about them in the MSDN magazine, with admiration those who have already plunged into the world of beauty, and with regret those who by the will of fate are forced to work in an environment categorically unacceptable of this heavenly mana. The first I can only wish decisiveness, the second I applaud, but the third and this article is addressed.

Who is it for? About what?


This article is dedicated to C # / ASP.NET WebForms corporate site developers, just like me. People who, like me, have grown up to the fact that ASP.NET WebForms is cool, but not really. Not really, because web forms do not support unit tests, and therefore TDD. And the only practical way to write persistent code is by thoughtfully reading it. So I suffered for the past few years, developing existing asp.net projects, until finally I received my sight. Enlightenment will be discussed.


A bit of history


At the time of its formation, WebForms was intended for desktop VB programmers who had already mastered CommandButton, ComboBox, and Click event (don’t rush, I also started this way!), Who heard about the unprecedented heyday of the Internet (do you remember every dotom boom?) And decided to contribute to this business. For such, WebForms was invented, which fully transferred the concept of RAD (rapid application development) to the web: controls on the form, event handlers, and an optional bunch of whistles.

What is the problem?


And everything would be great, but suddenly, it turned out that in this form, it’s very easy for a developer to mix business logic with a presentation and get stuff that cannot be tested.
Those to whom the above touched the depths of programmer indignation, it is clear that the problem is in the environment. Not only is it difficult to design a reliable, extensible system, but ASP.NET also rests on all horns and hooves! Here's how to test ascx control if everything is hung on events? Hands all do not test.

How to be?


This is where I approach the topic of the article smoothly and gently.

First some foreplay

As I said above, one of the fundamental problems is mixing business logic and presentation together. It is necessary to part them in the corners. It is appropriate to use one of the varieties of MVC, MVP. Because in MVP, View controls the appearance and also serves user requests. The role of the Presenter is to prepare the data for the view, as well as communicate with the data repository for example. Model is a View Model, a container containing data to display a view. So, MVP fits perfectly into the ASP.NET working scheme: view is still the same user control, presenter, this is a class containing business logic, and the model is also a class containing data.

The laugh of work is this: view takes the model from the presenter then when it needs to, and also notifies it of the user’s input if it exists. Presenter, in turn, listens to view, other presenters and creates a model on demand, taking into account all the data received.

Start pulling towards the bedroom

Having understood the basic structure, you can start writing tests. It is clear that writing a unit test covering absolutely everything is impractical because such a test will be 2-3 times longer than the class it tests.
After a little thought, I wrote a list of possible component interactions:
  • Presenter domain (by domain I mean domain, i.e. repositories, data warehouses, etc.)
  • View-presenter (how view handles presenter methods)
  • User-view (which input comes from the user)

And accordingly, the following types of tests
  • The interaction of the presenter domain is covered by the unit test of the presenter.
  • The view-presenter interaction is covered by the view test unit.
  • And finally, user-view interaction is covered by an integration test (browser scripting).


In this article I’ll talk about the most difficult thing about the unit test view. In the following articles I will describe less complex tests.

Go!


So, as already mentioned, view is a normal ascx control. Such control should be hosted on an aspx page in asp.net runtime. To do this, you can use the method
System.Web.Hosting.ApplicationHost.CreateApplicationHost(Type hostType, string virtualDir, string physicalDir)


where hostType must be a type inheriting from MarshalByRefObject, and virtualDir and physicalDir must indicate the virtual path and physical path, respectively. CreateApplicationHost will return the host to which HTTP requests can be sent.

Now think about what we want to write in the unit test? Since this test covers the interaction between the view and the presenter, you need to write a list of possible scenarios and run the view on them while tracking what and how the presenter was transmitted. That is, from our unit test you need to be able to send requests, create a presenter, receive answers, as well as control what happened in the process of processing requests.

If it’s not difficult in principle with requests (for example, to simulate a postback, you just need to correctly compose the POST request parameters), then controlling the presenter is by no means trivial, because it lives on the other side of the AppDomain.

AppDomain is a bit of a process in the process. In other words, the .NET environment makes it possible to divide executable code in a system process into logical subprocesses that are isolated from each other. In our case, CreateApplicationHost creates a new host just in another AppDomain, and therefore we cannot directly address objects from the unit test, and accordingly we do not have any access to the presenter on the other side of the barrier.

Fortunately, there are proxy classes that can send requests to both sides using messages. You can read about proxies in Google (a request like .net remoting proxy). It’s important for us to know that proxies can call methods.

public class Proxy : MarshalByRefObject
{
    private readonly T _object;
    public Proxy(T obj)
    {
        _object = obj;
    }
    public TResult Invoke(Func selector)
    {
        return selector(_object);
    }
    public void Invoke(Action action)
    {
        action(_object);
    }
}


In addition, the question arises of how exactly we will monitor the interaction of the view-presenter. For simplicity, we can use any mocking framework that can track class calls. In this case, I chose Moq .

And the last one is a container for data transfer. We have a lot to pass on: data about mock-presenter, data about the environment, data about the request, and so on. Without further ado, we create the TestData class:

[Serializable]
public class TestData
{
    public Type MockPresenterType { get; set; }
    public Delegate MockPresenterInitializer { get; private set; }
    public void SetPresenterMockInitializer(Action> action) where TPresenter : class
    {
        this.MockPresenterInitializer = action;
        this.MockPresenterType = typeof(TPresenter);
    }
}


In light of the above, the unit test algorithm looks like this:

From the side of the unit test:
  1. Create and configure a host
  2. Create and configure an instance of the TestData class
  3. Send a request with the desired control and testData
  4. Process response
  5. Check mock-presenter settings

From the host:
  1. In Default.aspx OnPreInit load the required control
  2. Create the required mock-presenter
  3. Apply mock-presenter settings


And an approximate view of the unit test:


[TestMethod]
public void UnitTestView
{
    string physicalPath = Environment.CurrentDirectory.Substring(0, Environment.CurrentDirectory.IndexOf("bin"));
    TestHost host = (TestHost)ApplicationHost.CreateApplicationHost(typeof(TestHost), "/Testing", physicalDir);
    TestData testData = new TestData();
    testData.SetPresenterMockInitializer(
        mock =>
        {
            mock.Setup(m => ...);
        });
    host.AddQueryParameter("ControlToTest", "~/OurView.ascx");
    string response = host.ProcessRequest("Default.aspx", testData, null);
    XDocument doc = XDocument.Parse(response);
    host.GetPresenterMock().Invoke(mock => mock.Verify(m => m.SomeMethodRan(), Times.Once()));
}


From the host side, the Default.aspx.cs code looks very simple too:

public class Default : Page, IRequiresSessionState
{
    private readonly PlaceHolder _phContent = new PlaceHolder();
    protected override void FrameworkInitialize()
    {
        this.Controls.Add(new LiteralControl(@""));
        var form = new HtmlForm();
        this.Controls.Add(form);
        form.Controls.Add(_phContent);
        this.Controls.Add(new LiteralControl(@""));
    }
    protected override void OnPreInit(EventArgs e)
    {
        base.OnPreInit(e);
        var controlToTest = this.Request.QueryString["ControlToTest"];
        var testData = (TestData)this.Context.Items["TestData"];
        if (controlToTest != null)
        {
            Control c = this.LoadControl(controlToTest);
            Type viewType = get view type from that control
            if (viewType != null)
            {
                object presenter;
                if (testData.MockPresenterType != null)
                {
                    var mockType = typeof(Mock<>).MakeGenericType(testData.MockPresenterType);
                    object mock = Activator.CreateInstance(mockType);
                    AppDomain.CurrentDomain.SetData("PresenterMock", mock);
                    presenter = mockType.GetProperty("Object", testData.MockPresenterType).GetValue(mock, null);
        testData.MockPresenterInitializer.DynamicInvoke(mock);
        viewType.GetProperty("Presenter").SetValue(c, presenter, null);
        AppDomain.CurrentDomain.SetData("Presenter", presenter);
        _phContent.Controls.Add(c);
    }
}


Here I intentionally omitted the details of the viewType search because it is purely individual (besides, I did not describe how I implemented the MVP pattern). The key points are
  • Creating and adding control passed to QueryString
  • Create a MockPresenter and add it to AppDomain through AppDomain.SetData. Using AppDomain.GetData unit test will be able to access our MockPresenter
  • A call to DynamicInvoke which will apply the settings (mock => mock.Setup ...)


The companion TestHost class is shown below:

public class TestHost : MarshalByRefObject
{
    private readonly Dictionary _query = new Dictionary();
    public void AddQueryParameter(string key, string value)
    {
        _query.Add(key, HttpUtility.UrlEncode(value));
    }
    private string GetQueryParameters()
    {
        StringBuilder sb = new StringBuilder();
        foreach (var parameter in _query)
        {
            sb.AppendFormat("{0}={1}&", parameter.Key, parameter.Value);
        }
        return sb.ToString();
    }
    public string ProcessRequest(string page, TestData testData, string postData)
    {
        StringWriter writer = new StringWriter();
        TestRequest swr = new TestRequest(page, GetQueryParameters(), writer, testData, postData);
        HttpRuntime.ProcessRequest(swr);
        writer.Close();
        return writer.ToString();
    }
    public Proxy> GetPresenterMock() where TPresenter : class
    {
        object mock = AppDomain.CurrentDomain.GetData("PresenterMock");
        return new Proxy>((Mock)mock);
    }
}
public class TestRequest : SimpleWorkerRequest
{
    private readonly TestData _testData;
    private readonly byte[] _postData;
    private const string PostContentType = "application/x-www-form-urlencoded";
    public TestRequest(string page, string query, TextWriter output, TestData testData, string postData)
        : base(page, query, output)
    {
        _testData = testData;
        if (!string.IsNullOrEmpty(postData))
            _postData = Encoding.Default.GetBytes(postData);
    }
        public override void SetEndOfSendNotification(EndOfSendNotification callback, object extraData)
    {
        base.SetEndOfSendNotification(callback, extraData);
        HttpContext context = extraData as HttpContext;
        if (context != null)
        {
            context.Items.Add("TestData", _testData);
        }
    }
    public override string GetHttpVerbName()
    {
        if (_postData == null)
            return base.GetHttpVerbName();
        return "POST";
    }
    public override string GetKnownRequestHeader(int index)
    {
        if (index == HeaderContentLength)
        {
            if (_postData != null)
                return _postData.Length.ToString();
        }
        else if (index == HeaderContentType)
        {
            if (_postData != null)
                return PostContentType;
    }
        return  base.GetKnownRequestHeader (index);
    }
    public override byte[] GetPreloadedEntityBody()
    {
        if (_postData != null)
            return _postData;
        return base.GetPreloadedEntityBody();
    }
}


Smoke break



That's all, at this stage you already have the opportunity to write elegant unit tests (as much as possible) for asp.net webforms. Of course, if you have the opportunity to use another environment more friendly to unit tests, by all means, as my American colleagues say. But if it is not there, but you want to write more stable code, then now it is possible.

By the way, I have cited only the most necessary code for proof-of-concept. For example, I omitted the process of creating a postback and transferring test data, as well as preparing controls for the test. If there is interest, I can write about it, as well as about my implementation of MVP, and about the other 2 types of tests that I mentioned above.

Thanks for attention.

Also popular now: