David Shifflet's Snippets

Mindset + Skillset + Toolkit = Success




< Back to Index

End to End Tests for HttpClient

A lot of applications have something like this in them:


    public class SomeBusinessService
    {
        public void DoTheBusiness(object o)
        {
            DoBusiness();

            using (var httpClient = new HttpClient())
            using (var response = httpClient.GetAsync(Settings.Default.SomeUrl).Result)
            using (var content = response.Content)
            {
                //Call out and based on the return do something
                DoMoreBusiness(content.ReadAsStringAsync().Result);
            }
        }
    }

So what is the problem with that code? First I can't inject a mock for the HttpClient it is concrete. I can't really change the setting for the "SomeUrl" property, and nine times out of 10 that is an ApplicationSettings (You can change user settings easily but not application settings).

What to do when we are faced with this problem?

Example Code

The Simple Http Server

What we are going to do is to bring up a simple HttpServer using HttpListener. We are going to register our routes with it and define what it should return. Then we can run code via our tests and the code is going to talk to our HttpServer a fake one and not the real one.

Application Settings... Why?

But what about changing the expected URL that is probably in some ApplicationSetting? I know I just wrote you can't change that... but you can change it like this:

First change the AssemblyInfo.cs for the namespace and add this:


[assembly: InternalsVisibleTo("Your.Test.Namespace")]
Now we can see the settings from our Test. Now in the test do this:

	Properties.Settings.Default.Reload(); //Reload because other tests might have changed it.
	var dummy = Properties.Settings.Default.SomeUrl; //This forces a refresh.  Looks odd, but you need it.
	Properties.Settings.Default.PropertyValues["SomeUrl"].PropertyValue = url; //Finally set the new value
Not pretty. But with that out of the way we can change any application setting in our tests!

HTTP Server Code

How do we bring up this HttpServer? Copy paste this content or get it from dshifflet git hub into your TestProject. You are going to want a copy for your tests causes you might need to expand on it.


using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace HttpServer
{
    public class HttpServer : IDisposable
    {
        private CancellationToken _cancelToken;

        public HttpServer(string url, IEnumerable<RouteUrl> testRoutes, int maxConcurrentRequests = 10)
        {
            _cancelToken = new CancellationToken();
            Task.Run(() =>
            {
                Listen(url, maxConcurrentRequests, _cancelToken, testRoutes).Wait();
            });
        }

        public static string GetLocalhostAddress()
        {
            var listener = new TcpListener(IPAddress.Loopback, 0);
            listener.Start();
            int port = ((IPEndPoint)listener.LocalEndpoint).Port;
            listener.Stop();
            return $"http://localhost:{port}/";
        }

        public async Task Listen(string prefix, int maxConcurrentRequests, CancellationToken token,
            IEnumerable<RouteUrl> endPoints)
        {
            var routedEndPoints = endPoints.ToArray();

            HttpListener listener = new HttpListener();
            listener.Prefixes.Add(prefix);
            listener.Start();

            var requests = new HashSet<Task>();
            for (int i = 0; i < maxConcurrentRequests; i++)
                requests.Add(listener.GetContextAsync());

            while (!token.IsCancellationRequested)
            {
                var t = await Task.WhenAny(requests);
                requests.Remove(t);

                if (t is Task<HttpListenerContext>)
                {
                    var context = (t as Task<HttpListenerContext>).Result;
                    requests.Add(ProcessRequestAsync(context, routedEndPoints));
                    requests.Add(listener.GetContextAsync());
                }
            }
        }

        public async Task ProcessRequestAsync(HttpListenerContext context, IEnumerable<RouteUrl> endPoints)
        {
            var response = context.Response;
            var stream = response.OutputStream;
            var writer = new StreamWriter(stream);
//Expand on this here to do what you want.  We are looking for endpoints that match what has been called.  URL and Method are the key.
            var endPoint = endPoints.FirstOrDefault(o =>
                o.Method.Equals(context.Request.HttpMethod, StringComparison.OrdinalIgnoreCase) &&
                o.Url.Equals(context.Request.Url.ToString(), StringComparison.OrdinalIgnoreCase));
            if (endPoint == null)
            {
                response.StatusCode = 404;
            }
            else
            {
                writer.Write(endPoint.Response);
            }

            writer.Close();
        }

        public void Dispose()
        {
            _cancelToken = new CancellationToken(true);
        }
    }

    public class RouteUrl
    {
        public string Method { get; set; }
        public string Url { get; set; }
        public string Response { get; set; }

        public RouteUrl(string method, string url, string response)
        {
            Method = method;
            Url = url;
            Response = response;
        }
    }
}

The Test

Now we want to write a test with it. That is going to look like:


        [TestMethod]
        public void CanGet()
        {
			//GetLocalHostAddress() will return a new port each time you call it.  So only call it once per test.  Put it in a variable.
            var url = HttpServer.GetLocalhostAddress(); 
			//Define the URL we want to map our response of "some html" to.
            var destinationUrl = $"{url}test/test/test";
            using (var server = new HttpServer(url,
                new[]
                {
                    new RouteUrl("GET", destinationUrl, "some html") //Going to that URL should give the response "some html"
                }
                ))
            {
                Assert.IsTrue(GetContent(new Uri(destinationUrl)).Contains("some html"));
            }
        }
		
        private string GetContent(Uri page)
        {
            using (var httpClient = new HttpClient())
            using (var response = httpClient.GetAsync(page).Result)
            using (var content = response.Content)
            {
                return content.ReadAsStringAsync().Result;
            }
        }		
So we are creating a new HttpServer and giving it a collection of methods (POST, GET, etc.) and URLs with the related response. So when our HttpServer is hit with the related method and URL it should give back the related response.

Keep in mind the response is strings, but it would be easy to change it and extend this. The magic happens in the HttpServer code around the method ProcessRequestAsync(...). You could even improve the thing to handle dealing with Funcs based on routes. Your imagination is the limit.

WHAT NOT TO DO!

Don't use this as some full blown HTTP server it's meant for just faking one side of the HTTP communication.

What to Do in the Future (How to avoid having to do this!)

  • AppSettings are great, but wrap them in a ConfigService and inject that into the constructor via DI. A lot easier and cleaner in the tests if you need to change settings.
  • Don't use the concrete HttpClient in your services. Wrap it as a client while implementing an interface and inject that interface via the constructor. That way you can mock the client, and avoid the need for this.
  • If you are using HttpClient like this. BEWARE! Read This: https://aspnetmonsters.com/2016/08/2016-08-27-httpclientwrong/

Summary

In the case of brownfield applications and you just need some tests, this will get you going without a refactor.

It might also be useful if you are calling into something else and you need to support the server side of the HTTP communication and don't have the ability to refactor the client side code and you need a test around it.

Keep on Testing!

Links