Most developers understand the value of unit testing and the importance of having a large test base for non-trivial code. However it is also important to have at least some integration tests which confirm that the various parts of your app are functioning together correctly.
In this post I'm going to demonstrate how to create some simple integration tests using xUnit and TestServer for testing the custom middleware shown in my previous post. I'm using the terms functional/integration testing pretty interchangeably here to refer to tests that cover the whole infrastructure stack.
This post was originally written use preview 1 of the tooling. For preview 2 use the following package versions:
- global.json SDK version -
1.0.0-preview2-003121
xunit
-2.2.0-beta2-build3300
dotnet-test-xunit
-2.2.0-preview2-build1029
Microsoft.NETCore.App
andMicrosoft.AspNetCore.TestHost
-1.0.0
Creating an integration test project
In order to run your integration tests, you will need to add a test project to your solution. By convention your test projects should reside in a subfolder, test
, of the root folder. You may also need to update your global.json
to account for this:
{
"projects": [ "src", "test" ],
"sdk": {
"version": "1.0.0-preview1-002702"
}
}
There are multiple ways to create a new project but all that is required is a project.json
in your project folder, which you can create using dotnet new
.
You then need to add a dependency to the project under test - in this case NetEscapades.AspNetCore.SecurityHeaders - and the necessary xUnit testing libraries. We also add a reference to Microsoft.AspNetCore.TestHost.
{
"version": "1.0.0-*",
"dependencies": {
"dotnet-test-xunit": "1.0.0-rc2-*",
"xunit": "2.1.0",
"Microsoft.AspNetCore.TestHost": "1.0.0-rc2-final",
"NetEscapades.AspNetCore.SecurityHeaders": {
"version": "1.0.0-*",
"target": "project"
}
},
"testRunner": "xunit",
"frameworks": {
"netcoreapp1.0": {
"dependencies": {
"Microsoft.NETCore.App": {
"version": "1.0.0-rc2-3002702",
"type": "platform"
}
},
"imports": [
"dnxcore50",
"portable-net451+win8"
]
},
"net451": { }
}
}
We have added our project under test with the "target": "project"
attribute - this ensures that when dependencies are restored, you don't accidentally restore a package from NuGet with the same name. You can also see we are targeting two frameworks, the net451
and netcoreapp1.0
, where the latter additionally has a dependency on the platform Microsoft.NETCore.App.
Configuring the TestServer
ASP.NET Core includes a TestServer
class in the Microsoft.AspNetCore.TestHost library which can be used to simulate ASP.NET Core applications for testing purposes. We can use this to test the whole ASP.NET Core pipeline, without having to worry about spinning up an actual test website in a different process to test against.
The TestServer
class is configured by passing an IWebHostBuilder
in the constructor. There are many ways to configure the IWebHostBuilder
and various ways to use the configured TestServer
, two of which I'll cover here.
Direct configuration in code
With this approach, we directly create a WebHostBuilder
in code, and configure it as required for testing our middleware:
var policy = new SecurityHeadersPolicyBuilder()
.AddDefaultSecurePolicy();
var hostBuilder = new WebHostBuilder()
.ConfigureServices(services => services.AddSecurityHeaders())
.Configure(app =>
{
app.UseSecurityHeadersMiddleware(policy);
app.Run(async context =>
{
await context.Response.WriteAsync("Test response");
})
});
Here we configure the bare minimum we require to setup our app - we setup our SecurityHeadersPolicy
, add the required services for our custom middleware, and setup the WebHostBuilder
to call our middleware. Finally, the app will return the content string "Test response"
for all requests.
Next, we configure our test to create a TestServer
object, passing in our WebHostBuilder
. We can use this to create and send requests through the ASP.NET Core pipeline.
using (var server = new TestServer(hostBuilder))
{
var response = await server.CreateRequest("/")
.SendAsync("GET");
// Assert
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
Assert.Equal("Test response", content);
}
In this case we create a request to the root of the web application, send it, and read the content asynchronously. The TestServer
object handles resolving and creating the necessary services and middleware, just as it would if it were a normal ASP.NET Core application.
We have now all the pieces we need to perform our integration test, all we need to do is put it together to create our xUnit [Fact]
test:
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.TestHost;
using NetEscapades.AspNetCore.SecurityHeaders.Infrastructure;
using Xunit;
namespace NetEscapades.AspNetCore.SecurityHeaders
{
public class SecurityHeadersMiddlewareTests
{
[Fact]
public async Task DefaultSecurePolicy_RemovesServerHeader()
{
// Arrange
var policy = new SecurityHeadersPolicyBuilder()
.AddDefaultSecurePolicy();
var hostBuilder = new WebHostBuilder()
.ConfigureServices(services => services.AddSecurityHeaders())
.Configure(app =>
{
app.UseSecurityHeadersMiddleware(policy);
app.Run(async context =>
{
await context.Response.WriteAsync("Test response");
});
});
using (var server = new TestServer(hostBuilder))
{
// Act
// Actual request.
var response = await server.CreateRequest("/")
.SendAsync("GET");
// Assert
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
Assert.Equal("Test response", content);
Assert.False(response.Headers.Contains("Server"), "Should not contain server header");
}
}
}
}
We have added an extra assert here to check that our SecurityHeadersMiddleware
is correctly removing the "Server"
tag.
Using a Startup configuration file
We have shown how to create a TestServer
using a manually created WebHostBuilder
. This is useful for testing our middleware in various scenarios, but it does not necessarily test a system as it is expected to be used in production.
An alternative to this is to have a separate ASP.NET Core website configured to use our middleware, which we can run using dotnet run
. We can then configure our TestServer
class to directly use the test website's Startup.cs
class for configuration, to ensure our integration tests match the production system as closesly as possible.
Rather than add our TestServer
configuration to the body of our tests in this case, we will instead create a helper TestFixture
class which we will use to initialise our tests.
public class TestFixture<TStartup> : IDisposable where TStartup : class
{
private readonly TestServer _server;
public TestFixture()
{
var builder = new WebHostBuilder().UseStartup<TStartup>();
_server = new TestServer(builder);
Client = _server.CreateClient();
Client.BaseAddress = new Uri("http://localhost:5000");
}
public HttpClient Client { get; }
public void Dispose()
{
Client.Dispose();
_server.Dispose();
}
}
We use the UseStartup<T>()
extension method to tell the WebHostBuilder
to use our test system's Startup
class to configure the application. We then create the TestServer
object, and from this we obtain an HttpClient
which we can use to send our requests to the TestServer
. Finally we set the base address URL that the server will be bound to.
In our test fixture we can implement the IClassFixture<TestFixture>
interface to gain access to an instance of the TestFixture
class, which xUnit will create and inject into the constructor automatically. We can then use the HttpClient
it exposes to run tests against our TestServer
.
public class MiddlewareIntegrationTests : IClassFixture<TestFixture<SystemUnderTest.Startup>>
{
public MiddlewareIntegrationTests(TestFixture<SystemUnderTest.Startup> fixture)
{
Client = fixture.Client;
}
public HttpClient Client { get; }
[Theory]
[InlineData("GET")]
[InlineData("HEAD")]
[InlineData("POST")]
public async Task AllMethods_RemovesServerHeader(string method)
{
// Arrange
var request = new HttpRequestMessage(new HttpMethod(method), "/");
// Act
var response = await Client.SendAsync(request);
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var content = await response.Content.ReadAsStringAsync();
Assert.Equal("Test response", content);
Assert.False(response.Headers.Contains("Server"), "Should not contain server header");
}
}
We now have a test configured and running using the same Startup
class as our website project SystemUnderTest. It's worth noting that although we have a TestServer
configured as part of the test, it is not actually listening globally - navigating to http://localhost:5000 in a browser while running the test will timeout and not call in to the configured pipeline.
Running the tests
To run our tests we must first ensure our dependencies are installed by calling dotnet restore
in the solution folder. We can then run this test by calling dotnet test
in the root of the test project folder. The test project and other required projects will then be compiled and the tests run, which hopefully should give output similar to the following (depending on which platform you're running on):
Project NetEscapades.AspNetCore.SecurityHeaders.Tests (.NETCoreApp,Version=v1.0) was previously compiled. Skipping compilation.
xUnit.net .NET CLI test runner (64-bit .NET Core win10-x64)
Discovering: NetEscapades.AspNetCore.SecurityHeaders.Tests
Discovered: NetEscapades.AspNetCore.SecurityHeaders.Tests
Starting: NetEscapades.AspNetCore.SecurityHeaders.Tests
Finished: NetEscapades.AspNetCore.SecurityHeaders.Tests
=== TEST EXECUTION SUMMARY ===
NetEscapades.AspNetCore.SecurityHeaders.Tests Total: 2, Errors: 0, Failed: 0, Skipped: 0, Time: 0.281s
Project NetEscapades.AspNetCore.SecurityHeaders.Tests (.NETFramework,Version=v4.5.1) was previously compiled. Skipping
compilation.
xUnit.net .NET CLI test runner (64-bit Desktop .NET win10-x64)
Discovering: NetEscapades.AspNetCore.SecurityHeaders.Tests
Discovered: NetEscapades.AspNetCore.SecurityHeaders.Tests
Starting: NetEscapades.AspNetCore.SecurityHeaders.Tests
Finished: NetEscapades.AspNetCore.SecurityHeaders.Tests
=== TEST EXECUTION SUMMARY ===
NetEscapades.AspNetCore.SecurityHeaders.Tests Total: 2, Errors: 0, Failed: 0, Skipped: 0, Time: 0.257s
SUMMARY: Total: 2 targets, Passed: 2, Failed: 0.
Note that as we targeted both net451
and netcoreapp1.0
in our project.json
, the tests are compiled and run once on each platform. A test execution summary is produced for each, with each platform showing the number of tests discovered and run. The final summary shows the number of platforms that passed and failed.
Note, there is currently a bug in the test runner of linux/OSX where you cannot run tests on both .NET Core and Mono. The provided code will run correctly on Windows, and the .NET Core tests run successfully, but the
net451
tests will not run as described here.
Integration tests over HTTPS
We have successfully configured and run our integration tests using the startup class of our test website. But what if we want to test the behaviour over SSL? Well the simple answer is that we don't necessarily need to - it depends what we are trying to achieve.
For the middleware tests I was running, I needed to verify that the Strict-Transport-Security
header would only be added when running over SSL. This is relatively easy to do, by just updating the TestServer
and HttpClient
to use an HTTPS base url.
So for the TestFixture
approach we just need to change the Client.BaseAddress
uri.
Client.BaseAddress = new Uri("https://localhost:5001");
Where we configure the 'WebHostBuilder' in code, we add a call to UseUrls()
and update the server BaseAddress
:
var hostBuilder = new WebHostBuilder()
.UseUrls("https://localhost:5001")
.ConfigureServices(services => services.AddSecurityHeaders())
.Configure(app =>
{
app.UseSecurityHeadersMiddleware(policy);
app.Run(async context =>
{
await context.Response.WriteAsync("Test response");
});
});
using (var server = new TestServer(hostBuilder))
{
server.BaseAddress = new Uri("https://localhost:5001");
...
}
The pipeline will then proceed as though the request was made over https.
However, it's important to note with this approach that the request is not actually being made over SSL. If you call dotnet run
on the test project you will find you can't establish a secure connection, as we haven't actually configured a certificate.
Again, this is not actually required given the way the TestServer
works, but for completion sake, I'll describe how to add a certificate to your test project.
Configuring Kestral to use SSL
First, we'll need an SSL certificate. For simplicity, I created a self-signed certificate using IIS - this is fine for testing purposes but you'll run into issues if you try to use it in production. I saved this in a tools
subfolder in the solution folder, with the name democertificate.pfx
and password democertificate
.
Next, we configure the test website to use SSL. The project.json
file is updated with the Microsoft.AspNetCore.Server.Kestrel.Https package and we run dotnet restore
:
{
"dependencies": {
"NetEscapades.AspNetCore.SecurityHeaders": {
"version": "1.0.0-*",
"target": "project"
},
"Microsoft.AspNetCore.Server.IISIntegration": "1.0.0-rc2-final",
"Microsoft.AspNetCore.Server.Kestrel": "1.0.0-rc2-final",
"Microsoft.AspNetCore.Server.Kestrel.Https": "1.0.0-rc2-final"
},
}
Finally, we update the static void Main()
entry point to use our new certificate when initialising the app.
public static void Main(string[] args)
{
var certificatePath = Path.GetFullPath(
Path.Combine(Directory.GetCurrentDirectory(), "..", "..", "tools", "democertificate.pfx"));
var host = new WebHostBuilder()
.UseKestrel(options => options.UseHttps(certificatePath, "democertificate"))
.UseUrls("http://localhost:5000", "https://localhost:5001")
.UseIISIntegration()
.UseStartup<Startup>()
.Build();
host.Run();
}
First we build the path (using Path.Combine
to avoid environment specific path separators so it works correctly in Windows and linux) to the self-signed certificate. This is passed to the extension method UseHttps
and the urls are updated to listen to both HTTP and HTTPS.
With that, we are able to make requests to our test app over SSL, though we will get a warning about the certificate being self-signed.
Obviously there is no reason why you can't configure your integration tests with the same demo certificate. However as demonstrated previously, that's generally not actually required to test most of the behaviour of requests.
Summary
This post demonstrated how to create a test project to perform functional and integration tests with xUnit and the TestServer
class in the Microsoft.AspNetCore.TestHost package. It showed two ways to build up the test server, including using the Startup
class of a test website. This allows you to send requests and receive responses as though running the app using host.Run()
. Finally, it showed how to perform integration tests over SSL. Happy testing!