Skip to content

Rules for tests using EF Core

Jon P Smith edited this page Nov 11, 2021 · 9 revisions

There are some rules to follow when when accessing a database via EF Core in xUnit test. This page explains each rule and how EF Core or EfCore.TestSupport can allow you to follow the five rules.

1. You need a unique database within a test class

By default, xUnit will run each class containing tests in parallel, and every test method in a test class is then run in series. That means if you used the same database for all of your test, then many tests will be accessing / changing the database at the same time, which will make it impossible to check that the test works! There are two solutions:

NOTE: The SQL Server and PostgreSQL relies on a base connection string placed in an appsettings.json file in your xUnit test project - see Creating connection strings for more information.

2. You need an empty database at the start of each test AND 3, the database schema matches the current EF Core Model

I have combined rules 2 and 3 because they are fixed by the same EfCore.TestSupport features, but here are the reasons:

  • Rule 2 is there because it much harder to write tests that don't care what the database contains. EF Core and EfCore.TestSupport provides ways to clear out all the rows in your tables (and more).
  • Rule 3 is there because if you change any of your entity classes or the EF Core configuration, then the current database schema (e.g. tables, views, function etc) will be out of date and most likely not work with your new . EF Core and EfCore.TestSupport provides wasy to delete the current schema and recreated the schema using EF Core's current Model of the database.

There are two ways to implement rule 2 and 3:

  • Call EF Core EnsuredDeleted method and then call EnsuredCreated - see the test TestEnsureDeletedEnsureCreatedOk in the TestSqlServerHelpers test class.
  • For SQL Server and PostgreSQL the EnsuredDeleted + EnsuredCreated approach is a bit slow. Therefore EfCore.TestSupport has a method called EnsureClean which is quicker - see the test TestSqlDatabaseEnsureCleanOk in the TestSqlServerHelpers test class.

NOTE: SqliteInMemory databases are empty by default and EnsuredCreated will set up the correct schema.

4. Seeding your test database

An automatic test is usually broken down into three stages:

  1. SETUP (also known as Arrange) where you set up the environment for your test.
  2. ATTEMPT (also known as Act) where you execute some code that you want to test.
  3. VERIFY (also known as Assert) where you check the results for the code you tested are as you expected to be

To "set up the environment for your test" you often add data into the database in the SETUP stage. My experience is that setting up the database with the right data for a test can quite complex, in fact its often the hardest part of the test to write! Here is how I manage the seeding:

I normally start by putting the database seed code directly in the SETUP stage, but as soon as I find myself wanting copy that setup code I turn that code into an extension method in your Test project, with a good name. For instance I have a method called SeedDatabaseFourBooks() that adds four books, with related data, into the database - see the the last method in the EfTestData static class I used when writing the 2nd edition of my book.

The other benefit to creating extension seeding methods is its a) easier to use, b) makes the test easier to understand, and c) easily to refactor/update the extension method as the application grows.

5. You must make sure your database test match your real-world usage

The last rule defined the three stages SETUP, ATTEMPT, and VERIFY, and this rule says that each stage should be isolated from each other to correctly mimic what would happen in your application. That isn't as simple as you might think because of a feature of EF Core.

The problem is that EF Core will automatically track all the entities that were read or written to the database across all the three stages which can hide errors in the database code you are testing. For instance you might write some data into the database in the SETUP, but that tracked data can cover over problems in your ATTEMPT, e.g. in SETUP you write a Book class with collection of Review classes. In the ATTEMPT stage you that Book and you forget the .Include(book => book.Reviews). Because of the tracked entities the Book will have the Reviews collection filled, when in real use the Reviews collection wouldn't be filled.

There are a few ways to fix this, but adding the following line of code context.ChangeTracker.Clear() at the end of the SETUP and ATTEMPT stage will stop this issue. The code below shows this in action.

[Fact]
public void TestBookDbContextAddFourBooksLoadRelationshipsOk()
{
    //SETUP
    var options = SqliteInMemory.CreateOptions<BookDbContext>();
    using var context = new BookDbContext(options);
    context.Database.EnsureCreated();
    context.SeedDatabaseFourBooks();

    context.ChangeTracker.Clear();

    //ATTEMPT
    var book = context.Books.First();
    book.Price = 123;
    context.SaveChanges(); 

    //VERIFY
    context.ChangeTracker.Clear();
    var verifyBook = context.Books.First();
    verifyBook.Price.ShouldEqual(123);
}

NOTE: See this section in the "Changes in EfCore.TestSupport 5" article for a deeper explanation.

6. Your DbContext needs a constructor taking in DbContextOptions<YourDbContext>

You need a way to provide the database options to your DbContext. This is done via a constructor in your DbContext which has a DbContextOptions<YourDbContext> options parameter, which is normal for ASP.NET Core - see this example.

If you are using the OnConfiguring approach you need add if (!optionsBuilder.IsConfigured) into your OnConfiguring method - see this example DbContext which has both a constructor with a DbContextOptions<YourDbContext> options parameter AND an OnConfiguring method.

Clone this wiki locally