次の方法で共有


In memory client, host and integration testing of your Web API service

One of the great things about ASP.NET MVC4 Web API is its testability.

If you have gone through my post ASP.NET MVC4 WebAPI Stack Diagram , you will notice that there is a block (green color) which says In memory client-host using no network (HttpMessageInvoker/HttpClient)” . This post is about that block and how you could do in-memory testing without using the network at all.

Some Pros and Cons of using In-memory testing:

Pros:

- Tests run very fast as there is no additional overhead of setting up port etc. Since these tests run very fast, you could write integration tests itself, but still expect the kind of performance that you would see in your unit tests.  

- Since HttpServer is the common layer for Web Host and Self Host layers, you could test your functionality independent of the hosting layers. (Of course, if you are testing a feature which is very specific to a host, using a real host would make sense.)

Cons:

- Cannot use the awesome tool Fiddler to capture the Request/Response messages!. Fiddler is a great tool for debugging purposes where you can exactly see how your request/response messages look like over the wire. Since we are testing everything in-memory, we would loose this capability.

- When doing in-memory testing, the request and response contents are basically stream contents backed up by buffered streams (example: in my example in this post, you would notice that the method ConvertToStreamContent() uses MemoryStream). This might not be ideal in some situations where you might be expecting a non-buffered stream.

- Error response messages behavior is currently different between Web Host and Self Host due to some bugs.

In view of some of the above Pros & Cons, you should be a better judge of whether in-memory kind of testing works for you or not.

Following is an example of a typical in-memory test:

Some things to consider while writing an in-memory test:

- Client to use: You can use HttpClient or HttpMessageInvoker. In fact HttpClient derives from HttpMessageInvoker. HttpMessageInvoker is lightweight compared to HttpClient, so I usually like to use it in my testing.

- In a real user scenario, data would be sent in a serialized format over the wire and its later deserialized at the client/service. For example, in our current case, data is serialized/deserialized by MediaTypeFormatters. Since we are doing in-memory testing, there are some caveats that we need to take care of.

Important: For example, the ParameterBinding stage tries to read the incoming request’s content using the HttpContent extension called “ReadAsAsync<>()” . This extension has internal checks to see if the request’s content is an ObjectContent or not. If its is an ObjectContent, then it does NOT try to read the request content stream again to deserialize the data. This is a cause of concern for us here since we are doing in-memory testing and the request/response contents could be of ObjectContent type, which means that we will not go through the MediaTypeFormatters’ ReadFromStream or WriteToStream methods.

To overcome this problem, we can create a Message Handler which causes the in-memory objects to be serialized into a stream of bytes in order to make the MediaTypeFormatters to take part in the serialization/deserialization process. In the following example code, you should see the message handler InMemoryHttpContentSerializationHandler doing that.

InMemoryClientHostIntegrationTesting

(click the image to get a larger image)

Here I am using XUnit framework for testing. I hope you can relate the following code to your test framework:

 public class OrdersController : ApiController
{
    [HttpPost]
    public Order CreateOrder(Order order)
    {
        return order;
    }
}

public class InMemoryTesting
{
    [Fact]
    public void CreateOrderTest()
    {
        string baseAddress = "https://dummyname/";

        // Server
        HttpConfiguration config = new HttpConfiguration();
        config.Routes.MapHttpRoute("Default", "api/{controller}/{action}/{id}", 
                                    new { id = RouteParameter.Optional });
        config.IncludeErrorDetailPolicy = IncludeErrorDetailPolicy.Always;

        HttpServer server = new HttpServer(config);

        // Client
        HttpMessageInvoker messageInvoker = new HttpMessageInvoker(new InMemoryHttpContentSerializationHandler(server));

        //order to be created
        Order requestOrder = new Order() { OrderId = "A101", OrderValue = 125.00, OrderedDate = DateTime.Now.ToUniversalTime(), ShippedDate = DateTime.Now.AddDays(2).ToUniversalTime() };

        HttpRequestMessage request = new HttpRequestMessage();
        request.Content = new ObjectContent<Order>(requestOrder, new JsonMediaTypeFormatter());
        request.RequestUri = new Uri(baseAddress + "api/Orders/CreateOrder");
        request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/xml"));
        request.Method = HttpMethod.Post;

        CancellationTokenSource cts = new CancellationTokenSource();

        using (HttpResponseMessage response = messageInvoker.SendAsync(request, cts.Token).Result)
        {
            Assert.NotNull(response.Content);
            Assert.NotNull(response.Content.Headers.ContentType);
            Assert.Equal<string>("application/xml; charset=utf-8", response.Content.Headers.ContentType.ToString());
            Assert.Equal<Order>(requestOrder, response.Content.ReadAsAsync<Order>().Result);
        }
    }
}

public class InMemoryHttpContentSerializationHandler : DelegatingHandler
{
    public InMemoryHttpContentSerializationHandler()
    {
    }

    public InMemoryHttpContentSerializationHandler(HttpMessageHandler innerHandler)
        : base(innerHandler)
    {
    }

    protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        // Replace the original content with a StreamContent before the request
        // passes through upper layers in the stack
        request.Content = ConvertToStreamContent(request.Content);

        return base.SendAsync(request, cancellationToken).ContinueWith<HttpResponseMessage>((responseTask) =>
                                                                    {
                                                                        HttpResponseMessage response = responseTask.Result;

                                                                        // Replace the original content with a StreamContent before the response
                                                                        // passes through lower layers in the stack
                                                                        response.Content = ConvertToStreamContent(response.Content);

                                                                        return response;
                                                                    });
    }

    private StreamContent ConvertToStreamContent(HttpContent originalContent)
    {
        if (originalContent == null)
        {
            return null;
        }

        StreamContent streamContent = originalContent as StreamContent;

        if (streamContent != null)
        {
            return streamContent;
        }

        MemoryStream ms = new MemoryStream();

        // **** NOTE: ideally you should NOT be doing calling Wait() as its going to block this thread ****
        // if the original content is an ObjectContent, then this particular CopyToAsync() call would cause the MediaTypeFormatters to 
        // take part in Serialization of the ObjectContent and the result of this serialization is stored in the provided target memory stream.
        originalContent.CopyToAsync(ms).Wait();

        // Reset the stream position back to 0 as in the previous CopyToAsync() call,
        // a formatter for example, could have made the position to be at the end after serialization
        ms.Position = 0;

        streamContent = new StreamContent(ms);

        // copy headers from the original content
        foreach (KeyValuePair<string, IEnumerable<string>> header in originalContent.Headers)
        {
            streamContent.Headers.TryAddWithoutValidation(header.Key, header.Value);
        }

        return streamContent;
    }
}

Have fun!

Comments

  • Anonymous
    February 18, 2013
    Really, really great article. Can't thank you enough! I do have one question though. If the controller I am testing is in another (referenced) assembly,  how will the in memory host know that it needs to load that  particular controller? I.e. if my route is http://dummyname/MyService/SomeMethod, where MyServiceController.cs is defined in another assembly, how does the host get to it? Do we need to provide a custom IHttpControllerSelector? Thanks, Priya

  • Anonymous
    February 27, 2013
    Hi Priya, Glad you liked it!. Regarding the referenced assembly, you would need to force load the assembly yourself. Simply referencing at least one type from the referenced assembly should solve the issue. Type controllerType = typeof(MyControllers.ProductsController); BTW, this is a known issue even with Selfhost application. stackoverflow.com/.../self-hosting-webapi-application-referencing-controller-from-different-assembly. Thanks, Kiran

  • Anonymous
    July 21, 2013
    Thank you for this post.  It works great locally but when it runs on my build server I get a 404.  I had to force-load the assembly just as the other poster needed to do.

  • Anonymous
    October 20, 2014
    Hello, Nice article. Question about it. I'd like to do something similar to wrap a test fixture around our Web Api controllers. Question about how to handle concerns like Dependency Injection? In other words, our controllers are bootstrapped to a Dependency Injection root (i.e. Castle Windsor, but the same should hold true for any DI, I imagine). When we bundle our routes to the in memory services, we maintain the benefit of the controller bootstrap(s)? Thank you...

  • Anonymous
    October 20, 2014
    Follow up to my response just now... Another thought along the same lines, standing a self-hosted server configuration affords an opportunity for the controller host global code to run, for bootstrap(s) to run? And/or, what's the analog to that? We need to manually bootstrap? Which would be fine, I just want to understand the scope what we're dealing with. Thank you, again...