Unit testing a ASP.NET WebAPI controller

 

Update: If you are using the ASP.NET WebAPI 2 see the new post here.

 

One of he goals of the ASP.NET WebAPI is to make REST style API controllers more testable than more traditional WCF services where in the past. For the most part that is true but there are cases where an ApiController depends on the actual incoming request and its data and things can become a bit more difficult.

Testing a simple ApiController that gets data

Suppose we have the following ASP.NET WebAPI Controller with two Get methods, the first returns the complete list of books and the second returns the book with the requested ID.

 

   1: public class BooksController : ApiController

   2: {

   3:     private readonly IBooksRepository _repo;

   4:  

   5:     public BooksController()

   6:         : this(new BooksRepository())

   7:     {

   8:  

   9:     }

  10:     public BooksController(IBooksRepository repository)

  11:     {

  12:         _repo = repository;

  13:     }

  14:  

  15:     // GET api/books

  16:     public IEnumerable<Book> Get()

  17:     {

  18:         return _repo.GetBooks();

  19:     }

  20:  

  21:     // GET api/books/5

  22:     public HttpResponseMessage Get(int id)

  23:     {

  24:         var book = _repo.GetBook(id);

  25:  

  26:         if (book == null)

  27:         {

  28:             return Request.CreateResponse(HttpStatusCode.NotFound);

  29:         }

  30:  

  31:         return Request.CreateResponse(HttpStatusCode.OK, book);

  32:     }

  33:  

  34:     // Remainder ommitted

 

Testing the Get() method

The Get() method that returns all books is easy enough to test. There are no dependencies on WebAPI bits, all it does is return a enumeration of books.

   1: [TestMethod]

   2: public void WhenGettingItShouldReturnAllBooks()

   3: {

   4:     // Arrange

   5:     var controller = new BooksController();

   6:  

   7:     // Act

   8:     var books = controller.Get();

   9:  

  10:     // Assert

  11:     Assert.AreEqual(5, books.Count());

  12: }

 

No big deal there 🙂

 

Testing the Get(id) method

When getting a specific book things are a bit more complex. The requested Id might not exist and in that case we should return a specific HTTP response status code of 404 NotFound. This results in that the ApiController method uses the Request property to create a new HttpResponseMessage with the specific HTTP status code. If we just call this method we will receive an ArgumentNullException in the CreateResponse() function.

System.ArgumentNullException: Value cannot be null.

Parameter name: request

Just setting the Request property to a new instance of HttpRequestMessage is a start but not quite enough. With just this change you will see an InvalidOperationException with the following message:

System.InvalidOperationException: The request does not have an associated configuration object or the provided configuration was null.

To fix this we need to add a HttpConfiguration object through the Properties dictionary as below:

   1: [TestMethod]

   2: public void WhenGettingWithAKnownIdItShouldReturnThatBook()

   3: {

   4:     // Arrange

   5:     var controller = new BooksController

   6:     {

   7:         Request = new HttpRequestMessage()

   8:         {

   9:             Properties = { { HttpPropertyKeys.HttpConfigurationKey, new HttpConfiguration() } }

  10:         }

  11:     };

  12:  

  13:     // Act

  14:     var response = controller.Get(1);

  15:  

  16:     // Assert

  17:     Assert.AreEqual(HttpStatusCode.OK, response.StatusCode);

  18:     var book = response.Content.ReadAsAsync<Book>().Result;

  19:     Assert.AreEqual(1, book.Id);

  20: }

 

Testing for getting with an invalid ID is equally simple:

   1: [TestMethod]

   2: public void WhenGettingWithAnUnknownIdItShouldReturnNotFound()

   3: {

   4:     // Arrange

   5:     var controller = new BooksController()

   6:     {

   7:         Request = new HttpRequestMessage()

   8:         {

   9:             Properties = { { HttpPropertyKeys.HttpConfigurationKey, new HttpConfiguration() } }

  10:         }

  11:     };

  12:  

  13:     // Act

  14:     var response = controller.Get(999);

  15:  

  16:     // Assert

  17:     Assert.AreEqual(HttpStatusCode.NotFound, response.StatusCode);

  18: }

Testing an HTTP PUT operation

Testing an update using an HTTP put is just as simple as a testing a get action. The implementation looks like this:

   1: // PUT api/books/5

   2: public HttpResponseMessage Put(int id, Book book)

   3: {

   4:     if (!ModelState.IsValid)

   5:     {

   6:         return Request.CreateErrorResponse(HttpStatusCode.BadRequest, ModelState);

   7:     }

   8:  

   9:     var newBook = _repo.UpdateBook(book);

  10:     return Request.CreateResponse(HttpStatusCode.OK, newBook);

  11: }

And the related test goes as follows:

   1: [TestMethod]

   2: public void WhenPuttingABookItShouldBeUpdated()

   3: {

   4:     // Arrange

   5:     var controller = new BooksController()

   6:     {

   7:         Request = new HttpRequestMessage()

   8:         {

   9:             Properties = { { HttpPropertyKeys.HttpConfigurationKey, new HttpConfiguration() } }

  10:         }

  11:     };

  12:  

  13:     // Act

  14:     var book = new Book() {Id = 1, Title = "New Title", Author = "New Author"};

  15:     var response = controller.Put(book.Id, book);

  16:  

  17:     // Assert

  18:     Assert.AreEqual(HttpStatusCode.OK, response.StatusCode);

  19:     var newBook = response.Content.ReadAsAsync<Book>().Result;

  20:     Assert.AreEqual(1, newBook.Id);

  21:     Assert.AreEqual("New Title", newBook.Title);

  22:     Assert.AreEqual("New Author", newBook.Author);

  23: }


No big surprises there 🙂

 

Adding new data using an HTTP POST action

Testing an HTTP POST used to add a new book is a little, but not much more, involved. The main reason for this is the HTTP convention to return a 201 Created status code and to include the HTTP Location header with an URI where the new resource can be found. The implementation is as follows:

   1: // POST api/books

   2: public HttpResponseMessage Post(Book book)

   3: {

   4:     if (!ModelState.IsValid)

   5:     {

   6:         return Request.CreateErrorResponse(HttpStatusCode.BadRequest, ModelState);

   7:     }

   8:  

   9:     var newBook = _repo.AddBook(book);

  10:  

  11:     var response = Request.CreateResponse(HttpStatusCode.Created, newBook);

  12:  

  13:     var uriString = Url.Link("DefaultApi", new { id = newBook.Id }) ?? string.Empty;

  14:     response.Headers.Location = new Uri(uriString);

  15:  

  16:     return response;

  17: }

 

If we try to test this with a similar setup action as the previous tests we will see a KeyNotFoundException below:

System.Collections.Generic.KeyNotFoundException: The given key was not present in the dictionary.

Not a very helpful message, what key is not found? The stack trace gives a clue and it turns out we need to configure the HTTP routing on the HttpConfiguration object. Easy enough as just calling WebApiConfig.Register() with the configuration should do the trick.

However this just leads us to an UriFormatException as the Uri.Link() function returns null.

It turns out we also need to add the HttpRouteData to the Request Properties dictionary with the controller name. No big deal but not exactly obvious.

The correct unit test is as follows:

   1: [TestMethod]

   2: public void WhenPostingANewBookShouldBeAdded()

   3: {

   4:     // Arrange

   5:     var httpConfiguration = new HttpConfiguration();

   6:     WebApiConfig.Register(httpConfiguration);

   7:     var httpRouteData = new HttpRouteData(httpConfiguration.Routes["DefaultApi"], 

   8:         new HttpRouteValueDictionary { { "controller", "Books" } });

   9:     var controller = new BooksController()

  10:     {

  11:         Request = new HttpRequestMessage(HttpMethod.Post, "http://domain.com/api/books/")

  12:         {

  13:             Properties = 

  14:             {

  15:                 { HttpPropertyKeys.HttpConfigurationKey, httpConfiguration },

  16:                 { HttpPropertyKeys.HttpRouteDataKey, httpRouteData } 

  17:             }

  18:         }

  19:     };

  20:  

  21:     // Act

  22:     var response = controller.Post(new Book()

  23:     {

  24:         Title = "A new book",

  25:         Author = "The author"

  26:     });

  27:  

  28:     // Assert

  29:     Assert.AreEqual(HttpStatusCode.Created, response.StatusCode);

  30:     var addedBook = response.Content.ReadAsAsync<Book>().Result;

  31:  

  32:     Assert.AreEqual(string.Format("http://domain.com/api/Books/{0}", 

  33:         addedBook.Id), response.Headers.Location.ToString());

  34: }

 

The arrange section might be a bit more verbose but once you know what to add no big deal.

 

Enjoy 🙂

9 thoughts on “Unit testing a ASP.NET WebAPI controller

  1. This is really cool, I’ve always looked for a http response and request codes in one whole and you just gave me that

  2. Maurice, any insight on how to test a ApiController with dependencies on the Asp.Net MembershipProvider/FormsAuthentication?

  3. @Carlos,

    It depends on what you are doing. If you are just using the Authorize attribute it should not be hard as it doesn’t depend on a specific security implementation. If you are actually calling into the MembershipProvider it is harder and you need to factor that out into an interface you inject and can mock.

  4. @vitaminjeff,

    Testing a HTTP DELETE is no different. In this case the delete does nothing special so it is just as easy as the HTTP GET.

  5. Hi Maurice.
    Thanks for your quick answer. 😉
    I have already removed the dependency on the Membership from my controller and implemented a Message Handler to handle the authentication/authorization issues.

  6. I am using the built in web server with VS2012. How do i figure out my url for line 11 in the POST example?

    LINE 11: Request = new HttpRequestMessage(HttpMethod.Post, “http://domain.com/api/books/”)

Leave a Reply to Carlos Souto Cancel reply

Your email address will not be published. Required fields are marked *