Create A/B Tests with ASP.NET MVC Core 1.0 TagHelpers

May 18th, 2016

A/B Tests are critical for marketing to determine the best approach for proven call-to-actions. In this post, I show a creative way to build a tag-based A/B test using TagHelpers.

When ASP.NET MVC TagHelpers were released, I was skeptical about whether I would like them or not.

I decided to give them a try by writing a couple "smart link" TagHelpers: Scheduled Links and Smart Links.

After building these, I realized TagHelpers can be quite the...err...helpers for developers.

When you create a TagHelper, you are giving users a tag-based system using HTML to further their understanding of the technology world through the HTML. Basically, if you have a CMS, your users can include a tag into their content and the TagHelper would render the output.

So I decided to brainstorm a bit and come up with an interesting way to use TagHelpers for users.

Enter A/B Tests

An A/B test is a way for marketers to test multiple call-to-actions and see which one converts better. After it's done running through the test period, the totals are calculated and the winner is the one with the highest conversion rate. That call-to-action is then set as the default for the page.

I thought "why not make A/B tests easy for a marketer?" Heck, they already know HTML.

Here is what a sample A/B looks like:

Views/Home/Index.cshtml

@using System.Threading.Tasks
@using ABTagHelperDemo.Models
@model ABTagHelperDemo.Models.IndexViewModel
@{
    ViewData["Title"] = "Home Page";
}
 
<h2>A/B Test for Push Buttons</h2>
 
@using (Html.BeginForm("Index", "Home", FormMethod.Post))
{
    <abtest name="ButtonTest" test-expires="2016-05-31">
        <baseline id="signUpButton">
            <button class="btn btn-primary btn-lg">Sign In</button>
        </baseline>
        <option id="LoginButton">
            <button class="btn btn-primary btn-lg">Log in</button>
        </option>
        <option id="freeButton">
            <button class="btn btn-primary btn-lg">Get Free Stuff</button>
        </option>
    </abtest>
 
}

What we are testing here is based on three buttons. Which text will influence our reader to push the button? Of course, this is a simplistic A/B test, but you can even change the colors of the button to see if colors influence the reader.

Our A/B Test is given a name (ButtonTest) and an expiration (test-expires).

Inside the <abtest> tag, we have our options. The baseline tag is the default HTML we display if our A/B test expires. It's called a baseline because this is what always displayed when we didn't have an A/B test in place.

The other two options are meant as an "experiment."

More of a "What if?"

The idea here is "The Sign In button is what the user always sees. If we give them a different option, would they be influenced more by different text?"

If we make the A/B test in plain HTML, this makes an A/B test look even simpler to a marketer.

Building the Test

First, we need our A/B Test business object. This stores our options and how we choose to display them in the browser.

Models\AbTest.cs

public class AbTest
{
    public AbTest()
    {
        Baseline = new AbTestOption();
        Options = new List<AbTestOption>();
    }
 
    public int Id { get; set; }
    public string Name { get; set; }
    public string Expires { get; set; }
    public AbTestOption Baseline { get; set; }
    public List<AbTestOption> Options { get; set; }
}

Very simple. We have a Baseline and a list of options along with the name of the Test and when it expires.

We need Options!

Our AbTestOption class requires a little bit more.

Models\AbTestOption.cs

public class AbTestOption
{
    public AbTestOption()
    {
        Impressions = 0;
        Clicks = 0;
    }
 
    public int Id { get; set; }
 
    public string ItemName { get; set; }
 
    [NotMapped]
    public bool IsBaseLine { get; set; }
 
    [NotMapped]
    public HtmlString Html { get; set; }
    
    // Calculatey-markety stuff
    public int Impressions { get; set; }
    public int Clicks { get; set; }
 
    [NotMapped]
    public float Conversion { get; set; }
}

Before we go any further, let's chat about this class for a minute.

We want to make our business objects very simple to work with when we build our AbTestTagHelper.

We Forgot Something!

When we display an option in the browser and the user refreshes the web page, we need to make sure they don't see a different option every time they refresh.

We need to initialize a cookie when they first visit the page and save the option displayed from the A/B test in a cookie.

After every refresh, we check to see if the cookie is there. If it is, read it from the cookie and display the option.

Well, back to our AbTest class.

Let's create a method to get an option based on an MVC ViewContext.

Models/AbTest.cs (GetOption method)

public AbTestOption GetOption(ViewContext viewContext)
{
    var context = viewContext.HttpContext;
 
    // Pick a number...any number.
    var picker = GetRandomOption();
 
    // Do we have an existing cookie?
    var cookies = context.Request.Cookies;
    var connection = context.Connection;
 
    var parameter = GetAbParameter(cookies, connection, picker);
 
    var testExpires = GetExpiration();
 
    // First, check to see if we are past our expiration.
    // If we are, just use the baseline item.
    var expired = testExpires <= DateTime.UtcNow;
    if (expired)
    {
        parameter.Item = 0;
    }
 
    // If we can't find the cookie and it's not expired 
    //   yet, save the cookie.
    if (String.IsNullOrEmpty(cookies[Name]))
    {
        var items = new Dictionary<string, string>
        {
            {"IP", parameter.IpAddress},
            {"Port", parameter.Port.ToString()},
            {"Item", parameter.Item.ToString()}
        };
        var result = String.Join("|", items.Values);
 
        context.Response.Cookies.Append(Name,
            result,
            new CookieOptions
            {
                Expires = testExpires
            });
    }
 
    return parameter.Item == 0 ? Baseline : Options.ElementAt(parameter.Item - 1);
}

Once we have the ViewContext passed in, we get a random number between 0 and Options.Count+1 (including the Baseline option).

The GetRandomOption and GetAbParameter are methods we'll get to in a minute.

The GetExpiration() method takes the string version of test-expires attribute and simply converts it into a DateTime so we can add it to our cookie later.

We also check to see if the expired time is less than the current time (meaning, yes, it expired). If it's expired, always set it to the baseline option so something always displays until we can get to it.

If we don't have a cookie stored, we build the parameters, attach them to our cookie named after our A/B test (Name), and set the expiration of the cookie.

Now, back to the other methods:

Once the parameters are set, we return the option to be displayed.

        private int GetRandomOption()
    {
        var random = new Random();
        var picker = random.Next(0, Options.Count + 1);
        return picker;
    }
 
    private DateTime GetExpiration()
    {
        DateTime testExpires;
        if (!DateTime.TryParse(Expires, out testExpires))
        {
            testExpires = DateTime.MinValue;
        }
 
        return testExpires;
    }
 
    private AbTestParameter GetAbParameter(IReadableStringCollection cookies, ConnectionInfo connection, int picker)
    {
        // Get the IPAddress and Port.
        var ip = connection.RemoteIpAddress;
        var port = connection.RemotePort;
        if (connection.IsLocal)
        {
            ip = connection.LocalIpAddress;
            port = connection.LocalPort;
        }
 
        // Split the A/B Values from the cookie.
        var splitValues = cookies[Name].ToString().Split('|');
 
        var cookieIp = String.Empty;
        var cookiePort = String.Empty;
        if (!String.IsNullOrEmpty(cookies[Name]))
        {
            cookieIp = String.IsNullOrEmpty(splitValues[0]) 
                ? String.Empty 
                : splitValues[0];
 
            cookiePort = String.IsNullOrEmpty(splitValues[1]) 
                ? String.Empty 
                : splitValues[1];
        }
 
        // create the parameter
        var abTestParameter = new AbTestParameter
        {
            IpAddress = ip.ToString(),
            Port = port,
            Item = picker
        };
 
        var item = picker;
        if (cookieIp == ip.ToString() && cookiePort == port.ToString())
        {
            if (!int.TryParse(splitValues[2], out item))
            {
                item = picker;
            }
        }
 
        abTestParameter.Item = item;
 
        return abTestParameter;
    }
}

Models/AbParameter.cs

public class AbTestParameter
{
    public string IpAddress { get; set; }
    public int Port { get; set; }
    public int Item { get; set; }
 
}

Finally! TagHelper Code!

Now that we have our A/B Test business object defined, we can build our ABTest TagHelper.

Helpers/ABTestTagHelper.cs

[HtmlTargetElement("abtest")]
public class AbTestTagHelper : TagHelper
{
    [HtmlAttributeNotBound]
    [ViewContext]
    public ViewContext ViewContext { get; set; }
 
    [HtmlAttributeName("name")]
    [Required]
    public string Name { get; set; }
 
    [HtmlAttributeName("test-expires")]
    public string Expires { get; set; }
 
    public override Task ProcessAsync(TagHelperContext context, 
        TagHelperOutput output)
    {
        var abTest = new AbTest
        {
            Name = this.Name,
            Expires = this.Expires
            
        };
        context.Items.Add(typeof(AbTest), abTest);
 
        var data = output.GetChildContentAsync();
 
        var chosen = abTest.GetOption(ViewContext);
        if (chosen != null)
        {
            output.TagName = String.Empty;
            output.PreContent.SetContent(String.Empty);
 
            output.Content.SetHtmlContent(chosen.Html.ToString());
 
            output.PostContent.SetContent(String.Empty);
 
            // Whatever option was chosen, we need to record
            // that it was delivered to the browser.
            chosen.Impressions++;
 
            // 1. Save the impressions
            // 2. Set the output.PostContent to:
            //    <input type="hidden" id="selected" value="@chosen.ItemName" />
            // On posting of the data, record the call-to-action item 
            // and save the results.
        }
 
        return base.ProcessAsync(context, output);
    }
}

At the top, we define our ABTest tag as abtest and attach our HtmlAttributeName Data Annotations to our properties, Name and Expires.

That is pretty standard for a TagHelper. However, there are some interesting aspects in this TagHelper.

One is the ViewContext property with a [ViewContext] data annotation on it. For TagHelpers in MVC Core 1.0, I was curious about how to get HttpContext information without an HttpContext.

Remember when Microsoft mentioned that Dependency Injection was included right out of the box?

I decided to do some research and found out that on Line 52 of the FormTagHelper in the MVC Source, you'll notice that same signature: ViewContext property with a ViewContext attribute/data annotation.

This is proof of how apparent dependency injection is in the new Core 1.0.

ViewContext was injected into the class specifically for the TagHelper

BOOM! <mic drop>

In addition, TagHelpers can pass additional information down to nested child TagHelpers using the Items property.

At the top of the method, we create our new AbTest object and add it to our items so the children can access it. Now that we have the Items populated, we need to get our child content using GetChildContentAsync().

Whoops! We need our Baseline and Option TagHelpers.

Define The Options

Our options include two TagHelpers: an AbBaselineTagHelper and an AbOptionTagHelper. These two will hold our data in each AbOption instance.

Helpers\AbBaselineTagHelper.cs

[HtmlTargetElement("baseline", Attributes = "id", ParentTag = "abtest")]
public class AbBaselineTagHelper : TagHelper
{
    public string Id { get; set; }
    public HtmlString Content { get; set; }
 
    public override Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
    {
        var childData = output.GetChildContentAsync();
        var content = childData.Result.GetContent();
 
        var abTest = (AbTest)context.Items[typeof(AbTest)];
        if (abTest != null)
        {
            abTest.Baseline = new AbTestOption
            {
                ItemName = Id,
                IsBaseLine = true,
                Html = new HtmlString(content),
                Conversion = 0
            };
        }
 
        output.SuppressOutput();
 
        return base.ProcessAsync(context, output);
    }
}

Helpers\AbOptionTagHelper.cs

[HtmlTargetElement("option", Attributes = "id", ParentTag = "abtest")]
public class AbOptionTagHelper : TagHelper
{
    public string Id { get; set; }
 
    public override Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
    {
        var childData = output.GetChildContentAsync();
        var content = childData.Result.GetContent();
 
        var abTest = (AbTest) context.Items[typeof(AbTest)];
        if (abTest != null)
        {
            abTest.Options.Add(new AbTestOption
            {
                ItemName = Id,
                IsBaseLine = false,
                Html = new HtmlString(content),
                Conversion = 0,
            });
        }
 
        output.SuppressOutput();
 
        return base.ProcessAsync(context, output);
    }
}

Both are obviously very similar, but the difference is the HTML target element attribute and the settings inside the creation of the AbTestOption.

If you notice we are calling the GetChildContentAsync() again.

Why? Because we need the HTML for each option. Each GetChildContentAsync triggers the children's ProcessAsync method.

Also, we are retrieving our business object (the AbTest object) from the context.Items collection.

Finally, we don't want this TagHelper to render our HTML so we suppress the output.

Now, back to our regularly scheduled AbTestTagHelper.

The Nail In The Coffin

Now that everything is in place, we use our AbTest object to get an option (it can be random or a set option because of the cookie, remember?).

var chosen = abTest.GetOption(ViewContext);

To replace the HTML, we set the TagName to String.Empty. From this point, we can output anything we want.

Our PreContent and PostContent will be empty, but our Content will be the Option's Html, the option we selected.

I added an additional line to keep track of impressions.

chosen.Impressions++;

You may notice the code comments as well and this is something that I leave open to the readers, but while I'm not Lucille Ball, "I got some 'splaining to do."

Point/Counterpoint

While this TagHelper works great, there are a couple points that I want to bring up to my readers.

Point 1 - Impressions

When a specific HTML option is selected and delivered to the browser, this is considered an impression for that option. Somehow, that needs to be recorded somewhere.

Now, in MVC Core, TagHelpers are cousins to HtmlHelpers. I'm not quite sure if they are meant to replace HtmlHelpers. What I've been wrestling with in this particular TagHelper is that one of my guidelines for HtmlHelpers is that you shouldn't include any database calls. Once the "boat has left the dock," you work with the data in the ViewModel given to you by the controller.

With that said, would there be a reason to include a database call to immediately record the impression for that selected option?

The counterpoint to that would be that Microsoft has said that TagHelpers are specifically server-side controls, similar to Web Controls back in the Web Form days.

While I've seen Web Controls make database calls, would this be the exception to the rule?

The only thing you would need to save is the option name that was delivered to the browser with the impression count.

Point 2 - Call-to-action results

The second point is recording the call-to-action result. While this simple experiment uses button clicks, you can easily use labels, colors, and other HTML assets to build your A/B tests.

However, you need a way to capture that result and record it somehow whether it be a button click or post back to the server.

When that call-to-action is executed (like a button click), you need to post the data and record the action to the table.

These are the next steps for my readers that I can't write into an article.

As I've mentioned before to developers, "I've built the chair for you to use, but how you use that chair is up to you."

Conclusion

This A/B TagHelper was something I feel portrays the full flexibility of TagHelpers in ASP.NET MVC Core 1.0 and how TagHelpers will make a tremendous impact on the MVC community.

Not only are TagHelpers completely programmable to create any type of HTML to assist with development (or business rules for that matter), but they can be used in CMS (Content Management Systems) tools and create a simple syntax for business users as well.

How easy would it be for a marketer to type in custom HTML like this A/B Test TagHelper, complete with options through attributes they can set for their testing?

Complete flexibility without a lot of rope for hanging themselves. :-)

What are your thoughts on the Point/Counterpoint issues? Was this a good TagHelper? Was it helpful? Post your comments below and let's discuss.