Integration testing with Respawn in ASP.NET Core

Niko Kantaria
10 min readJan 30, 2023

--

Testing is one of the crucial part for any software development process, because it’s meant to catch bugs, test individual units of code and test whole module combined. Unit tests and integration tests are two types of software testing that are used to ensure that the code of an application is working correctly.

Unit tests are focused on testing individual units of code, such as individual methods or classes. They are designed to test the functionality of the code in isolation from other parts of the application, and are typically fast to run. The goal of unit tests is to catch bugs and errors early in the development process, before they can propagate and cause issues in other parts of the application.

Integration tests, on the other hand, test how different units of code work together. They are typically slower to run than unit tests because they involve running multiple parts of the application together. The goal of integration tests is to ensure that all the different components of the application work together correctly and that there are no issues when they are combined. They can also test the interactions between the application and external systems, such as databases or APIs.

Both unit tests and integration tests are important in the software development process. Unit tests are useful for catching bugs early, while integration tests are useful for catching issues that may arise when different parts of the code are combined.

In this article, we’re going to focus on Integration testing with Respawn 4.0.0 tool, that helps in resetting test databases to a clean state. Instead of deleting data at the end of a test or rolling back a transaction, Respawn resets the database back to a clean, empty state by intelligently deleting data from tables, this is why, we’ll need two databases, one for our web project that actually holds the data, and second test database, that we’ll be using for our integration test project. To achieve this goal, we’ll need a ASP.NET Core MVC project and knowledge of C#.

Let’s get started

Imagine we have an MVC application that allows users to subscribe to our service by providing their email addresses. The application uses MediatR to handle the process of inserting email addresses into the database in a Command-Query Responsibility Segregation (CQRS) pattern. We want to make sure that the email subscription feature of our application is working correctly, so we need to create an integration test that will run against our code and insert a new email address into the database. This will allow us to test the entire email subscription functionality end-to-end and ensure that it is working as expected.

The first thing that we’re gonna do is create new Nunit project inside of our app. Let’s name it IntegrationTests.

Add appsettings.json file into that project, and set ConnectionStrings as follows:

 "ConnectionStrings": {
"DefaultConnection": "Data Source=(local);Initial Catalog=MyAppDatabase-Test;Integrated Security=True; TrustServerCertificate=True"
},

Copy and paste the following ItemGroups to IntegrationTests.csprojfile

 <ItemGroup>
<None Remove="appsettings.json" />
</ItemGroup>

<ItemGroup>
<Content Include="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>

<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.8.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="6.0.12" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.2" />
<PackageReference Include="Moq" Version="4.18.3" />
<PackageReference Include="NUnit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.2.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="NUnit.Analyzers" Version="3.3.0" />
<PackageReference Include="coverlet.collector" Version="3.1.2" />
<PackageReference Include="Respawn" Version="4.0.0" />
</ItemGroup>

The code is defining certain dependencies and configuration settings for testing in the project.

  • The first ItemGroup block is removing the file “appsettings.json”
  • The second ItemGroup block is including the same file “appsettings.json” with the instruction to preserve the newest version of the file.
  • The last ItemGroup block is including various package references for testing such as FluentAssertions, NUnit, Moq, Respawn and other packages. These packages are used for various testing purposes such as test execution, test adapters, code coverage, and database resetting.

Rebuild the project so that it can install the dependencies. Once you’re done, create a class called Testing inside of IntegrationTests project, copy and paste the following code:

 [SetUpFixture]
public partial class Testing
{
private static WebApplicationFactory<Program> _factory = null!;
private static IConfiguration _configuration = null!;
private static IServiceScopeFactory _scopeFactory = null!;
private static Checkpoint _checkpoint = null!;
private static string? _currentUserId;

[OneTimeSetUp]
public void RunBeforeAnyTests()
{
_factory = new CustomWebApplicationFactory();
_scopeFactory = _factory.Services.GetRequiredService<IServiceScopeFactory>();
_configuration = _factory.Services.GetRequiredService<IConfiguration>();

_checkpoint = new Checkpoint
{
TablesToIgnore = new[] { "__EFMigrationsHistory" }
};
}

public static async Task<TResponse> SendAsync<TResponse>(IRequest<TResponse> request)
{
using var scope = _scopeFactory.CreateScope();

var mediator = scope.ServiceProvider.GetRequiredService<ISender>();

return await mediator.Send(request);
}

public static string? GetCurrentUserId()
{
return _currentUserId;
}

public static async Task<string> RunAsDefaultUserAsync()
{
return await RunAsUserAsync("test@local", "Testing1234!", Array.Empty<string>());
}

public static async Task<string> RunAsAdministratorAsync()
{
return await RunAsUserAsync("administrator@local", "Administrator1234!", new[] { "Administrator" });
}

public static async Task<string> RunAsUserAsync(string userName, string password, string[] roles)
{
using var scope = _scopeFactory.CreateScope();

var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();

var user = new ApplicationUser
{
UserName = userName,
Email = userName
};

var result = await userManager.CreateAsync(user, password);

if (roles.Any())
{
var roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>();

foreach (var role in roles)
{
await roleManager.CreateAsync(new IdentityRole(role));
}

await userManager.AddToRolesAsync(user, roles);
}

if (result.Succeeded)
{
_currentUserId = user.Id;

return _currentUserId;
}

var errors = string.Join(Environment.NewLine, result.ToApplicationResult().Errors);

throw new Exception($"Unable to create {userName}.{Environment.NewLine}{errors}");
}

public static async Task ResetState()
{
await _checkpoint.Reset(_configuration.GetConnectionString("DefaultConnection"));

_currentUserId = null;
}

[OneTimeTearDown]
public void RunAfterAnyTests()
{
_factory.Dispose();
}
}

The class Testing is decorated with [SetUpFixture] attribute, which means that it will be run only once before all other tests in the assembly and the [OneTimeSetUp] attribute is used for the method RunBeforeAnyTests() which will be run before all the tests.

The class Testing contains several properties that are used to manage the application's state and dependencies during testing. The properties include:

  • _factory of type WebApplicationFactory<Program> is used to create a test server for the application and it is initialized in the RunBeforeAnyTests() method.
  • _scopeFactory of type IServiceScopeFactory is used to create instances of the application's services that can be used in tests.
  • _configuration of type IConfiguration is used to access the application's configuration settings.
  • _checkpoint of type Checkpoint is used to reset the application's database state between tests.

The class contains several methods that can be used to perform common operations in tests, such as:

  • SendAsync<TResponse> method is used to send requests to the application and handle responses.
  • RunAsDefaultUserAsync method is used to authenticate a user with the specified credentials and return the user Id.
  • RunAsAdministratorAsync method is used to authenticate an administrator user with the specified credentials and return the user Id.
  • RunAsUserAsync method is used to authenticate a user with the specified credentials and roles.
  • ResetState method is used to reset the application's state, it is using the _checkpoint object to reset the application's database.
  • FindAsync<TEntity> method is used to find an entity in the application's database.
  • AddAsync<TEntity> method is used to add an entity to the application's database.
  • CountAsync<TEntity> method is used to count entities in the application's database.

The method RunAfterAnyTests is decorated with the [OneTimeTearDown] attribute, which means that it will be run only once after all other tests in the assembly, the method RunAfterAnyTests is used to dispose the factory at the end of the tests.

Overall, this class provides a set of utility methods and properties that can be used to make it easier to write and maintain integration tests for an ASP.NET Core web application by abstracting common functionalities that are needed in integration tests such as creating test users, resetting the application’s state, and interacting with the application’s services and database.

Now, as we’ve set up the Testing class, we need to configure the host for our integration test project. To achieve this goal, create CustomWebApplicationFactory into the IntegrationTests project. Copy and paste following code:

 using static Testing;

internal class CustomWebApplicationFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureAppConfiguration(configurationBuilder =>
{
var integrationConfig = new ConfigurationBuilder()
.AddJsonFile("appsettings.json")
.AddEnvironmentVariables()
.Build();

configurationBuilder.AddConfiguration(integrationConfig);
});

builder.ConfigureServices((builder, services) =>
{
services
.Remove<ICurrentUserService>()
.AddTransient(provider => Mock.Of<ICurrentUserService>(s =>
s.UserId == GetCurrentUserId()));

services
.Remove<DbContextOptions<ApplicationDbContext>>()
.AddDbContext<ApplicationDbContext>((sp, options) =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"),
builder => builder.MigrationsAssembly(typeof(ApplicationDbContext).Assembly.FullName)));
});
}
}

This code defines a class called CustomWebApplicationFactory which is derived from WebApplicationFactory<Program>. This class is responsible for configuring a web host for integration testing.

The ConfigureWebHost method is overridden to customize the web host's configuration. In this implementation, it is configuring the application's configuration by adding an additional JSON file named appsettings.json and environment variables to the configuration builder. This configuration is then added to the web host's configuration.

The ConfigureServices method is also overridden to customize the web host's service collection. This implementation removes the ICurrentUserService and replaces it with a mock object that is created using the Mock.Of method. The mock object's UserId property is set to the value returned by the GetCurrentUserId method.

It also removes the DbContextOptions<ApplicationDbContext> and replaces it with a new instance of ApplicationDbContext. The ApplicationDbContext is configured to use a SQL Server database using the connection string named DefaultConnectionand the migrations assembly is set to the assembly containing the ApplicationDbContext class.

It also using using static Testing which is a static class that contain some testing utility methods that we’ve created before.

Moving on, we’ll create a ServiceCollectionExtensions class that will hold a Remove extension method which allows us to remove a specific service from the IServiceCollection in a more concise and readable way than manually finding and removing the service descriptor.

Create class into our project and add the following code

  public static class ServiceCollectionExtensions
{
public static IServiceCollection Remove<TService>(this IServiceCollection services)
{
var serviceDescriptor = services
.FirstOrDefault(x =>
x.ServiceType == typeof(TService));

if (serviceDescriptor != null)
{
services.Remove(serviceDescriptor);
}

return services;
}
}
  • IServiceCollection is a built-in class in the .NET framework that represents a collection of service descriptors. Service descriptors are objects that define a service (usually an interface and an implementation class) and how it should be constructed and managed by the .NET Dependency Injection (DI) container. Developers can use the IServiceCollection class to configure the services that are available to their application.
  • The FirstOrDefault method is a standard LINQ extension method that returns the first element in a collection that matches a given condition, or a default value (in this case, null) if no matching elements are found. In this case, it is used to search for the first service descriptor in the collection whose ServiceType property is equal to typeof(TService). This is done to check if a service of the specified type is already registered in the collection.
  • The Remove method is a built-in method of the IServiceCollection class that removes a specific service descriptor from the collection. It takes an instance of the ServiceDescriptor class as a parameter. In this case, it is used to remove the service descriptor that was found by the FirstOrDefault method.
  • The services parameter is an instance of the IServiceCollection class that the method is extending. It represents the collection of services that the method will operate on. The method modifies the collection in place and returns the same instance at the end.

By reaching this step, we’re almost ready to start creating integration tests for our App.

Let’s create BaseTestFixture class in our project and add following code into it

 using static Testing;

[TestFixture]
public abstract class BaseTestFixture
{
[SetUp]
public async Task TestSetUp()
{
await ResetState();
}
}

This code defines an abstract class called BaseTestFixture. The class is decorated with the TestFixture attribute, which is a feature provided by the NUnit testing framework. This attribute indicates that the class contains test methods that can be executed by the NUnit test runner.

The class contains a single method called TestSetUp, which is decorated with the SetUp attribute. This attribute tells NUnit that the method should be executed before each test method in the test class.

The method is asynchronous and it calls an await on a method called ResetState which is a static method in Testing class. The method ResetState resets database state before each test.

The using static Testing at the top of the file allows the us to call the method ResetState without specifying the class name.

Now we are ready to write an integration test for our application.

The application uses MediatR library for CQRS approach, for example we have an app that subscribes users to a mail list, basically it expects an email to be passed as string parameter, and it adds it into database. Imagine we have a SubscribeCommand that holds Email property. Here’s a handler for this command

    public class SubscribeCommandHandler : IRequestHandler<SubscribeCommand, string>
{
private readonly IUnitOfWork _unitOfWork;

public SubscribeCommandHandler(IUnitOfWork unitOfWork)
{
_unitOfWork = unitOfWork;
}

public async Task<string> Handle(
SubscribeCommand command,
CancellationToken cancellationToken = default)
{
try
{
var email = command.Email;
if (string.IsNullOrWhiteSpace(email))
{
throw new EmailIsEmptyException("Email is empty!");
}

var emailSubscription = new EmailSubscription()
{
Email = email,
};

var repository = _unitOfWork.GetRepository<EmailSubscription>();

await repository.Insert(emailSubscription, cancellationToken);
await _unitOfWork.SaveChangesAsync();

return emailSubscription.Email;
}
catch (EmailIsEmptyException)
{
throw;
}
catch (Exception)
{
throw;
}
}
}

By looking at this code, you may come up with some test scenarios for our integration test.

Let’s create a SubscribeCommandTests class in our project and add the following code to it

using static Testing; 

public class SubscribeCommandTests : BaseTestFixture
{
[Test]
public async Task ShouldThrownEmailIsEmptyException()
{
var command = new SubscribeCommand
{
Email = string.Empty
};

await FluentActions
.Invoking(() => SendAsync(command))
.Should()
.ThrowAsync<EmailIsEmptyException>();
}


[Test]
public async Task ShouldSubscribeAUser()
{
var email = "Integration@test.com";
var command = new SubscribeCommand
{
Email = email
};

var result = await SendAsync(command);
result.Should().Be(email);
}
}

This class, SubscribeCommandTests inherits from a previously defined BaseTestFixture class, which sets up the initial state of the database before running the tests. The class has two test methods, one tests that the application throws an exception if an empty email is provided, and the other tests that an email address is successfully added to the database. The method TestSetUp which is defined in the BaseTestFixture class is used to reset the state of the database before running each test. This ensures that the second test method, which adds an email address to the database, will have a clean slate to work with and will be able to add the email "Integration@test.com" to the database.

Summary

The article explains how to use the Respawn tool to create a project for integration tests, where we ensure the functionality works as expected from end to end, it also teaches a technique to reset the database state before running tests. Demonstrates how to use fluent assertions in the project and invoke the SendAsync method, which triggers the handlers to handle the command and make sure it's working as expected.

--

--