Building an ASP.NET Core Starter App on MacOS (Automated Testing)

In the previous post, we completed our initial configuration of the Identity configuration, and added basic authentication and authorization to the example application.  Like our previous posts, source code (with better formatting!) for this post can be found at https://github.com/jrodenbostel/example-web-application.  There will be revision for all of the changes mentioned in this post.  In this post, we’ll cover adding automated tests to our application.  This topic is where I personally had the most difficult time finding examples – as mentioned before, the struggles were specifically related to mocking Identity related services and getting integration tests up and running on secured methods and against the AccountController.

To start, we need to add an automated test project to our solution.  In order to do that, I had to move the location of the project root and rearrange the repository (and solution) so that it included a root solution with two sub-projects: one for the application, and one for tests. The first revision you’ll see in the repository is the result of the projects being rearranged into a solution and the test project being added.  I used a project template to create the new project. You’ll see it is named “ExampleWebApplicationTest” and is packaged with the xUnit library unit testing and Moq library for creating mock objects.

After creating the test project, I created a reference to ExampleWebApplication from ExampleWebApplicationTest so the test project can use the libraries included in the ExampleWebApplication project.

Our goal in this post is to create a suite of unit and integration tests that exercise the logic in our ExampleWebApplication project.  Since our integration tests will leverage an in-memory database and will need to “start up” like a normal application, we need to update the test project’s project configuration file to use the web sdk.

/ExampleWebApplicationTest/ExampleWebApplicationTest.csproj:

<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.2.0" />
<PackageReference Include="xunit" Version="2.4.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.0" />
<PackageReference Include="coverlet.collector" Version="1.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ExampleWebApplication\ExampleWebApplication.csproj" />
</ItemGroup>
</Project>

After that, we’ll get started by installing some new packages.  One library of note in the following list is “MockQueryable”, which is a set of helper classes for creating mocks of IQueryable collections (https://github.com/romantitov/MockQueryable).  Very handy.  From the command line:

dotnet add package Microsoft.AspNetCore.Mvc.Testing
dotnet add package Microsoft.EntityFrameworkCore.InMemory
dotnet add package Microsoft.EntityFrameworkCore.Sqlite
dotnet add package Microsoft.EntityFrameworkCore.Tools
dotnet add package Moq
dotnet add package MockQueryable.Moq
dotnet add package Microsoft.AspNet.WebApi.Client

Next, let’s add a “Controllers” directory and create our first unit test file for the AccountController.  This file has TONS of examples and workarounds.  One of the interesting additions is the AccountControllerWrappers method, which wraps difficult/impossible to mock methods so that they can be mocked.  Note that the code this wrapper uses is largely Microsoft framework code that I don’t feel is worth testing. When you ask yourself how to mock normal controller interactions, Identity-specific services (such as SignInManager and UserManager), or EntityFramework-related classes, this is the place to look – all of these were things I struggled with and this test class contains examples of working with each.

ExampleWebApplicationTest/Controllers/AccountControllerTest.cs

using System;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using ExampleWebApplication.Models;
using ExampleWebApplication.Models.ViewModels;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.UI.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace ExampleWebApplication.Controllers
{
public class AccountController : Controller
{
private readonly UserManager<User> _userManager;
private readonly SignInManager<User> _signInManager;
private readonly ILogger _logger;
private readonly IEmailSender _emailSender;
private readonly IConfiguration _configuration;
public IAccountControllerWrappers Wrappers { get; set; }
public AccountController(
UserManager<User> userManager,
SignInManager<User> signInManager,
ILogger<AccountController> logger,
IEmailSender emailSender,
IConfiguration configuration)
{
_userManager = userManager;
_signInManager = signInManager;
_logger = logger;
_emailSender = emailSender;
_configuration = configuration;
Wrappers = new AccountControllerWrappers();
}
[HttpGet]
[AllowAnonymous]
public IActionResult Register(string returnUrl = null)
{
ViewData["ReturnUrl"] = returnUrl;
return View();
}
[HttpPost]
[AllowAnonymous]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Register(RegisterViewModel model, string returnUrl = null)
{
if (!Convert.ToBoolean(_configuration["EnableRegistration"]))
{
// If we got this far, something failed, redisplay form
ViewData["Error"] = "Registration disabled!";
return View();
}
ViewData["ReturnUrl"] = returnUrl;
if (ModelState.IsValid)
{
var user = new User {UserName = model.Email, Email = model.Email};
var result = await _userManager.CreateAsync(user, model.Password);
if (result.Succeeded)
{
_logger.LogInformation("User created a new account with password.");
var code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
var callbackUrl = Wrappers.GetActionLink(Url, nameof(ConfirmEmail), user, code);
await _emailSender.SendEmailAsync(model.Email, "Confirm your email",
$"Please confirm your account by <a href='{HtmlEncoder.Default.Encode(callbackUrl)}'>clicking here</a>.");
TempData["Information"] = "Confirmation email sent.";
_logger.LogInformation("User created a new account with password.");
return RedirectToLocal(returnUrl);
}
AddErrors(result);
}
// If we got this far, something failed, redisplay form
return View(model);
}
[HttpGet]
[AllowAnonymous]
public async Task<IActionResult> ConfirmEmail(string userId, string code)
{
var user = await _userManager.FindByIdAsync(userId);
var result = await _userManager.ConfirmEmailAsync(user, code);
if (result.Succeeded)
{
TempData["Information"] = "Registration confirmed!";
return RedirectToAction(nameof(Login));
}
TempData["Error"] = "Something went wrong.";
return RedirectToAction(nameof(Register));
}
[HttpGet]
[AllowAnonymous]
public async Task<IActionResult> Login(string returnUrl = null)
{
// Clear the existing external cookie to ensure a clean login process
await Wrappers.SignOutAsync(HttpContext);
ViewData["ReturnUrl"] = returnUrl;
return View();
}
[HttpPost]
[AllowAnonymous]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Login(LoginViewModel model, string returnUrl = null)
{
ViewData["ReturnUrl"] = returnUrl;
if (ModelState.IsValid)
{
// This doesn't count login failures towards account lockout
// To enable password failures to trigger account lockout, set lockoutOnFailure: true
var result = await _signInManager.PasswordSignInAsync(model.Email, model.Password, model.RememberMe,
lockoutOnFailure: false);
if (result.Succeeded)
{
_logger.LogInformation("User logged in.");
return RedirectToLocal(returnUrl);
}
if (result.IsLockedOut)
{
_logger.LogWarning("User account locked out.");
return RedirectToAction(nameof(Lockout));
}
else
{
ModelState.AddModelError(string.Empty, "Invalid login attempt.");
return View(model);
}
}
// If we got this far, something failed, redisplay form
return View(model);
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Logout()
{
await _signInManager.SignOutAsync();
_logger.LogInformation("User logged out.");
return RedirectToAction(nameof(HomeController.Index), "Home");
}
[HttpGet]
[AllowAnonymous]
public IActionResult Lockout()
{
return View();
}
private void AddErrors(IdentityResult result)
{
foreach (var error in result.Errors)
{
ModelState.AddModelError(string.Empty, error.Description);
}
}
private IActionResult RedirectToLocal(string returnUrl)
{
if (returnUrl != null && Wrappers.IsLocalUrl(Url, returnUrl))
{
return Redirect(returnUrl);
}
else
{
return RedirectToAction(nameof(HomeController.Index), "Home");
}
}
[HttpGet]
[AllowAnonymous]
public IActionResult ForgotPassword()
{
return View();
}
[HttpPost]
[AllowAnonymous]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ForgotPassword(ForgotPasswordViewModel model)
{
var user = await _userManager.FindByEmailAsync(model.Email);
if (user != null)
{
var code = await _userManager.GeneratePasswordResetTokenAsync(user);
var callbackUrl = Wrappers.GetActionLink(Url, nameof(ResetPassword), user, code);
await _emailSender.SendEmailAsync(model.Email, "Reset your password",
$"Please reset your password by <a href='{HtmlEncoder.Default.Encode(callbackUrl)}'>clicking here</a>.");
TempData["Information"] = "Password reset email sent.";
return RedirectToAction(nameof(Login));
}
TempData["Error"] = "No user with that email address found.";
return RedirectToAction(nameof(Register));
}
[HttpGet]
[AllowAnonymous]
public async Task<IActionResult> ResetPassword(string userId, string code)
{
var user = await _userManager.FindByIdAsync(userId);
if (user != null)
{
var viewModel = new ResetPasswordViewModel {Email = user.Email, Code = code};
return View(viewModel);
}
TempData["Error"] = "Invalid confirmation code.";
return RedirectToAction(nameof(Register));
}
[HttpPost]
[AllowAnonymous]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ResetPassword(ResetPasswordViewModel model)
{
var user = await _userManager.FindByEmailAsync(model.Email);
if (user != null)
{
await _userManager.ResetPasswordAsync(user, model.Code, model.Password);
await _emailSender.SendEmailAsync(model.Email, "Password reset notification",
$"Your password has been reset successfully.");
TempData["Information"] = "Password reset successfully.";
return RedirectToAction(nameof(Login));
}
TempData["Error"] = "No user with that email address found.";
return RedirectToAction(nameof(Register));
}
public IActionResult ExternalLogin()
{
throw new NotImplementedException();
}
}
public class AccountControllerWrappers : IAccountControllerWrappers
{
public async Task SignOutAsync(HttpContext context)
{
await context.SignOutAsync(IdentityConstants.ExternalScheme);
}
public string GetActionLink(IUrlHelper helper, string action, User user, string code)
{
return helper.ActionLink(action,
values: new {userId = user.Id, code = code});
}
public bool IsLocalUrl(IUrlHelper helper, string url)
{
return helper.IsLocalUrl(url);
}
}
public interface IAccountControllerWrappers
{
public Task SignOutAsync(HttpContext context);
public string GetActionLink(IUrlHelper helper, string action, User user, string code);
public bool IsLocalUrl(IUrlHelper helper, string url);
}
}

Setting up unit testing is the easy part! Configuring our test project to also execute end-to-end integration tests involves a little more work. Let’s start by creating a test version of our Startup file that extends the Startup configuration from the ExampleWebApplication project.  You should notice here that we are swapping out our database configuration for one that uses the in-memory SQLite implementation.  This is useful for keeping our automated tests away from altering data in any of our “live” databases and prevents a build-time dependency on database availability.

ExampleWebApplicationTest/TestStartup.cs

using ExampleWebApplication;
using ExampleWebApplication.Data;
using ExampleWebApplication.Models;
using ExampleWebApplication.Services;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.UI.Services;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace ExampleWebApplicationTest
{
public class TestStartup : Startup
{
public TestStartup(IConfiguration configuration) : base(configuration)
{
}
public override void ConfigureServices(IServiceCollection services)
{
services.AddControllersWithViews();
const string connectionString = "DataSource=:memory:";
var connection = new SqliteConnection(connectionString);
connection.Open();
services.AddDbContext<DefaultContext>(options =>
options.UseSqlite(connection));
services.AddDbContext<IdentityContext>(options =>
options.UseSqlite(connection));
services.AddIdentity<User, IdentityRole>(config => { config.SignIn.RequireConfirmedEmail = false; })
.AddEntityFrameworkStores<IdentityContext>()
.AddDefaultTokenProviders();
services.AddTransient<IEmailSender, MockEmailSender>();
services.AddAuthorization();
}
}
public class MockEmailSender : EmailSender
{
}
}

When integration testing, I prefer to continually reset the database to a known state before my tests run.  I created a test version of the SeedData process to accomplish this, and you’ll notice calls in the classes that follow that run our migration process before seeding our database.

using System;
using System.Linq;
using ExampleWebApplication.Models;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
namespace ExampleWebApplicationTest
{
public static class TestSeedData
{
public const string TestEmail = "justin@rodenbostel.com";
public const string TestPassword = "Test_password1234";
public static void Initialize(IServiceProvider serviceProvider)
{
SeedRoles(serviceProvider);
SeedUsers(serviceProvider);
}
private static void SeedUsers(IServiceProvider serviceProvider)
{
var userManager = serviceProvider.GetRequiredService<UserManager<User>>();
foreach (var user in userManager.Users.ToList())
{
userManager.DeleteAsync(user).Wait();
}
if (userManager.FindByEmailAsync(TestEmail).Result == null)
{
var user = new User {UserName = TestEmail, Email = TestEmail, EmailConfirmed = true};
var result = userManager.CreateAsync(user, TestPassword).Result;
if (result.Succeeded)
{
userManager.AddToRoleAsync(user, "Admin").Wait();
}
}
}
private static void SeedRoles(IServiceProvider serviceProvider)
{
var roleManager
= serviceProvider.GetRequiredService<RoleManager<IdentityRole>>();
foreach (var role in roleManager.Roles.ToList())
{
roleManager.DeleteAsync(role).Wait();
}
if (!roleManager.RoleExistsAsync("Admin").Result)
{
var role = new IdentityRole {Name = "Admin"};
var roleResult = roleManager.CreateAsync(role).Result;
}
}
}
}

Now let’s create a WebApplicationFactory that we can use to run our app during the tests:

ExampleWebApplicationTest/TestWebApplicationFactory.cs

using System;
using ExampleWebApplication.Data;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace ExampleWebApplicationTest
{
public class TestWebApplicationFactory<TStartup>
: WebApplicationFactory<TestStartup>
{
protected override IHostBuilder CreateHostBuilder()
{
var builder = Host.CreateDefaultBuilder().ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<TestStartup>().UseTestServer();
});
return builder;
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
// Build the service provider.
var serviceProvider = services.BuildServiceProvider();
// Create a scope to obtain a reference to the database
// context (ApplicationDbContext).
using (var scope = serviceProvider.CreateScope())
{
var scopedServices = scope.ServiceProvider;
var identityDb = scopedServices.GetRequiredService<IdentityContext>();
var logger = scopedServices
.GetRequiredService<ILogger<TestWebApplicationFactory<TestStartup>>>();
// Ensure the database is created.
identityDb.Database.Migrate();
identityDb.Database.EnsureCreated();
try
{
// Seed the database with test data.
TestSeedData.Initialize(serviceProvider);
}
catch (Exception ex)
{
logger.LogError(ex, "An error occurred seeding the " +
"database with test messages. Error: {Message}", ex.Message);
}
}
});
}
}
}

At this point, the plumbing for integration testing should be in place.  Next we’ll create a helper class for integration test development to house/run our boilerplate set up/tear down logic.  One of the big concerns in this file is dealing with spoofing CSRF tokens for forgery-protected controller methods.  I created a directory called “Integration” for this file and it’s implementations.

ExampleWebApplicationTest/Integration/IntegrationTest.cs

using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Microsoft.Net.Http.Headers;
using Xunit;
namespace ExampleWebApplicationTest.Integration
{
public abstract class IntegrationTest : IClassFixture<TestWebApplicationFactory<TestStartup>>
{
private SetCookieHeaderValue _antiForgeryCookie;
private string _antiForgeryToken;
private static readonly Regex AntiForgeryFormFieldRegex =
new Regex(@"\<input name=""__RequestVerificationToken"" type=""hidden"" value=""([^""]+)"" \/\>");
private async Task<string> EnsureAntiForgeryToken(HttpClient client)
{
if (_antiForgeryToken != null) return _antiForgeryToken;
var response = await client.GetAsync("/Account/Login");
response.EnsureSuccessStatusCode();
if (response.Headers.TryGetValues("Set-Cookie", out IEnumerable<string> values))
{
_antiForgeryCookie = SetCookieHeaderValue.ParseList(values.ToList()).SingleOrDefault(c =>
c.Name.StartsWith(".AspNetCore.AntiForgery.", StringComparison.InvariantCultureIgnoreCase));
}
Assert.NotNull(_antiForgeryCookie);
client.DefaultRequestHeaders.Add("Cookie",
new CookieHeaderValue(_antiForgeryCookie.Name, _antiForgeryCookie.Value).ToString());
var responseHtml = await response.Content.ReadAsStringAsync();
var match = AntiForgeryFormFieldRegex.Match(responseHtml);
_antiForgeryToken = match.Success ? match.Groups[1].Captures[0].Value : null;
Assert.NotNull(_antiForgeryToken);
return _antiForgeryToken;
}
private async Task<Dictionary<string, string>> EnsureAntiForgeryTokenForm(HttpClient client,
Dictionary<string, string> formData = null)
{
if (formData == null) formData = new Dictionary<string, string>();
formData.Add("__RequestVerificationToken", await EnsureAntiForgeryToken(client));
return formData;
}
protected async Task PerformLogin(HttpClient client, string username, string password)
{
var formData = await EnsureAntiForgeryTokenForm(client, new Dictionary<string, string>
{
{"Email", username},
{"Password", password}
});
var response = await client.PostAsync("/Account/Login", new FormUrlEncodedContent(formData));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
// The current pair of anti-forgery cookie-token is not valid anymore
// Since the tokens are generated based on the authenticated user!
// We need a new token after authentication (The cookie can stay the same)
_antiForgeryToken = null;
}
}
}

And last, we’ll create our integration test file, which contains methods that exercise the application from controller methods through to our configured in-memory database.

ExampleWebApplication/Integration/AccountIntegrationTest.cs

using System;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;
namespace ExampleWebApplicationTest.Integration
{
public sealed class BasicIntegrationTest : IntegrationTest
{
private readonly TestWebApplicationFactory<TestStartup> _factory;
public BasicIntegrationTest(TestWebApplicationFactory<TestStartup> factory)
{
_factory = factory;
}
[Fact]
public async Task Get_SecurePageRedirectsAnUnauthenticatedUser()
{
// Arrange
var client = GetClient(false);
// Act
var response = await client.GetAsync("/");
// Assert
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
Assert.Contains("Account/Login",
response.Headers.Location.OriginalString);
}
[Fact]
public async Task ShouldLoginAndRenderHome()
{
//Arrange
var client = GetClient();
await PerformLogin(client, TestSeedData.TestEmail, TestSeedData.TestPassword);
//Act
var response = await client.GetAsync("/");
var body = response.Content.ReadAsStringAsync().Result;
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Contains("Home", body);
}
private HttpClient GetClient(Boolean redirects = true)
{
return _factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = redirects
});
}
}
}

To test this is all functioning properly, run `dotnet test` from the root of your solution.  We should see 18 passing testing and a decent amount of coverage on our AccountController.

This is the final post. As I previously mentioned, inspiration for these posts and this code came from a variety of sources. By no means could any of this been accomplished without blogs, StackOverflow, Microsoft documentation, and many other sources.  I hope this series of posts helps those struggling in the same way I was create what I think is a more full-featured starting point for ASP.NET Core MVC development.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s