Monday, March 08, 2010

Maintainable MVC Series: Poor man’s RenderAction

This article is part of the Maintainable MVC Series.



Soon - once that MVC 2 is finalized and released - we will have access to the new Html.Action command with which we can easily render partials for shared functionality, like navigation structure or other parts of the master page that need dynamic data. As Phil Haack shows in this post it's a very powerful and urgently needed feature.

In the master view you will have a call like this:



[csharp light="true"]

<%= Html.Action("Menu") %>

[/csharp]

This will directly call a method in one of your controllers to supply the partial view with its data:



[csharp]
[ChildActionOnly]
public ActionResult Menu() {

NavigationData navigationData = navigationService.GetNavigationData();
MenuViewModel menuViewModel = mapper.GetMenuViewModel(navigationData);

return PartialView(menuViewModel);
}
[/csharp]

Unfortunately in MVC 1 we have to make use of a more elaborate trick to get the data to the partial view.




In the earlier post about view hierarchy I showed you what our master view looks like and how partial views are included:



[csharp light="true"]

<% Html.RenderPartial("Menu", ViewData.Eval("DataForMenu")); %>

[/csharp]

The attribute



But how do we make sure the data is present in ViewData? We do this through the use of attributes. For each partial there is an attribute that puts the view data into ViewData.



[csharp highlight="8"]
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method,
AllowMultiple = false)]
public sealed class DataForMenuAttribute : ActionFilterAttribute
{
public override void OnActionExecuting(ActionExecutingContext filterContext)
{
// Work only for GET request
if (filterContext.RequestContext.HttpContext.Request.RequestType != "GET")
return;

// Do not work with AjaxRequests
if (filterContext.RequestContext.HttpContext.Request.IsAjaxRequest())
return;

NavigationData navigationData = navigationService.GetNavigationData();
MenuViewModel menuViewModel = mapper.GetMenuViewModel(navigationData);

filterContext.Controller.ViewData["DataForMenu"] = menuViewModel;
}
}
[/csharp]

This attribute sets the data for the partial view. As you can see from the highlighted line we don't set data for POST requests. To make this possible we have to make sure we never render views for POST requests. We do this by upholding the Post-Redirect-Get pattern. More on that in a future part.



Of course, we have to make sure that every controller that makes use of the master view, which includes the Menu partial view, has this attribute set.



[csharp]
[DataForMenu]
public HomeController : Controller
{
[/csharp]

The trouble with attributes in MVC is that you have no control over the creation of their instances. This makes it hard to use dependency injection, but not completely impossible. We have to make sure there's a parameterless constructor for MVC to instantiate them, but we can make an overloaded constructor, that is called from the parameterless one to make use of dependency injection:



[csharp highlight="7,8"]
public sealed class DataForMenuAttribute : ActionFilterAttribute
{
private readonly INavigationService navigationService;
private readonly IViewModelMapper mapper;

public DataForMenuAttribute() :
this(ObjectFactory.GetInstance<INavigationService>(),
ObjectFactory.GetInstance<IViewModelMapper>())
{}

public DataForMenuAttribute(INavigationService navigationService,
IViewModelMapper mapper)
{
this.navigationService = navigationService;
this.mapper = mapper;
}
[/csharp]

Until now we succeeded to limit the use of StructureMap to only one line in the ControllerFactory, however attributes are the one part of MVC that makes it impossible to limit usage of ObjectFactory.GetInstance to just a single occurrence.



Unit testing the attribute



Fortunately, with the overloaded constructor in place, we are able to make use of AutoMocker for unit testing the attribute.



[csharp]
[TestFixture]
public class DataForMenuAttributeTests
{
private RhinoAutoMocker<DataForMenuAttribute> autoMocker;
private DataForMenuAttribute attribute;

private SomeController controller;
private ActionExecutingContext context;
private HttpRequestBase httpRequestMock;
private HttpContextBase httpContextMock;

[SetUp]
public void Setup()
{
StructureMapBootstrapper.Restart();

autoMocker = new RhinoAutoMocker<DataForMenuAttribute>(MockMode.AAA);
attribute = autoMocker.ClassUnderTest;

controller = new SomeController();
httpRequestMock = MockRepository.GenerateMock<HttpRequestBase>();
httpContextMock = MockRepository.GenerateMock<HttpContextBase>();
httpContextMock.Expect(x => x.Request).Repeat.Any().Return(httpRequestMock);

context = new ActionExecutingContext(
new ControllerContext(httpContextMock ,
new RouteData(),
controller),
MockRepository.GenerateMock<ActionDescriptor>(),
new Dictionary<string, object>());
}

[Test]
public void DataForMenuAttributeShouldNotSetViewDataForPostRequest()
{
//Arrange
httpRequestMock.Expect(r => r.RequestType).Return("POST");

//Act
attribute.OnActionExecuting(context);

//Assert
Assert.That(controller.ViewData["DataForMenu"], Is.Null);
}

[Test]
public void DataForMenuAttributeShouldNotSetViewDataForAjaxRequest()
{
//Arrange
httpRequestMock.Expect(r => r.RequestType).Return("GET");
httpRequestMock.Expect(r => r["X-Requested-With"]).Return("XMLHttpRequest");

//Act
attribute.OnActionExecuting(context);

//Assert
Assert.That(controller.ViewData["DataForMenu"], Is.Null);
}

[Test]
public void DataForMenuAttributeShouldCallGetNavigationData()
{
//Arrange
httpRequestMock.Expect(r => r.RequestType).Return("GET");
httpRequestMock.Expect(r => r["X-Requested-With"]).Return(string.Empty);

NavigationData navigationData = new NavigationData();
autoMocker.Get<INavigationService>()
.Expect(x => x.GetNavigationData()).Return(navigationData);

//Act
attribute.OnActionExecuting(context);

//Assert
autoMocker.Get<INavigationService>().VerifyAllExpectations();
}

[Test]
public void DataForMenuAttributeShouldCallGetMenuViewModel()
{
//Arrange
httpRequestMock.Expect(r => r.RequestType).Return("GET");
httpRequestMock.Expect(r => r["X-Requested-With"]).Return(string.Empty);

NavigationData navigationData = new NavigationData();
MenuViewModel menuViewModel = new MenuViewModel();
autoMocker.Get<INavigationService>()
.Expect(x => x.GetNavigationData()).Return(navigationData);
autoMocker.Get<IViewModelMapper>()
.Expect(x => x.GetMenuViewModel(navigationData)).Return(menuViewModel);

//Act
attribute.OnActionExecuting(context);

//Assert
autoMocker.Get<IViewModelMapper>().VerifyAllExpectations();
}

[Test]
public void DataForMenuAttributeShouldSetMenuViewDataToGetMenuViewModelResult()
{
//Arrange
httpRequestMock.Expect(r => r.RequestType).Return("GET");
httpRequestMock.Expect(r => r["X-Requested-With"]).Return(string.Empty);

MenuViewModel menuViewModel = new MenuViewModel();
autoMocker.Get<IViewModelMapper>()
.Expect(x => x.GetMenuViewModel(Arg<NavigationData>.Is.Anything))
.Return(menuViewModel);

//Act
attribute.OnActionExecuting(context);

//Assert
Assert.That(controller.ViewData["DataForMenu"], Is.EqualTo(menuViewModel));
}
}
[/csharp]

As you can see, it's quite a lot of work setting up expectations and especially the context for testing an attribute.



Also, notice that for each test I try to arrange and assert just what's needed to verify that the code does what the name of the test says it should do.



Having multiple assertions in every test makes it much more difficult to alter the code under test. Ideally you would want to ditch the tests that verify functionality that's no longer wanted and create new tests to verify new functionality.
For example, if you decide not to use the INavigationService any longer for returning data to feed to the IViewModelMapper, the DataForMenuAttributeShouldCallGetNavigationData test can be removed and DataForMenuAttributeShouldCallGetMenuViewModel has to be altered slightly.



One last thing we have to test is the presence of the attribute on the controllers where it's necessary. This can be done in the following manner:



[csharp]
[Test]
public void ExampleControllerShouldHaveDataForMenuAttribute()
{
Assert.That(Attribute.GetCustomAttribute(typeof(ExampleController),
typeof(DataForMenuAttribute)),
Is.Not.Null);
}
[/csharp]

Make sure you have this test for every controller that needs the attribute!



Concluding, as you can see by the amount of work that's needed to test the attribute itself and its presence on the controllers, you can imagine we're really looking forward to MVC 2's RenderAction.

2 comments:

  1. In MVC1 we have RenderAction which come from Microsoft.Web.Mvc.dll :)

    ReplyDelete
  2. I don't think that's the case. It is available in the Futures assembly, but not in the regular mvc 1 assembly.

    ReplyDelete