Improving ApiController testability


Motivation

1) Codeplex discussion - Mocking the request of ApiController.
Prefers mocking over setting individual properties on HttpRequestMessage. Claims he liked HttpResponseMessage<T>.
2) Codeplex bug - The default created MVC controller is strongly coupled with the Model, making it difficult to unit-test.
Complains about the scaffolding EF controller we generate being tied too much into DbContext (Not being addressed in these changes. Requires tooling changes).
3) Codeplex item - Make APIController more testable.
Properties like Url on ApiController are not virtual - so people cannot mock them.
4) Codeplex item - Refactor the UrlHelper so it becomes easier to test.
UrlHelper methods are not virtual so that it cannot be mocked. It requires too much context on the Request to work properly.
5) Codeplex item (gblock) -Testing ApiController using Url helpers or ObjectContent is too hard.
Complains about the amount of context to be initialized on ApiController to test POST and PUT.
6) SO thread - Unit testing post controller .NET Web Api.
Missed a step in the context initialization code (too big that it is easy to miss one). Pablo Cibraro complains about UrlHelper and shares his IUrlHelper that can be mocked and claims that he doesn't use UrlHelper.
7) SO thread - ASP.Net Web Api Url.Link not returning a UriString in unit test.
Another case of missing setting RouteData on the request.
8) Blog post by Peter Provost detailing unit testing controllers.
Complains,
"Unfortunately, that didn’t work. You end up getting a NullReferenceException thrown by Request.CreateResponse because it expects a fair amount of web config stuff to have been assembled. This is a bummer, but it is what it is."
Creates a 'SetupControllerForTests' that initializes the controller with required context.
9) SO thread ASP.NET WebApi unit testing with : Request.CreateResponse.
Answered through the earlier blog post that has a helper to set context.
10) Blog post on MVC URL helper Unit testing MVC controllers / Mocking UrlHelper.
Same old context initialization issue. Helper for doing it. Prefers testing the generated URL as well instead of just mocking it.

Issues observed

There are two pieces of framework code that usually run in controller that require a lot of WEB context. Testing actions that depend on this functionality is really hard as it requires all that framework context to be setup. Those two pieces are not replaceable with mock implementations giving users the pain of writing a lot of initialization code. The two pieces are,
  1. URL generation
  2. HttpResponseMessage generation.
In the most common scenarios (default), URL generation is the harder among the two as it requires lot more context on the request (configuration, routes, route data). Response message generation requires just the configuration.

Planned changes

  1. Make UrlHelper methods virtual so that it can be mocked.
  2. Make all the properties on the ApiController that are stored in the request be backed by request.
Mocking response message generation (for example through IHttpActionResult)will not be addressed in this iteration of changes.

Samples

Action under test

public HttpResponseMessage Post(Customer c)
{
    var response = Request.CreateResponse(HttpStatusCode.Created, c);
    response.Headers.Location = new Uri(Url.Link("default", new { id = c.ID }));
    return response;
}

Tests with current bits

(Note: this should have been two tests, but I merged them for being concise here.)

[Fact]
public void Post_Sets_RightContentAndLocationHeader()
{
    // Arrange
    var request = new HttpRequestMessage(HttpMethod.Get, "http://localhost/customers/");
    var config = new HttpConfiguration();
    var route = config.Routes.MapHttpRoute("DefaultApi", "{controller}/{id}", new { id = RouteParameter.Optional });
    var routeData = new HttpRouteData(route, new HttpRouteValueDictionary { { "controller", "customers" } });

    CustomersController controller = new CustomersController();
    controller.ControllerContext = new HttpControllerContext(config, routeData, request);
    controller.ControllerContext.ControllerDescriptor = new HttpControllerDescriptor(config, "customers", typeof(CustomersController));
    controller.Request = request;
    controller.Request.Properties[HttpPropertyKeys.HttpConfigurationKey] = config;
    controller.Request.Properties.Add(HttpPropertyKeys.HttpRouteDataKey, routeData);

    // Act
    var result = controller.Post(new Customer { ID = 42 });

   // Assert
   Customer customer;
   Assert.Equal("http://localhost/customers/42", result.Headers.Location.AbsoluteUri);
   Assert.True(result.TryGetContentValue<Customer>(out customer));
   Assert.Equal(42, customer.ID);
}

Tests after the changes

[Fact]
public void Post_With_InitializeConfigurationAndRequestAndRouteData()
{
    // Arrange
    CustomersController controller = new CustomersController();
    controller.Request = new HttpRequestMessage { RequestUri = new Uri("http://localhost/Customers") };
    controller.Configuration = new HttpConfiguration();
    controller.Configuration.Routes.MapHttpRoute("default", "{controller}/{id}", new { id = RouteParameter.Optional });
    controller.RouteData = new HttpRouteData(new HttpRoute(), new HttpRouteValueDictionary { { "controller", "SpecialCustomers" } });

    // Act
    var result = controller.Post(new Customer { ID = 42 });

    // Assert
    Customer customer;
    Assert.Equal("http://localhost/SpecialCustomers/42", result.Headers.Location.AbsoluteUri);
    Assert.True(result.TryGetContentValue<Customer>(out customer));
    Assert.Equal(42, customer.ID);
}
Alternative version (mock UrlHelper).
[Fact]
public void Post_With_EmptyRequest_And_MockUrlHelper()
{
    // Arrange
    CustomersController controller = new CustomersController();
    controller.Request = new HttpRequestMessage();
    controller.Configuration = new HttpConfiguration();

    Mock<UrlHelper> url = new Mock<UrlHelper>();
    url.Setup(u => u.Link("default", new { id = 42 })).Returns("http://location_header/").Verifiable();
    controller.Url = url.Object;

    // Act
    var result = controller.Post(new Customer { ID = 42 });

    // Assert
    Customer customer;
    Assert.Equal("http://location_header/", result.Headers.Location.AbsoluteUri);
    Assert.True(result.TryGetContentValue<Customer>(out customer));
    Assert.Equal(42, customer.ID);
}       

Last edited Apr 3, 2013 at 6:23 PM by raghuramn, version 5

Comments

EvilShrike Oct 28, 2013 at 3:47 PM 
It's a nice list in "motivation" section. I can't understand only one thing - why you made UrlHelper's methods virtual in WebApi "5.0.0" but not in MVC in "5.0.0"?!