How to keep things tidy when using ASP.NET Core TestServer

Reason

As stated before, ASP.NET Core makes unit and integration testing a breeze, especially when using a WebHostBuilder to wire up your system under test.

Going one step further than service and controller testing, the Microsoft.AspNetCore.TestHost.TestServer allows us to make actual HTTP interactions with our application in its entirety.

I’ve found using a TestServer particularly useful when testing authentication related code and code that relies on multiple pieces of middleware working together, as it’s difficult to wire up mocks of the Http– and AuthenticationContext classes without “losing touch” with the innards of ASP.NET Core.

The following are the two helper classes we use to setup and interact with any TestServer, and a sample test suite showing how these classes help keep tests to the point.

Code

Our HttpContext-dependant integration tests are all set up following the same pattern:

  1. Wire up a WebHostBuilder.
    1.1 Default services are added using the ConfigureServices method of the application’s startup class.
    1.2 Application middleware is added using the Configure method of the application’s startup class.
    1.3 Mocks/stubs/spies are injected as needed, along with any additional services or middleware used for testing.
  2. Create a TestServer using the IWebHostBuilder.
  3. Wrap the test server in a TestServerBrowser for convenience.
  4. Run tests against the SUT.

WebHostBuilderFactory

The WebHostBuilderFactory facilitates wiring up a WebHostBuilder with any modifications required by our tests, e.g. replacing dependencies with mocks, stubs or spies.
This is a more lengthy version of the previous code sample:

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;

public static class WebHostBuilderFactory
{
  public static IWebHostBuilder Create()
  {
    return Create(Enumerable.Empty<Action<IServiceCollection>>());
  }

  public static IWebHostBuilder Create(IEnumerable<Action<IServiceCollection>> configureServices)
  {
    var configureApplication = Enumerable.Empty<Action<IApplicationBuilder>>();
    return Create(configureServices, configureApplication);
  }

  public static IWebHostBuilder Create(IEnumerable<Action<IApplicationBuilder>> configureApplication)
  {
    var configureServices = Enumerable.Empty<Action<IServiceCollection>>();
    return Create(configureServices, configureApplication);
  }

  public static IWebHostBuilder Create(IEnumerable<Action<IServiceCollection>> configureServices, 
    IEnumerable<Action<IApplicationBuilder>> configureApplication)
  {
    // We can't use ".UseStartup<T>" as we want to be able to affect "MyStartup.Configure(IApplicationBuilder)".
    MyStartup app = null;
    var contentRoot = GetContentRoot();
    var webHostBuilder = new WebHostBuilder()
      .UseContentRoot(contentRoot.FullName)
      .UseEnvironment(EnvironmentName.Development)
      .ConfigureServices(services =>
      {
        var hostingEnvironment = GetHostingEnvironment(services);
        app = new MyStartup(hostingEnvironment);
        ConfigureServices(app, services, configureServices);
      })
      .Configure(builder =>
      {
        ConfigureApplication(app, builder, configureApplication);
      });
    return webHostBuilder;
  }

  private static DirectoryInfo GetContentRoot()
  {
    // Change to match your directory layout.
    const string relativeContentRootPath = @"..\..\src\App";
    var contentRoot = new DirectoryInfo(Path.Combine(Directory.GetCurrentDirectory(), relativeContentRootPath));
    if (!contentRoot.Exists)
    {
      throw new DirectoryNotFoundException($"Directory '{contentRoot.FullName}' not found.");
    }
    return contentRoot;
  }

  private static void ConfigureServices(MyStartup app, IServiceCollection services, 
    IEnumerable<Action<IServiceCollection>> configureServices)
  {
    app.ConfigureServices(services);
    foreach (var serviceConfiguration in configureServices)
    {
      serviceConfiguration(services);
    }
  }

  private static IHostingEnvironment GetHostingEnvironment(IServiceCollection services)
  {
    Func<ServiceDescriptor, bool> isHostingEnvironmet = service => service.ImplementationInstance is IHostingEnvironment;
    var hostingEnvironment = (IHostingEnvironment) services.Single(isHostingEnvironmet).ImplementationInstance;
    var assembly = typeof (MyStartup).GetTypeInfo().Assembly;
    // This can be skipped if you keep your tests and production code in the same assembly.
    hostingEnvironment.ApplicationName = assembly.GetName().Name;
    return hostingEnvironment;
  }

  private static void ConfigureApplication(MyStartup app, IApplicationBuilder builder, 
    IEnumerable<Action<IApplicationBuilder>> configureApplication)
  {
    foreach (var applicationConfiguration in configureApplication)
    {
      applicationConfiguration(builder);
    }
    app.Configure(builder);
  }
}

TestServerBrowser

The TestServerBrowser serves as a facade for interactions with the TestServer. In our case it helps keep track of (authentication-)cookies and XSRF tokens, which would otherwise clutter up our test code.

using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Net.Http.Headers;

public class TestServerBrowser
{
  private readonly TestServer _testServer;
  // Modify to match your XSRF token requirements.
  private const string XsrfCookieName = "XSRF-TOKEN";
  private const string XsrfHeaderName = "X-XSRF-TOKEN";

  public TestServerBrowser(TestServer testServer)
  {
    _testServer = testServer;
    Cookies = new CookieContainer();
  }

  public CookieContainer Cookies { get; }

  public HttpResponseMessage Get(string relativeUrl)
  {
    return Get(new Uri(relativeUrl, UriKind.Relative));
  }

  public HttpResponseMessage Get(Uri relativeUrl)
  {
    var absoluteUrl = new Uri(_testServer.BaseAddress, relativeUrl);
    var requestBuilder = _testServer.CreateRequest(absoluteUrl.ToString());
    AddCookies(requestBuilder, absoluteUrl);
    var response = requestBuilder.GetAsync().Result;
    UpdateCookies(response, absoluteUrl);
    return response;
  }

  private void AddCookies(RequestBuilder requestBuilder, Uri absoluteUrl)
  {
    var cookieHeader = Cookies.GetCookieHeader(absoluteUrl);
    if (!string.IsNullOrWhiteSpace(cookieHeader))
    {
      requestBuilder.AddHeader(HeaderNames.Cookie, cookieHeader);
    }
  }

  private void UpdateCookies(HttpResponseMessage response, Uri absoluteUrl)
  {
    if (response.Headers.Contains(HeaderNames.SetCookie))
    {
      var cookies = response.Headers.GetValues(HeaderNames.SetCookie);
      foreach (var cookie in cookies)
      {
        Cookies.SetCookies(absoluteUrl, cookie);
      }
    }
  }

  public HttpResponseMessage Post(string relativeUrl, IDictionary<string, string> formValues)
  {
    return Post(new Uri(relativeUrl, UriKind.Relative), formValues);
  }

  public HttpResponseMessage Post(Uri relativeUrl, IDictionary<string, string> formValues)
  {
    var absoluteUrl = new Uri(_testServer.BaseAddress, relativeUrl);
    var requestBuilder = _testServer.CreateRequest(absoluteUrl.ToString());
    AddCookies(requestBuilder, absoluteUrl);
    SetXsrfHeader(requestBuilder, absoluteUrl);
    var content = new FormUrlEncodedContent(formValues);
    var response = requestBuilder.And(message =>
    {
      message.Content = content;
    }).PostAsync().Result;
    UpdateCookies(response, absoluteUrl);
    return response;
  }

  // Modify to match your XSRF token requirements, e.g. "SetXsrfFormField".
  private void SetXsrfHeader(RequestBuilder requestBuilder, Uri absoluteUrl)
  {
    var cookies = Cookies.GetCookies(absoluteUrl);
    var cookie = cookies[XsrfCookieName];
    if (cookie != null)
    {
      requestBuilder.AddHeader(XsrfHeaderName, cookie.Value);
    }
  }

  public HttpResponseMessage FollowRedirect(HttpResponseMessage response)
  {
    if (response.StatusCode != HttpStatusCode.Moved && response.StatusCode != HttpStatusCode.Found)
    {
      return response;
    }
    var redirectUrl = new Uri(response.Headers.Location.ToString(), UriKind.RelativeOrAbsolute);
    if (redirectUrl.IsAbsoluteUri)
    {
      redirectUrl = new Uri(redirectUrl.PathAndQuery, UriKind.Relative);
    }
    return Get(redirectUrl);
  }
}

Example

The following is an example of how the TestServerBrowser facade helps keep interaction logic readable and minimal, and how we can easily inject e.g. middleware into the request pipeline using the WebHostBuilderFactory:

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Security.Claims;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.TestHost;
using NUnit.Framework;

public class AuthenticationControllerTests
{
  private readonly ConcurrentQueue<HttpContext> _httpContexts = new ConcurrentQueue<HttpContext>();
  private TestServer _testServer;

  [OneTimeSetUp]
  public void CreateTestServer()
  {
    // This middleware stores all HTTP contexts created by the test server to be inspected by our tests.
    Action<IApplicationBuilder> captureHttpContext = builder => builder.Use(async (httpContext, requestHandler) =>
    {
      await requestHandler.Invoke();
      _httpContexts.Enqueue(httpContext);
    });

    var webHostBuilder = WebHostBuilderFactory.Create(new[]
    {
      captureHttpContext
    });

    _testServer = new TestServer(webHostBuilder);
  }

  [OneTimeTearDown]
  public void DisposeTestServer()
  {
    _testServer.Dispose();
  }

  [Test]
  public void AnonymousUsersAreRedirectedToSignIn()
  {
    // Arrange
    var browser = new TestServerBrowser(_testServer);

    // Act
    var frontPageResponse = browser.Get("/");

    // Assert
    Assert.That(frontPageResponse.StatusCode, Is.EqualTo(HttpStatusCode.Found));
    Assert.That(frontPageResponse.Headers.Location.ToString(), Does.EndWith("/Authentication/SignIn?ReturnUrl=%2F"));
  }

  [Test]
  public void SuccessfulSignInRedirectsToFrontPage()
  {
    // Arrange
    var browser = new TestServerBrowser(_testServer);
    var credentials = new Dictionary<string, string>
    {
      {"username", "Uli"},
      {"password", "secret"}
    };

    // Act
    var signInResponse = browser.Post("/Authentication/SignIn", credentials);

    // Assert
    Assert.That(signInResponse.StatusCode, Is.EqualTo(HttpStatusCode.Found));
    Assert.That(signInResponse.Headers.Location.ToString(), Is.EqualTo("/"));
  }

  [Test]
  public void UserNameIsStoredInClaim()
  {
    // Arrange
    var browser = new TestServerBrowser(_testServer);
    var expectedName = "Uli";
    var credentials = new Dictionary<string, string>
    {
      {"username", expectedName},
      {"password", "secret"}
    };

    var signInResponse = browser.Post("/Authentication/SignIn", credentials);

    // Act
    browser.FollowRedirect(signInResponse);
    var name = _httpContexts.Last().User.FindFirstValue(ClaimTypes.Name);

    // Assert
    Assert.That(name, Is.EqualTo(expectedName));
  }
}