FastEndpoints is an alternative web framework built on ASP.NET Core primitives to reinforce the Request-Endpoint-Response (REPR) Design pattern. In summary, FastEndpoints lets you focus on building endpoint-focused APIs instead of dealing with the ceremony of something like MVC or the potential analysis-paralysis of Minimal API decisions.
I think the framework does a lot correctly and can help teams accelerate creating APIs without dealing with the plumbing and code shuffling that typically accompanies large applications. That said, confidence is an essential part of any complex system, and that confidence naturally comes in the form of tests.
This post will explore testing FastEndpoints using a few community NuGet packages and some helper classes.
Your First FastEndpoint Endpoint
When starting a FastEndpoints project, it’s best to start with an empty ASP.NET Core template. You’ll also need to add the FastEndpoints
NuGet package to your web project. Your version number might differ from the one in this post.
<PackageReference Include="FastEndpoints" Version="5.5.0.3-beta" />
You can set up your Program.cs
file after you’ve installed the Fast Endpoints package. Fast Endpoints is secure by default, so we allow access to all endpoints for this demo. Of course, we’ll only have one for this demo, but feel free to add as many as you’d like.
using EndpointsSample;
using FastEndpoints;
using Microsoft.AspNetCore.Authentication;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<ISystemClock, SystemClock>();
builder.Services.AddFastEndpoints();
var app = builder.Build();
app.UseFastEndpoints(c => {
// everything is anonymous for this sample
c.Endpoints.Configurator = epd => epd.AllowAnonymous();
});
app.Run();
public partial class Program {}
Next, in an Api
folder, let’s add a Root
class. This class will be our root endpoint, accessible at /
.
using FastEndpoints;
using Microsoft.AspNetCore.Authentication;
namespace EndpointsSample.Api;
public class Root : EndpointWithoutRequest<string>
{
public ISystemClock Clock { get; set; }
= default!;
public override void Configure()
{
Get("/");
}
public override async Task HandleAsync(CancellationToken ct)
{
var message = $"Hello, World @ {Clock.UtcNow:hh:mm:ss tt}";
if (User.Identity?.IsAuthenticated == true)
{
message += $"\n{User.Identity.Name}";
}
await SendStringAsync(
message,
cancellation: ct
);
}
}
Running your web application, you should now be able to hit the root endpoint and retrieve the following result (your time may differ).
Hello, World @ 05:03:00 PM
Let’s write some unit tests!
Testing FastEndpoints Using XUnit
Our goal is to be able to test each endpoint in isolation. The philosophy of the REPR pattern is that all endpoints should be testable in isolation, so it should be straightforward.
Let’s start by adding a few NuGet packages to a new XUnit test project. The most important being Microsoft.AspNetCore.Mvc.Testing
. Don’t worry; while the name mentions Mvc
, it is a general-purpose testing library for ASP.NET Core. You’ll also need to reference your previous web project.
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="7.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.2" />
<PackageReference Include="xunit" Version="2.4.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="3.1.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
Add the following file to your test project, and feel free to replace the namespace and move the classes to any file structure you’d like. These classes will help us write concise tests later.
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
namespace EndpointSample.Tests.Helpers;
public class App : WebApplicationFactory<Program>
{
private readonly ServiceDescriptor[] _overrides;
public App(params ServiceDescriptor[]? overrides)
{
_overrides = overrides ?? Array.Empty<ServiceDescriptor>();
}
protected override IHost CreateHost(IHostBuilder builder)
{
builder.ConfigureServices(services =>
{
foreach (var service in _overrides)
{
services.Replace(service);
}
});
return base.CreateHost(builder);
}
}
public static class ClaimsPrincipalExtensions
{
public static void WithIdentity(this ClaimsPrincipal user, params Claim[] claims)
=> user.AddIdentity(new ClaimsIdentity(claims, "test_auth"));
}
public class TestSystemClock : ISystemClock
{
public DateTimeOffset UtcNow { get; set; }
= DateTimeOffset.UtcNow;
public void SetTime(int hour, int minutes, int seconds = 0)
{
var now = DateTimeOffset.UtcNow.Date;
UtcNow = new DateTimeOffset(
now.Year,
now.Month,
now.Day,
hour,
minutes,
seconds,
TimeSpan.Zero
);
}
}
Now, let’s write a test and explain what’s happening.
using EndpointSample.Tests.Helpers;
using Microsoft.AspNetCore.Authentication;
using Xunit;
using Xunit.Abstractions;
using static Microsoft.Extensions.DependencyInjection.ServiceDescriptor;
namespace EndpointSample.Tests.Api;
public class Root
{
private readonly ITestOutputHelper _output;
private App App { get; }
private TestSystemClock Clock { get; } = new();
public Root(ITestOutputHelper output)
{
_output = output;
App = new(
Scoped<ISystemClock>(_ => Clock)
);
}
[Fact]
public async Task CanGetRootEndpoint()
{
// Arrange
Clock.SetTime(0, 0);
// Act
var client = App.CreateClient();
var result = await client.GetStringAsync("/");
// Assert
Assert.Equal("Hello, World @ 12:00:00 AM", result);
_output.WriteLine(result);
}
}
Each unit test will spin up an instance of our web application, allowing us to call any endpoint using an HTTP client. Our App
class takes in any dependencies (Singleton, Scoped, or Transient) and replaces any instance of that registration in our ServiceCollection
. In the case of this test, we’re replacing our instance of ISystemClock
with a TestSystemClock
, which allows us to control time.
We want to keep these instances as part of our unit test class, which enables us to massage state to pass a test. Given the nature of FastEndpoints, I feel this also makes more sense, as each endpoint could have its own set of dependencies you’ll need to swap out.
You should now have a passing test!
Manipulating Auth for FastEndpoints
Adding the ability to bypass authentication is a bit more complex, but not by much. Your web application will need an implementation for UserOverrideMiddleware
. Add the following code to that project.
using System.Security.Claims;
namespace EndpointsSample;
public class UserOverrideMiddleware : IMiddleware
{
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
var userOverride = context.RequestServices.GetService<UserOverride>();
if (userOverride is { Value : {} })
{
context.User = userOverride.Value;
}
await next(context);
}
}
public record UserOverride(ClaimsPrincipal Value)
{
public static ServiceDescriptor With(ClaimsPrincipal user)
=> ServiceDescriptor.Scoped<UserOverride>(_ => new(user));
};
The middleware will look for a UserOverride
in the services container. If it is present, then we’ll override the currently authenticated user. It’s important to register this middleware before the authentication and authorization middleware.
You’ll also need to modify your Program.cs
file to look like the following. Note we only register the UserOverrideMiddleware
for development. You don’t want this middleware in production.
using EndpointsSample;
using FastEndpoints;
using Microsoft.AspNetCore.Authentication;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<ISystemClock, SystemClock>();
builder.Services.AddScoped<UserOverrideMiddleware>();
builder.Services.AddFastEndpoints();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseMiddleware<UserOverrideMiddleware>();
}
app.UseFastEndpoints(c => {
// everything is anonymous for this sample
c.Endpoints.Configurator = epd => epd.AllowAnonymous();
});
app.Run();
public partial class Program {}
Let’s modify our test to allow for supplying a user. Then, back in your unit test project, adjust your test class to include a new User
property and a new test using it.
using System.Security.Claims;
using EndpointSample.Tests.Helpers;
using EndpointsSample;
using Microsoft.AspNetCore.Authentication;
using Xunit;
using Xunit.Abstractions;
using static Microsoft.Extensions.DependencyInjection.ServiceDescriptor;
namespace EndpointSample.Tests.Api;
public class Root
{
private readonly ITestOutputHelper _output;
private App App { get; }
private TestSystemClock Clock { get; } = new();
private ClaimsPrincipal User { get; } = new();
public Root(ITestOutputHelper output)
{
_output = output;
App = new(
UserOverride.With(User),
Scoped<ISystemClock>(_ => Clock)
);
}
[Fact]
public async Task CanGetRootWithUserEndpoint()
{
// Arrange
Clock.SetTime(0, 0);
User.WithIdentity(
new Claim(ClaimTypes.Name, "Khalid")
);
// Act
var client = App.CreateClient();
var result = await client.GetStringAsync("/");
// Assert
Assert.Equal("Hello, World @ 12:00:00 AM\nKhalid", result);
_output.WriteLine(result);
}
}
Running your test now, you should get a passing test along with the following output result.
Hello, World @ 12:00:00 AM
Khalid
Nice! Now you have a test harness for all your new FastEndpoint endpoints. So you can keep your tests readable and straight to the point.
Conclusion
Building on ASP.NET Core primitives allows us to exercise FastEndpoints like we would Minimal API endpoints or MVC controllers. You could certainly adapt this approach to any ASP.NET Core web framework, and I think it helps keep your tests from devolving into setup soup. You may also want to consider moving the creation of your App
instance into a class fixture if you find instantiating the dependencies add considerable time to your unit testing runs.
If you’d like to see a complete sample of this post, please go to my GitHub repository. As always, thanks for reading, and be sure to let me know if you found this post helpful. Cheers!