Obstacles on the way to TDD
Unfortunately it's very hard to apply TDD to ASP.NET WebForms as it is. WebForms was designed as a platform for Rapid Application Development. Features like wide set of rich controls and state management (view state) can significantly speed up development but in the same time WebForms does not provide decent separation of concerns. I think code-behind files were suppose to give us some separation between the HTML and application logic. And to be fair it's "some" separation, but we still need to deal in one code-behind file with ASP.NET components, which are tightly bound to the underlying HttpContext, presentation logic and usually a few other things. It's clearly too much. There are way too many concerns in one place, too many dependencies on ASP.NET runtime. It makes code in applications like this hard to maintain and also makes writing unit test impossible.
Another thing with which you have to cope with is EPiServer. To get an idea of problems which you may encounter check this unit tests:
1: [Test]
2: public void MyEPiServerUnitTest()
3: {
4: var page = new PageData();
5: page.Property.Add("MetaAuthor", new PropertyString { Value = "MetaAuthor123" });
6: page.Property.Add("Heading", new PropertyString { Value = "Heading123" });
7: page.Property.Add("MainBody", new PropertyXhtmlString { Value = "MainBody123" });
8:
9: Assert.IsTrue(page["MainBody"].Equals("MainBody123"));
10: }
System.ApplicationException: First time you call Settings.Instance you must have a valid HttpContext.
If you want to know who you should blame for calling Settings.Instance then check PropertyXhtmlString :)
As you may know, HttpContext can be mocked, it's not a problem. But in our case it won't help much, we won't get this exception of course, but we will get different one instead.
As you can see the way to TDD is not easy, EPiServer is also closely bound to HttpContext.
Deal with problems in two steps
Step 1 - Isolate as much functionality as you can away from ASP.NET runtime. How? Introduce design pattern called Model-View-Presenter (MVP). This pattern divides the responsibilities into separate classes:
- View - those are *.aspx and *.ascx files including code-behind files. This layer should be as thin as possible, code-behind files should delegate as much functionality as possible to the Presenter.
- Presenter is responsible for interface logic which includes initialization of view and dealing with all events. It's important that presenter shouldn't have any reference to System.Web namespace and should be separated from the concrete view with an interface. Additionally presenter has access to business layer and/or backend services.
- Model - the data on which application is working on, for EPiServer those might be pages, users, files, data stored in the HttpSession etc.
There are minor differences between both versions, you can learn more about both versions on Martin Fowler's site: Passive View and Supervising Controller.
Let me show you how it works in practice. I'm going to implement well known news page from EPiServer's public templates, the page for visitors looks like this:
Areas which are set by news page are marked with red rectangles. Other data comes from MasterPage. Below you can check how the aspx file:
1: <asp:Content ID="Content4" ContentPlaceHolderID="MainBodyRegion" runat="server">
2: <div id="MainBody">
3: <h1><asp:Literal ID="litHeading" runat="server" /></h1>
4: <asp:Literal ID="litMainBody" runat="server" />
5: </div>
6: </asp:Content>
7:
8:
9: <asp:Content ID="Content5" ContentPlaceHolderID="SecondaryBodyRegion" runat="server">
10: <div id="SecondaryBody">
11: <dl>
12: <dt><EPiServer:Translate ID="author" runat="server" Text="/news/writername" /></dt>
13: <dd><asp:Literal ID="litMetaAuthor" runat="server" /></dd>
14: <dt><EPiServer:Translate ID="Translate1" runat="server" Text="/news/publishdate" /></dt>
15: <dd><asp:Literal ID="litPageStartPublish" runat="server" /></dd>
16: </dl>
17: </div>
18: </asp:Content>
As you can see, so far nothing surprising. But now, instead of jumping to the code-behind file, you should create an interface for the view. I'm going to call my interface INewsView and this is how it looks like:
1: public interface INewsView
2: {
3: String Heading { get; set; }
4: String MainBody { get; set; }
5: String MetaAuthor { get; set; }
6: String PageStartPublish { get; set; }
7: }
Having the interface ready we can start working on the presenter, the simplest implementation looks like this:
1: public class NewsPresenter : AbstractPresenterBase<INewsView>
2: {
3: public override void Initialize()
4: {
5: View.MetaAuthor = PresenterUtils.GetPropertyValue(CurrentPage, "MetaAuthor", "");
6: View.PageStartPublish = PresenterUtils.GetProperty<DateTime>(CurrentPage, "PageStartPublish", "",
7: x => x.ToShortDateString());
8:
9: View.Heading = PresenterUtils.GetPropertyValue(CurrentPage, "Heading", "");
10: View.MainBody = PresenterUtils.GetPropertyValue(CurrentPage, "MainBody", "");
11: }
12: }
Now we have NewsPresenter ready we can piece it all together. The only missing part is implementation of INewsView interface:
1: public partial class NewsItem : SimpleMVPPageBase<NewsPresenter, INewsView>, INewsView
2: {
3: protected override INewsView ViewImplementation
4: {
5: get { return this; }
6: }
7:
8: public String MetaAuthor
9: {
10: get { return litMetaAuthor.Text; }
11: set { litMetaAuthor.Text = value; }
12: }
13:
14: public String PageStartPublish
15: {
16: get { return litPageStartPublish.Text; }
17: set { litPageStartPublish.Text = value; }
18: }
19:
20: public string Heading
21: {
22: get { return litHeading.Text; }
23: set { litHeading.Text = value; }
24: }
25:
26: public string MainBody
27: {
28: get { return litMainBody.Text; }
29: set { litMainBody.Text = value; }
30: }
31: }
What you can't see in this class is how the presenter is instantiated, when the view implementation is passed to the presenter, this is wrapped in SimpleMVPPageBase class:
1: public abstract class SimpleMVPPageBase<PresenterClass, ViewInterface> : SimplePage
2: where PresenterClass : AbstractPresenterBase<ViewInterface>, new()
3: {
4: protected PresenterClass Presenter;
5: protected abstract ViewInterface ViewImplementation { get; }
6:
7: protected override sealed void OnInit(EventArgs e)
8: {
9: base.OnInit(e);
10: Presenter = new PresenterClass
11: {
12: View = ViewImplementation,
13: CurrentPage = CurrentPage
14: };
15: }
16:
17: protected override sealed void OnLoad(EventArgs e)
18: {
19: base.OnLoad(e);
20: if (!IsPostBack)
21: {
22: Presenter.Initialize();
23: DataBind();
24: }
25: }
26: }
SimpleMVPPageBase class is responsible for two things:
- It instantiates presenter class, and sets for it CurrentPage and view implementation.
- Also, within OnLoad, it calls Presenter.Initialize() method which should initialize the view.
1: public class Checking_news_view_initialization : AbstractPresenterTest<NewsPresenter>
2: {
3: protected override void Before_each_test()
4: {
5: Presenter.CurrentPage = EPiServerPage.CreateBlankPage()
6: .AddProperty<PropertyString>("MetaAuthor", "author123")
7: .AddProperty<PropertyString>("Heading", "heading123")
8: .AddProperty<PropertyXhtmlString>("MainBody", "mainBody123")
9: .AddProperty<PropertyDate>(EPiProperties.PageStartPublish, DateTime.Today)
10: .Instance();
11:
12: Presenter.View = mocks.StrictMock<INewsView>(null);
13: }
14:
15: [Test]
16: public void It_should_set_all_properties()
17: {
18: using (mocks.Record())
19: {
20: Expect.Call(Presenter.View.Heading).SetPropertyWithArgument("heading123");
21: Expect.Call(Presenter.View.MainBody).SetPropertyWithArgument("mainBody123");
22: Expect.Call(Presenter.View.MetaAuthor).SetPropertyWithArgument("author123");
23: Expect.Call(Presenter.View.PageStartPublish).SetPropertyWithArgument(DateTime.Today.ToShortDateString());
24: }
25:
26: using (mocks.Playback())
27: {
28: Presenter.Initialize();
29: }
30: }
31: }
Within Before_each_test() method I'm basically creating an instance of PageData with a few properties. I'm also mocking the view using Rhino Mocks. Thanks to that in It_should_set_all_properties() method I can record my expectations and later verify them.
But wait a second ... as I was writing earlier, EPiServer requires valid HttpContext right?
Workaround for this issue is a step 2 on our way to TDD:
1: [TestFixture]
2: public abstract class AbstractPresenterTest<PresenterClass> where PresenterClass : new()
3: {
4: protected PresenterClass Presenter { get; private set; }
5: protected MockRepository mocks { get; private set; }
6:
7:
8: protected AbstractPresenterTest()
9: {
10: Presenter = new PresenterClass();
11: }
12:
13: [TestFixtureSetUp]
14: protected void SetUpContextForAWholeFixture()
15: {
16: var settings = new EPiServer.Configuration.Settings
17: {
18: StringCompressionThreshold = 0
19: };
20:
21: Type settingsType = settings.GetType();
22: settingsType.GetField("_instance", BindingFlags.Static | BindingFlags.NonPublic)
23: .SetValue(settings, settings);
24: }
25:
26: [SetUp]
27: protected void SetUpContextForEachTest()
28: {
29: mocks = new MockRepository();
30: Before_each_test();
31: }
32:
33: protected abstract void Before_each_test();
34: }
HttpContext related exception won't be triggered if you create instance of Settings and using reflection assign it to the static and private field _instance on Settings class. This way you can also provide your configuration for tests.
Conclusions
- the most important one is that Test Driven Development with EPiServer is possible!
- you need to isolate as much functionality from ASP.NET runtime and MVP design patter is doing exactly that for you
- with a little bit of hacking of EPiServer you can instantiate PageData and test your logic
- MVP requires additional effort to create interfaces, presenters etc. Downside is that you won't be able to build web applications so rapidly but it should pay off in longer and more complex projects where having a set of automated tests is a big advantage.
- My research and presentation were inspired by Raymond Lewallen who was a guest of Poznan .Net Group and was presenting Behavioral Driven Development approach.
- I have used image from Microsoft site talking about MVP.