Enhance your .NET Testing #2: InMemoryDatabase

The development environment in C#/.NET offers a myriad of tools that optimize testing efficiency. Among these are the InMemoryDatabase and WebApplicationFactory, both possessing the potential to drastically streamline testing strategies and development processes.

As a sequel to our previous discussion on unit testing in .NET Core, this article aims to delve deeper into these tools. If you haven't read it, I recommand you to start by reading it:

Elevate Your .NET Testing Strategy #1: WebApplicationFactory
The WebApplicationFactory class in .NET provides a powerful tool for integration testing. It allows you to create a factory for bootstrapping an application in memory for testing. This article provides an overview of how to use WebApplicationFactory to create end-to-end tests using the xUnit ClassD…

Before getting into the subject, I'd like to recall an important point from Microsoft:

The EF Core in-memory database is not designed for performance or robustness and should not be used outside of testing environments. It is not designed for production use.
⚠️New features are not being added to the in-memory database.
– Microsoft documentation

The following article is to be used for testing purposes only, and not for production.

Now, let's commence our exploration of InMemoryDatabase in the context of C#/.NET development.

Setting Up

The InMemoryDatabase offers an efficient avenue for performing tests that need database interactions without the overhead of actual database operations. Establishing this tool within your project involves a few meticulously outlined steps, designed to ensure seamless integration.

To begin, you'll need to include the required dependencies. Install the Microsoft.EntityFrameworkCore.InMemory package by running the following command:

Install-Package Microsoft.EntityFrameworkCore.InMemory

With the necessary package installed, we turn our attention to the context configuration. In the Startup.cs file, modify the ConfigureServices method to include the InMemoryDatabase. Here, the AddDbContext method is employed to configure the InMemoryDatabase.

public void ConfigureServices(IServiceCollection services)
{
    services.AddDbContext<GoatDbContext>(options =>
        options.UseInMemoryDatabase("InMemoryGoatDb"));
}

In the code snippet above, InMemoryGoatDb is the name assigned to our database. Remember, this name can be customized as per your needs.

Use fake database context

Now that the installation is complete, we can ask ourselves what we're going to use it for.

In this situation, the tests are not unit tests, but more global tests of the functioning of a complete operation. To be relevant, services must be in a state as close to reality as possible. The fewer classes that are replaced by fake ones, the more interesting the results will be.

In this case, the repositories and the unit of work classes, which are essential to the application's operation, must not be modified.

The only thing that changes is the environment in which the database will be stored. We don't want to bother with an external component such as a remote database, or one instantiated under Docker, for performance reasons, which are usually associated with integration tests, which is not the case here.

What we want is to host our database in memory, to keep the same structure.

To achieve this, we can inherit the application's GoatDbContext to simply override the configuration and define memory usage. In this way, the GoatDbContextFake retains the same links with repositories and the unit of work.

internal class GoatDbContextFake : GoatDbContext
{
	private string _connectionString;
    public GoatDbContextFake(string connectionString) : base(connectionString)
    {
    	_connectionString = connectionString;
    }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {     optionsBuilder.UseInMemoryDatabase(_connectionString).EnableSensitiveDataLogging();
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        OnModelCreating(modelBuilder, typeof(MyDbContext));
    }
}

Combine it with WebApplicationFactory

Next, let's use the WebApplicationFactory class to create a test web host environment for our component tests.

The new DbContext is configured directly in ConfigureServices, so that it can be injected in place of the old one.

One very important thing before continuing: as the database is loaded into memory, its lifetime is over the entire test run. If the connection string is the same for all tests, all tests will use the same database.

Be careful how data is injected into tests. Generally speaking, I always prefer to have one set of data for a test and not share it with others, to avoid any side effects.

To ensure this, you need to give a unique name to the creation of GoatDbContextFake. In order to validate this, you can use random generated identifier for each.

internal class GoatWebApplication : WebApplicationFactory<Program>
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
       builder.ConfigureServices(services =>
        { 
        	var dbConnectionString = = Guid.NewGuid().ToString();
            services.AddScoped<GoatDbContext>(c => new GoatDbContextFake(dbConnectionString));           
            services.AddDbContext<GoatDbContextFake>();
        });
    }
}

Everything's in place, all that's left is to update the test.

public async Task ShouldGetGoatsList_WhenAskingToRouteGetAllGoats()
{
    // Arrange
    var webApplication = new GoatWebApplication();
    var serviceProvider = webApplication.Services.CreateScope().ServiceProvider;

    var goat = new Goat() { Id = 1, Name = "John" };
    var goatRepository = serviceProvider.GetService<IGoatRepository>().Add(goat);
    var unitOfWork = serviceProvider.GetService<IUnitOfWork>().SaveChangesAsync();
        
    // Act
    var response = await webApplication.CreateClient().GetAsync("PATHTOGETALLGOATS");

    // Arrange
    var content = await response.Content.ReadAsStringAsync();
    response.StatusCode.Should().Be(HttpStatusCode.OK, content);
    var sites = JsonSerializer.Deserialize<List<Site>>(content);
    sites.Should().HaveCount(1);
}

Several points are important in this test.

Firstly, the service provider is obtained from a scope. By default, AddDbContext is set to Scoped and since this is the actual behavior, it's best not to change it to be as close to reality as possible.

Secondly, the service provider gives us access to the database. In this way, we can control the element we insert.

Finally, since we know the dataset, we know exactly what the result of the call to make the assertions is.

I wouldn't recommend testing all your endpoints with these tests, as they are much heavier than unit tests. On the other hand, they are very useful for validating that your routes return the correct data formats and return codes. These two types of tests are complementary and in no way replace the other.

Limits

Certainly, while the InMemoryDatabase provides a useful tool for testing, its design as a simple, in-memory data store does limit its utility when compared to a full-fledged relational database management system (RDBMS). Here are a few notable restrictions:

  1. Lacks Transaction Support: In a real RDBMS, you can employ transactions to ensure data integrity. However, the InMemoryDatabase provider in Entity Framework Core does not support transactions.
  2. No Real Persistence: The InMemoryDatabase, as the name implies, stores data in memory and not on disk, meaning data stored in the database will disappear when the application stops or the test ends.
  3. Absence of Concurrency Control: InMemoryDatabase does not have any concurrency control, which can lead to issues when trying to simulate concurrency conflicts in your tests.
  4. Missing SQL Support: If your application uses raw SQL queries or SQL-based procedures, these won't be executable on the InMemoryDatabase as it is not a SQL-based provider.
  5. Database-Specific Functions: InMemoryDatabase does not support database-specific functions and behaviors, such as SQL Server's IDENTITY columns or SEQUENCE objects.

Remember, the InMemoryDatabase is designed for testing and not for mirroring the exact behavior of a relational database. For more accurate integration tests that require database interactions, consider using a test-specific instance of your actual database. This allows you to account for the behavior of your specific database server in your tests.

Summary

By integrating InMemoryDatabase and WebApplicationFactory in our testing framework, we've unlocked a rapid, reliable, and efficient testing environment. Let's recall what we've achieved:

  • Simplified database setup for testing by using InMemoryDatabase.
  • Ensured data isolation for each test case.
  • Created a testing web host for integration tests using WebApplicationFactory.
  • Replaced actual DbContext with InMemoryDatabase during testing.

With these powerful tools, testing becomes a breeze, making your application more robust and reliable. It's not only simple to set up, but it also elevates the overall development experience.

Next article:

Enhance your .NET Testing #3: Fixtures separation
This article introduces a practical technique to optimize test data creation by encapsulating the entire logic within a separate fixture class.

To go further:

In-memory Database Provider - EF Core
Information on the Entity Framework Core in-memory database provider

Have a goat day 🐐