Alexander Zeitler

Alexander Zeitler

Integration testing an ASP.NET Core 7 app with ASP.NET Identity using Alba

Published on Monday, March 20, 2023

Photo by Jeswin Thomas on Unsplash

Wiring things up

As you might have noticed on Twitter that I started to evaluate Wolverine (formerly known as "JasperFx") to become the Message Bus for my SaaS apps (e.g. kliento).

One of the benefits of Wolverine: it's part of the "Critter Stack for .NET".

If you want to understand what that means, I recommend this blog post by Jeremy D. Miller.

Another thing you might know about me (from here, here or here), is that I'm using HTMX instead of SPA libraries like React.

This leads to the fact I'm using ASP.NET Identity for user management.

Now I wanted to refactor an ASP.NET Core 7 app with ASP.NET Identity to use Wolverine and being integration tested using Alba.

In a first step, I started to refactor my tests to Alba, so I could integration test async operations as described in the post by Jeremy and be prepared for a second refactoring to Wolverine.

And that's where things got interesting (read: went south).

The easiest way to get a test set up using Alba is possibly this:

[Fact]
public async Task should_say_hello_world()
{
    // Alba will automatically manage the lifetime of the underlying host
    await using var host = await AlbaHost.For<global::Program>();
    
    // This runs an HTTP request and makes an assertion
    // about the expected content of the response
    await host.Scenario(_ =>
    {
        _.Get.Url("/");
        _.ContentShouldBe("Hello World!");
        _.StatusCodeShouldBeOk();
    });
}

That way the application under test will be bootstrapped in the exact same way like your production app.

Now you might want to run some tests in parallel using different databases (as you might have guessed, I'm using Marten) with dynamic connection strings being generated during test setup (note to self: this could be another blog post).

This means you need to have the same middleware pipeline etc. but with different parameters.

This all works pretty nice using Marten and other stuff but when it came to modifying the configuration for ASP.NET Identity at runtime or to add it with different configuration settings errors like these happened:

InvalidOperationException: Scheme already exists: Identity.Application Microsoft.AspNetCore.Authentication.AuthenticationOptions.AddScheme(string name, Action configureBuilder)...

So, I was in need of a different way to bootstrap my application under test - and in the end, the whole application (confirming my opinion that real world applications won't use the minimal APIs bootstrapping approach 🫢).

Lucky me, the Alba "Getting started" guide mentioned this blog post by Andrew Lock, in which he describes the evolution of ASP.NET Core application bootstrapping since the beginning of ASP.NET Core (if you follow all linked posts - you better grab some coffee before you start reading).

The solution I came up with is this - we'll start with a test asserting that a redirect to the login page happens when trying to access the application as an anonymous user:

public class When_calling_app_as_anonymous_user : IAsyncLifetime
{
  private AlbaHost? _host;

  public async Task InitializeAsync()
  {
    var testServices = new TestServices();
    var testConfiguration = await testServices.GetTestConfigurationRoot();
    var hostBuilder = ConfigureHost.GetHostBuilder(testConfiguration);
    _host = new AlbaHost(hostBuilder);

    await _host.MigrateIdentityDatabase();
  }

  [Fact]
  public async Task should_redirect_to_login()
  {
    await _host?.Scenario(
      _ =>
      {
        _.Get.Url("/");
        _.Header("Location")
          .ShouldHaveValues("http://localhost/Identity/Account/Login?ReturnUrl=%2F");
        _.StatusCodeShouldBe(302);
      }
    );
  }

  public async Task DisposeAsync() => await _host.DisposeAsync();
}

TestServices does a ton of stuff like handling the database bootstrapping/seeding mentioned above (yes, this really requires a blog post on it's own).

GetTestConfigurationRoot(), as it's name suggests builds an in-memory configuration for things like databases, including the ASP.NET Identity database (each test has it's own test event store and identity database).

The key thing for this post however is ConfigureHost.GetHostBuilder(testConfiguration) which returns an IHostBuilder instance which Alba is happily using to create an IAlbaHost instance from:

public static class ConfigureHost
{
  public static IHostBuilder GetHostBuilder(
    IConfigurationRoot configuration
  )
  {
    var hostBuilder = Host.CreateDefaultBuilder();

    hostBuilder.AddLogging();
    new SerilogLoggerFactory(Log.Logger)
      .CreateLogger<Program>();
    
    hostBuilder.ConfigureWebHostDefaults(
      builder =>
      {
        builder.ConfigureServices(collection => collection.ConfigureAppServices(configuration));
        builder.UseConfiguration(configuration);
        builder.Configure(
          (
            context,
            app
          ) =>
          {
            if (!context.HostingEnvironment.IsDevelopment())
            {
              app.UseExceptionHandler("/Error");
              app.UseHsts();
            }

            app.UseStaticFiles();

            app.UseRouting();

            app.UseAuthentication();
            app.UseAuthorization();

            app.UseEndpoints(
              endpoints =>
              {
                endpoints.MapControllers();
                endpoints.MapRazorPages();
                endpoints.MapDefaultControllerRoute()
                  .RequireAuthorization();
              }
            );
          }
        );
      }
    );

    return hostBuilder;
  }
}

This creates an ASP.NET Host with MVC, Controllers and Identity set up.

The magic happens in collection.ConfigureAppServices(configuration)) - I removed the event store and other configuration stuff for brevity:

public static class ConfigureServices
{
  public static IServiceCollection ConfigureAppServices(
    this IServiceCollection services,
    IConfigurationRoot configuration
  )
  {
    var identityTestConnectionString = configuration.GetConnectionString("Identity");
    services.AddDbContext<IdentityDbContext>(
      options =>
        options.UseNpgsql(identityTestConnectionString)
    );

    services.AddDefaultIdentity<AppUser>(options => options.SignIn.RequireConfirmedAccount = false)
      .AddEntityFrameworkStores<IdentityDbContext>();

    services.AddControllersWithViews()
      .AddRazorRuntimeCompilation();
    services.Configure<RazorViewEngineOptions>(
      o => o.ViewLocationExpanders.Add(new FeatureFolderLocationExpander())
    );
    services.AddMvc();

    return services;
  }
}

That way I can spin up concurrent async integration tests (the async part has to be validated when Wolverine is added to the game).

What's left is the application itself to be bootstrapped in the exact same way but with different configuration:

var configuration = new ConfigurationBuilder()
  .AddEnvironmentVariables()
  .AddCommandLine(args)
  .AddJsonFile("appsettings.json")
  .AddJsonFile($"appsettings.{Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")}.json", optional: true)
  .Build();

var builder = ConfigureHost.GetHostBuilder(configuration);
builder
  .Build()
  .Run();

Well, that's it and works fine so far.

Maybe there's an easier / better solution - if so, please let me know!

What are your thoughts about "Integration testing an ASP.NET Core 7 app with ASP.NET Identity using Alba"?
Drop me a line - I'm looking forward to your feedback!
Imprint | Privacy