Create fake user for ASP.NET Core integration tests
After getting done with fake users for ASP.NET Core controller unit tests I wanted to make fake users available also in integration tests. It took some inventing and hacking but I made it work. This blog post shows you how to create fake users for ASP.NET Core integration tests and write effective extension methods to keep integration tests shorter.
Source code available! Thin and clean demo solution with ASP.NET Core testing examples is available in my Github repository gpeipman/AspNetCoreTests.
Getting started with integration tests
Let’s start with default integration test for home controller. Here is the integration test to test Index and Privacy actions of HomeController.
public class HomeControllerTests : TestBase
{
public HomeControllerTests(TestApplicationFactory<FakeStartup> factory) : base(factory)
{
}
[Theory]
[InlineData("/")]
[InlineData("/Home/Privacy")]
public async Task Get_EndpointsReturnSuccessAndCorrectContentType(string url)
{
// Arrange
var client = Factory.CreateClient();
// Act
var response = await client.GetAsync(url);
// Assert
response.EnsureSuccessStatusCode();
Assert.Equal("text/html; charset=utf-8", response.Content.Headers.ContentType.ToString());
}
}
If there’s no authentication and authorization enabled then this test will pass.
Adding authentication to web application
Let’s enable authentication in web application and configure it to use whatever authentication we need to support. My demo application uses cookie authentication and it is enabled in Configure() and ConfigureServices() method of Startup class.
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
public virtual void ConfigureServices(IServiceCollection services)
{
services.AddDbContext<DemoDbContext>(options =>
{
options.UseSqlite(Configuration.GetConnectionString("DefaultConnection"));
});
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme,
options =>
{
options.LoginPath = new PathString("/auth/login");
options.AccessDeniedPath = new PathString("/auth/denied");
});
services.AddAuthorization();
services.AddControllersWithViews();
services.AddScoped<ICustomerService, CustomerService>();
}
public virtual void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Home/Error");
}
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
});
}
For testing purposes let’s add AuthorizeAttribute to HomeController.
[Authorize]
public class HomeController : Controller
{
public IActionResult Index()
{
return View();
}
public IActionResult Privacy()
{
return View();
}
}
Integration test may succeed because by default it follows redirects and if we are redirected to login page then we get back HTTP response 200. To make sure that client doesn’t follow redirects we have to modify our test.
[Theory]
[InlineData("/")]
[InlineData("/Home/Privacy")]
public async Task Get_EndpointsReturnSuccessAndCorrectContentType(string url)
{
// Arrange
var client = Factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
// Act
var response = await client.GetAsync(url);
// Assert
response.EnsureSuccessStatusCode();
Assert.Equal("text/html; charset=utf-8", response.Content.Headers.ContentType.ToString());
}
Integration test will now fail.
Adding fake authentication for integration tests
With controller unit tests it was easy to have authenticated user like I demonstrated in my blog post Create fake user for ASP.NET Core controller tests. Integration tests are different beast and it’s not so easy to get under the skin.
Here’s the plan based on official documentation:
- When application is started in test host add new authentication scheme (let’s call it Test).
- Configure authentication scheme to use custom authentication handler (TestAuthHandler) that creates fake identity for integration tests.
- Extend test client to use authentication header with scheme Test.
Let’s start with authentication handler.
public class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public TestAuthHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock) : base(options, logger, encoder, clock)
{
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var claims = new[]
{
new Claim(ClaimTypes.Name, "Test user"),
new Claim(ClaimTypes.NameIdentifier, Guid.NewGuid().ToString())
};
var identity = new ClaimsIdentity(claims, "Test");
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, "Test");
var result = AuthenticateResult.Success(ticket);
return Task.FromResult(result);
}
}
This handler creates fake user when HandleAuthenticateAsync() is called. We don’t need any additional hacks to make ASP.NET Core application use this fake identity. Our integration test needs also some changes because of authentication handler.
[Theory]
[InlineData("/")]
[InlineData("/Home/Privacy")]
public async Task Get_EndpointsReturnSuccessAndCorrectContentType(string url)
{
// Arrange
var factory = Factory.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
services.AddAuthentication("Test")
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>("Test", options => { });
});
});
var client = factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Test");
// Act
var response = await client.GetAsync(url);
// Assert
response.EnsureSuccessStatusCode();
Assert.Equal("text/html; charset=utf-8", response.Content.Headers.ContentType.ToString());
}
Now our integration test will pass.
Moving fake authentication to extension methods
Although our integration test works fine it looks ugly and in long run this approach will end up with huge amount of duplicated code as we need authenticated calls in many other integration tests too. This is why I move all authentication related code to extension methods.
public static class WebApplicationFactoryExtensions
{
public static WebApplicationFactory<T> WithAuthentication<T>(this WebApplicationFactory<T> factory) where T : class
{
return factory.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
services.AddAuthentication("Test")
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>("Test", options => { });
});
});
}
public static HttpClient CreateClientWithTestAuth<T>(this WebApplicationFactory<T> factory) where T : class
{
var client = factory.WithAuthentication().CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Test");
return client;
}
}
Using these extension methods we can make our integration test way shorter.
[Theory]
[InlineData("/")]
[InlineData("/Home/Privacy")]
public async Task Get_EndpointsReturnSuccessAndCorrectContentType(string url)
{
// Arrange
var client = Factory.CreateClientWithTestAuth();
// Act
var response = await client.GetAsync(url);
// Assert
response.EnsureSuccessStatusCode();
Assert.Equal("text/html; charset=utf-8", response.Content.Headers.ContentType.ToString());
}
We can use same approach for authenticated user also in other integration tests.
NB! Solution shown here fits well for simpler applications that doesn’t depend heavily on small details of authentication and authorization. For more complex scenarious it’s possible to use this sample as a base to get started.
Wrapping up
Faking identities for ASP.NET Core integration tests wasn’t easy and straightforward. We had to configure web application through web application factory and custom authentication handler to make get fake user to application. This method is not perfect for applications that depend on small details of authentication or authorization. But as far as only user claims are used this solution works very well. In the end we moved user related code to extension methods and achieved shorter integration test.
Pingback:Dew Drop – January 14, 2020 (#3111) | Morning Dew
Pingback:The Morning Brew - Chris Alcock » The Morning Brew #2910
Great post, just what I needed right now. But I have noticed something strange. I use a custom middleware to check if the user is authenticated and when I use this guide to fake my user I can see that in my middleware there is no fake user set in the HttpContext.User. If I skip my middleware and look what the HttpContext.User looks like in the receiving action in my controller I can see that my user is there.
So any idea why the user dosen’t show up in the middleware context?
Please try out if your middleware runs before user gets injected to HttpContext by fake authentication handler.
How do I test that?
First make sure your middleware is added to request pipeline after authentication (app.UseAuthentication()).
You can run integration tests in debug mode. Put one breakpoint to fake auth handler and another one to your middleware. Run test in debug mode and see which breakpoint gets hit first.
Thanks now it works!
Thank you!
Hmm, I am using a WebApi with Jwt authorization and this won’t work.
My Authorize attribute won’t trigger the Test Auth from this example.
Do I need to shut it off inside the test?
services.AddAuthentication(options =>
{
options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.IncludeErrorDetails = true;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidIssuer = jwtAppSettingOptions[nameof(JwtIssuerOptions.Issuer)],
ValidAudience = jwtAppSettingOptions[nameof(JwtIssuerOptions.Audience)],
IssuerSigningKey = signingKey,
TokenDecryptionKey = signingKey,
ClockSkew = TimeSpan.Zero
};
options.RequireHttpsMetadata = true;
options.SaveToken = true;
});
Great and helpful post, Gunnar. However, this line near the end of the post does not build (for multiple reasons):
var client = Factory.CreateClientWithTestAuth();
The method needs parameters, but I’m having trouble figuring out what to put there if you can clarify. Thanks.