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 🙂
Thanks for the valueable tips, Maurice!
This is really cool, I’ve always looked for a http response and request codes in one whole and you just gave me that
Thank you for the nicely written post! If I may ask, what about HTTP DELETE?
Great post. This is useful for developers.. Thanks for this post..!
Maurice, any insight on how to test a ApiController with dependencies on the Asp.Net MembershipProvider/FormsAuthentication?
@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.
@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.
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.
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/”)