Reliably testing components that use Entity Framework Core

14 minute read

Entity Framework Core is a powerful ORM able to abstract the access to a plethora of databases, both relational and document-based. This flexibility comes via a powerful provider system that can translate LINQ queries into efficient database-specific queries.

Unfortunately, this flexibility makes testing components that use Entity Framework Core directly quite problematic.

Sample Project Overview

Before we delve deeper into the post, let’s introduce the classes that will be used throughout the post.

This post assumes a basic operational knowledge of Entity Framework Core, how to configure it and how to use it to access data.

The sample code is composed by a simple DbContext that provides data access to a single entity, ToDoItem.

Here is the class representing the ToDoItem entity and a ToDoDbContext that inherits from the Entity Framework Core DbContext class.

public class TodoItem
{ 
  public Guid Id { get; set; }
  
  public string Title { get; set; } = default!;
  
  public string Description { get; set; } = default!;
  
  public DateTime CreatedAt { get; set; }
  
  public bool IsComplete { get; set; }
}

public class TodoDbContext(DbContextOptions<TodoDbContext> options) : DbContext(options)
{
  public DbSet<TodoItem> Items => Set<TodoItem>();
}

And here is a simple service using the specified DbContext. This post will explore a solution to test such component.

public class ToDoService(TodoDbContext db)
{
  public async Task<TodoItem[]> GetAll()
  {
    var items = await db.Items.ToArrayAsync();
    
    return items;
  }
}

The setup of the Entity Framework Core components varies depending on the type of application. Here is a simple console application that uses a non-specified connection string to connect to a PostgreSQL database and prints to the terminal the amount of items fetched from the database.

var connectionString = GetConnectionString();

var options = new DbContextOptionsBuilder<TodoDbContext>()
  .UseNpgsql(connectionString)
  .Options;

using var db = new TodoDbContext(options);

var service = new ToDoService(db);

var items = await service.GetAll();

Console.WriteLine($"Items found: {items.Length}");

Most importantly, we want to write tests that help us make sure the component behaves as we expect it to do. Here is a simple test fixture targeting the ToDoService written using NUnit.

[TestFixture]
public class ToDoServiceTests
{
  [Test]
  public async Task GetAll_should_return_empty_array()
  {
    var sut = new ToDoService(db); // 👈 COMPILER ERROR

    var items = await service.GetAll();

    Assert.That(items, Is.Empty);
  }
}

The snippet above won’t compile because the variable db is not defined. We’ll solve this issue in due time.

Testing Strategies for EF Core

Microsoft has published a detailed article that explains the challenges and the strategies available for effectively testing components using EF Core. Here is a quick list from the article:

  • Use the In-memory provider: This allows you to run tests against an in-memory database, which is fast and easy to set up but does not fully mimic the behavior of a real database.
  • Use the SQLite provider in in-memory mode: Similar to the in-memory provider but with some features closer to a real relational database.
  • Mock or stub the Entity Framework Core APIs: By mocking DbContext and DbSet, you can isolate your tests from the database. However, this can be complex due to the rich API surface of EF Core.
  • Introduce a repository layer: Abstracting database access behind a repository interface makes it easier to mock dependencies. However, this can add unnecessary complexity for smaller projects.

Each strategy has its own pros and cons. The first two options involve using different EF Core providers, which may lead to discrepancies in supported features. Mocking or stubbing the EF Core APIs can be complex and error-prone. Introducing a repository layer might be overkill for simpler applications.

Testing with real databases

A fifth alternative is to test against real databases. This approach, while more cumbersome to set up, ensures that your tests run against the same environment as your production code, avoiding the issues of provider mismatch.

Testing with real databases involves either:

  • Installing the database locally: This ensures consistency but adds a dependency on your local environment, which might complicate setup and maintenance.
  • Using containers: Containers can encapsulate the database setup, making it easier to reproduce the test environment across different machines.

Utilizing Containers for database testing

Using containers helps keep your local environment clean and makes it easy to replicate the test setup. For example, you can start a PostgreSQL container with the following command:

$ docker run -d \
  --name my_postgres \
  -e POSTGRES_PASSWORD=mysecretpassword \
  -e POSTGRES_USER=myuser \
  -e POSTGRES_DB=mydatabase \
  -p 5432:5432 \
  postgres

To stop the container, you would use docker stop my_postgres. For more reproducibility, you can define a Docker Compose file and use docker compose up -d and docker compose down to manage the container lifecycle.

Here is an example docker-compose.yml file:

version: '3.8'

services:
  postgres:
    image: postgres
    container_name: my_postgres
    environment:
      POSTGRES_USER: myuser
      POSTGRES_PASSWORD: mysecretpassword
      POSTGRES_DB: mydatabase
    ports:
      - "5432:5432"

Once the container is running, you can configure your tests to connect to it using a connection string, ensuring your tests run against a real database instance.

I’m purposefully not showing any code snippet on how to use the connection string directly because it won’t be needed in the final solution.

Simplifying database testing with Testcontainers

Using containers directly still requires manual steps to start and stop the database. Testcontainers simplifies this by managing the container lifecycle within your tests.

Here’s a quick example using Testcontainers for .NET in a console application:

async Task Main()
{
  await using var container = new PostgreSqlBuilder()
    .WithImage("postgres:latest")
    .WithDatabase("mydatabase")
    .WithUsername("myuser")
    .WithPassword("mysecretpassword")
    .WithWaitStrategy(Wait.ForUnixContainer()
        .UntilMessageIsLogged("database system is ready to accept connections"))
    .Build();
    
  await container.StartAsync();
  
  var connectionString = container.GetConnectionString();
  
  await using var connection = new NpgsqlConnection(connectionString);
  
  await connection.OpenAsync();
  
  // DO DATABASE STUFF
  
  await connection.CloseAsync();
  
  await container.StopAsync();
}

From the snippet above, you can see that we

  • Build a container definition
  • Start the container
  • Retrieve its connection string
  • Create a ADO.NET connection using the PostgreSQL provider
  • Perform operations on the database
  • Close the connection
  • Stop the container

This approach can be integrated into NUnit tests to manage the container lifecycle automatically, ensuring each test runs against a fresh database instance.

Here is a unit test written to use Testcontainers facilities to spin the container on execution.

[TestFixture]
public class ToDoServiceTests
{
  [Test]
  public async Task GetAll_should_return_empty_array()
  {
    await using var container = new PostgreSqlBuilder()
      .WithImage("postgres:latest")
      .WithDatabase("mydatabase")
      .WithUsername("myuser")
      .WithPassword("mysecretpassword")
      .WithWaitStrategy(Wait.ForUnixContainer()
          .UntilMessageIsLogged("database system is ready to accept connections"))
      .Build();
      
    await container.StartAsync();
    
    var connectionString = container.GetConnectionString();

    var options = new DbContextOptionsBuilder<TodoDbContext>()
      .UseNpgsql(connectionString)
      .Options;

    using var db = new TodoDbContext(options);

    await db.Database.EnsureCreatedAsync();

    var sut = new ToDoService(db);

    var items = await service.GetAll();

    Assert.That(items, Is.Empty);

    await container.StopAsync();
  }
}

The test above has many issues that need to be solved. The biggest grievances are:

  • if the assertion fails, the container is not stopped
  • if the fixture contained more tests, each test would spin up its own container, making running the tests extremely slow

Finally, the actual test is lost in the code needed to initialize the container. We can move that into its own method, but that wouldn’t solve the other issues.

Instead, let’s take advantage of NUnit test fixture and test lifecycle and its extension model.

Managing a single database instance for multiple tests

NUnit offers the possibility to execute arbitrary code before and after any and each test is executed.

To do so, we simply need to decorate methods with the following attributes:

  • [OneTimeSetUp] marks a method to be executed before any test is executed
  • [SetUp] marks a method to be executed before the execution of each test
  • [TearDown] marks a method to be executed after the execution of each test
  • [OneTimeTearDown] marks a method to be executed after all tests are executed

More information about the NUnit SetUp/TearDown attributes, here.

Let’s rewrite our test fixture to leverage these attributes

[TestFixture]
public class ToDoServiceTests
{
  private static readonly PostgreSqlContainer DatabaseContainer = new PostgreSqlBuilder()
    .WithImage("postgres:latest")
    .WithDatabase("mydatabase")
    .WithUsername("myusername")
    .WithPassword("mypassword")
    .WithWaitStrategy(Wait
      .ForUnixContainer()
      .UntilMessageIsLogged("database system is ready to accept connections"))
    .Build();

  [OneTimeSetUp]
  public async Task OneTimeSetUp()
  {
    await DatabaseContainer.StartAsync();

    var connectionString = DatabaseContainer.GetConnectionString();

    var options = new DbContextOptionsBuilder<TodoDbContext>()
      .UseNpgsql(connectionString)
      .Options;

    using var db = new TodoDbContext(options);

    await db.Database.EnsureCreatedAsync();
  }
  
  [OneTimeTearDown]
  public async Task OneTimeTearDown()
  {
    await DatabaseContainer.StopAsync();
    await DatabaseContainer.DisposeAsync();
  }
  
  private TodoDbContext db = default!;
  
  [SetUp]
  public void SetUp()
  {
    var connectionString = DatabaseContainer.GetConnectionString();

    var options = new DbContextOptionsBuilder<TodoDbContext>()
      .UseNpgsql(connectionString)
      .Options;

    db = new TodoDbContext(options);
  }
  
  [TearDown]
  public void TearDown()
  {
    db.Dispose();
  }

  [Test]
  public async Task GetAll_should_return_empty_array()
  {
    var sut = new ToDoService(db);

    var items = await sut.GetAll();

    Assert.That(items, Is.Empty);
  }
}

In the snippet above, we have restored the simplicity of the original unit test while leveraging Testcontainers container handling thanks to the help of the NUnit attributes.

Here is how we split the logic:

  • The container definition is a static readonly field of the class representing the test fixture
  • OneTimeSetUp() is responsible for starting the container and ensuring that the database is correctly initialized
  • SetUp() creates a fresh instance of the DbContext using the connection string returned by Testcontainers
  • TearDown() disposes the used DbContext
  • OneTimeTearDown() stops the container

Now we can add additional tests, each test will receive a fresh instance of the DbContext properly configured to target the instance of the container started at the beginning of the test.

Unfortunately, while reducing execution time, we have now introduced another issue.

To explain the issue, let’s expand the ToDoService with an additional operation. The new operations will receive a new item, add it to the set and immediately save it to the database.

public class ToDoService(TodoDbContext db)
{
  public async Task<TodoItem[]> GetAll()
  {
    var items = await db.Items.ToArrayAsync();
    
    return items;
  }

  public async Task Add(TodoItem item)
  {
    db.Items.Add(item);

    await db.SaveChangesAsync();
  }
}

Now, let’s add a simple test for the new operation.

[TestFixture]
public class ToDoServiceTests
{
  // OMITTED

  [Test]
  public async Task Add_adds_item_successfully()
  {
    var item = new TodoItem
    {
      Id = Guid.NewGuid(),
      Title = "Test",
      Description = "Some very important description",
      CreatedAt = DateTime.UtcNow,
      IsComplete = false
    };

    var sut = new ToDoService(db);

    await sut.Add(item);

    Assert.That(db.Items.Count(c => c.Id == item.Id), Is.EqualTo(1));
  }
}

The test is fairly simple:

  • We create a new item
  • We create an instance of the system under test (our ToDoService component)
  • We invoke the Add method
  • We check that the item is present in the database

The test works nicely but, when we execute the full test suite, we experience random failures of the GetAll_should_return_empty_array test. These failure are caused by the resulting array not being empty, thus failing the assertion.

Generally, NUnit executes tests in the same fixture alphabetically. In our case, when Add_adds_item_successfully is executed before GetAll_should_return_empty_array, it leaves the item it created in the table. So, when GetAll_should_return_empty_array is executed, the database is not empty, thus failing.

NUnit does have ways to control the order of execution of tests, but, generally, tests should be able to run independently and in whichever order.

This is a problem that manifests because we’re reusing the same database instance for all tests. A quick solution would be to move the steps executed in the OneTimeSetUp and OneTimeTearDown methods in the SetUp and TearDown methods. This would solve this problem but we would be back at starting a new database container for each test.

A generally good approach to this issue is resetting the database before each test.

Resetting the database between tests

The author of the linked article created a library that takes care of resetting the database by sequentially deleting all records from all tables.

The library is called Respawn and it is available as NuGet package.

To use it, we need to pass an open connection to the database and some customization options to the library. The library will take care of taking a snapshot of the layout of the database and create a plan to reset it.

var respawnerOptions = new () { DbAdapter = DbAdapter.Postgres };

await using var connection = new NpgsqlConnection(connectionString);

await connection.OpenAsync();

var respawner = await Respawner.CreateAsync(connection, respawnerOptions);

// DO DATABASE STUFF

await respawner.ResetAsync(connection);

// DO DATABASE STUFF WITH REFRESHED DATABASE

await connection.CloseAsync();

As you can imagine, we are going to plug Respawner in our fixture class. Specifically:

  • We are going to execute the snapshot in the OneTimeSetUp() method
  • We are going to reset the database in the TearDown() method.
[TestFixture]
public class ToDoServiceTests
{
  private static readonly PostgreSqlContainer DatabaseContainer = new PostgreSqlBuilder()
    .WithImage("postgres:latest")
    .WithDatabase("mydatabase")
    .WithUsername("myusername")
    .WithPassword("mypassword")
    .WithWaitStrategy(Wait
      .ForUnixContainer()
      .UntilMessageIsLogged("database system is ready to accept connections"))
    .Build();
    
  private static Respawner respawner = default!;

  private static RespawnerOptions respawnerOptions = new () { DbAdapter = DbAdapter.Postgres };

  [OneTimeSetUp]
  public async Task OneTimeSetUp()
  {
    await DatabaseContainer.StartAsync();

    var connectionString = DatabaseContainer.GetConnectionString();

    var options = new DbContextOptionsBuilder<TodoDbContext>()
      .UseNpgsql(connectionString)
      .Options;

    using var db = new TodoDbContext(options);
    
    await db.Database.EnsureCreatedAsync();
    
    await using var connection = db.Database.GetDbConnection();
    
    await connection.OpenAsync();
    
    respawner = await Respawner.CreateAsync(connection, respawnerOptions);
  }

  [OneTimeTearDown]
  public async Task OneTimeTearDown()
  {
    await DatabaseContainer.StopAsync();
    await DatabaseContainer.DisposeAsync();
  }
  
  private TodoDbContext db = default!;
  
  [SetUp]
  public void SetUp()
  {
    var connectionString = DatabaseContainer.GetConnectionString();

    var options = new DbContextOptionsBuilder<TodoDbContext>()
      .UseNpgsql(connectionString)
      .Options;

    db = new TodoDbContext(options);
  }
  
  [TearDown]
  public async Task TearDown()
  {
    await using var connection = db.Database.GetDbConnection();

    await connection.OpenAsync();
    
    await respawner.ResetAsync(connection);

    db.Dispose();
  }

  // TESTS OMITTED
}

With the setup above, we can execute the tests in this fixture regardless of their order knowing that each test will be running on a pristine database.

What happens when we have multiple fixtures?

Sharing the database across multiple fixtures

If our system contains more than one component accessing the database, our current setup loses efficiency as each individual test fixture will have their own database container to be started.

We can solve this by leveraging another functionality of NUnit, the [SetUpFixture] attribute. As explained in the documentation, the [SetUpFixture] attribute can be used to mark a class that contains methods marked with the [OneTimeSetUp] and [OneTimeTearDown] attributes that need to be executed before and after all tests in the scope are executed.

Further more, to reduce code duplication, we are going to leverage NUnit support for test fixture inheritance to reduce the amount of code needed by each fixture.

First, I’ll introduce a Tests class that will be marked with the [SetUpFixture] attribute. This class will be responsible for configuring the database container and initialize the database. It will also expose a utility method that will be used by the test fixtures to reset the database.

[SetUpFixture]
public class Tests
{
  private static readonly PostgreSqlContainer DatabaseContainer = new PostgreSqlBuilder()
    .WithImage("postgres:latest")
    .WithDatabase("mydatabase")
    .WithUsername("myusername")
    .WithPassword("mypassword")
    .WithWaitStrategy(Wait
      .ForUnixContainer()
      .UntilMessageIsLogged("database system is ready to accept connections"))
    .Build();
    
  private static Respawner respawner = default!;

  private static RespawnerOptions respawnerOptions = new () { DbAdapter = DbAdapter.Postgres };

  public static string ConnectionString => DatabaseContainer.GetConnectionString();

  [OneTimeSetUp]
  public async Task OneTimeSetUp()
  {
    await DatabaseContainer.StartAsync();

    var options = new DbContextOptionsBuilder<TodoDbContext>()
      .UseNpgsql(ConnectionString)
      .Options;

    using var db = new TodoDbContext(options);
    
    await db.Database.EnsureCreatedAsync();
    
    await using var connection = new NpgsqlConnection(ConnectionString);
    
    await connection.OpenAsync();
    
    respawner = await Respawner.CreateAsync(connection, respawnerOptions);
  }

  [OneTimeTearDown]
  public async Task OneTimeTearDown()
  {
    await DatabaseContainer.StopAsync();
    await DatabaseContainer.DisposeAsync();
  }

  public static async Task ResetDatabase()
  {
    await using var connection = new NpgsqlConnection(ConnectionString);

    await connection.OpenAsync();
    
    await respawner.ResetAsync(connection);
  }
}

Now, we can introduce a base class for all our test fixtures that need access to the database.

[TestFixture]
public abstract class TestsWithDatabase
{
  protected TodoDbContext Database = default!;

  [SetUp]
  public async Task SetUp()
  {
    var options = new DbContextOptionsBuilder<TodoDbContext>()
      .UseNpgsql(Tests.ConnectionString)
      .Options;

    Database = new TodoDbContext(options);
  }

  [TearDown]
  public async Task TearDown()
  {
    await Tests.ResetDatabase();

    Database.Dispose();
  }
}

We can finally simplify our test fixture by leveraging the new utilities.

[TestFixture]
public class ToDoServiceTests : TestsWithDatabase
{
  [Test]
  public async Task GetAll_should_return_empty_array()
  {
    var sut = new ToDoService(Database);

    var items = await sut.GetAll();

    Assert.That(items, Is.Empty);
  }

  [Test]
  public async Task Add_adds_item_successfully()
  {
    var item = new TodoItem
    {
      Id = Guid.NewGuid(),
      Title = "Test",
      Description = "Some very important description",
      CreatedAt = DateTime.UtcNow,
      IsComplete = false
    };

    var sut = new ToDoService(Database);

    await sut.Add(item);

    Assert.That(Database.Items.Count(c => c.Id == item.Id), Is.EqualTo(1));
  }
}

We are now able to write tests for components using Entity Framework Core without the need for different providers or additional layers.

You can find the final version of the code snippets in this GitHub repository: https://github.com/Kralizek/EntityFrameworkCoreTesting

Are these unit tests?

While solving the concrete problem of testing components that use Entity Framework Core directly, this solution raises a philosophical question.

Are these unit tests?

Unit tests are often described as tests that focus on a single component (the unit) without any external dependency.

This solution is undoubtedly introducing external dependencies such as a concrete DbContext and even an external database running in a container, so the debate about whether these tests can be called as unit tests has some motivation.

Yet, I am personally willing to go beyond the strict definition and leverage this solution to write efficient and reliable tests for my components.

Recap

In this post we have seen how we can leverage libraries like Testcontainers.net and Respawn and the NUnit lifecycle attributes to write tests that connect to a real database that gets reset after the execution of each test.

In a future post, I will show how to use these libraries when testing ASP.NET Core applications via the WebApplicationFactory discussed in a previous post.

Support this blog

If you liked this article, consider supporting this blog by buying me a pizza!