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:
- Wire up a
WebHostBuilder
.
1.1 Default services are added using theConfigureServices
method of the application’s startup class.
1.2 Application middleware is added using theConfigure
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. - Create a TestServer using the
IWebHostBuilder
. - Wrap the test server in a
TestServerBrowser
for convenience. - 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)); } }
Is it possible to debug the code you’re testing? Or is this basically a fancy wrapper around HttpClient or something similar?
Hi Josh,
Yes, you can debug all your code. I suggest trying it out for yourself, it’s really great.
Just to emphasize, the classes posted here are helpers which simplify using
Microsoft.AspNetCore.TestHost.TestServer
when it comes to handling cookies and following redirects, and last but not least mocking parts of your application.Be sure to check out the official docs on Integration Testing.
Is it possible to use Azure Active Directory authentication with TestServer?
Any particular reason why you think it wouldn’t work?
You should be able to use any middleware you want, for instance.
I thought it should work but I was getting 401 not authorized until I discovered that I needed to call the “UseConfiguration” method of the WebHostBuilder to explicitly pull in config that is typically pulled in implicitly.
what is MyStartup as the app? do you have a complete solution to download?
Hi Fitzgibbons,
The “MyStartup” is your ASP.NET Core startup class.
See the official docs for details: https://docs.microsoft.com/en-us/aspnet/core/fundamentals/startup