EF Core testing sample

Tip

The code in this document can be found on GitHub as a runnable sample. Note that some of these tests are expected to fail. The reasons for this are explained below.

This doc walks through a sample for testing code that uses EF Core.

The application

The sample contains two projects:

The model and business rules

The model backing this API has two entity types: Items and Tags.

  • Items have a case-sensitive name and a collection of Tags.
  • Each Tag has a label and a count representing the number of times it has been applied to the Item.
  • Each Item should only have one Tag with a given label.
    • If an item is tagged with the same label more than once, then the count on the existing tag with that label is incremented instead of a new tag being created.
  • Deleting an Item should delete all associated Tags.

The Item entity type

The Item entity type:

public class Item
{
    private readonly int _id;
    private readonly List<Tag> _tags = new List<Tag>();

    private Item(int id, string name)
    {
        _id = id;
        Name = name;
    }

    public Item(string name)
    {
        Name = name;
    }

    public Tag AddTag(string label)
    {
        var tag = _tags.FirstOrDefault(t => t.Label == label);

        if (tag == null)
        {
            tag = new Tag(label);
            _tags.Add(tag);
        }

        tag.Count++;

        return tag;
    }

    public string Name { get; }

    public IReadOnlyList<Tag> Tags => _tags;
}

And its configuration in DbContext.OnModelCreating:

modelBuilder.Entity<Item>(
    b =>
    {
        b.Property("_id");
        b.HasKey("_id");
        b.Property(e => e.Name);
        b.HasMany(e => e.Tags).WithOne().IsRequired();
    });

Notice that entity type constrains the way it can be used to reflect the domain model and business rules. In particular:

  • The primary key is mapped directly to the _id field and not exposed publicly
    • EF detects and uses the private constructor accepting the primary key value and name.
  • The Name property is read-only and set only in the constructor.
  • Tags are exposed as a IReadOnlyList<Tag> to prevent arbitrary modification.
    • EF associates the Tags property with the _tags backing field by matching their names.
    • The AddTag method takes a tag label and implements the business rule described above. That is, a tag is only added for new labels. Otherwise the count on an existing label is incremented.
  • The Tags navigation property is configured for a many-to-one relationship
    • There is no need for a navigation property from Tag to Item, so it is not included.
    • Also, Tag does not define a foreign key property. Instead, EF will create and manage a property in shadow-state.

The Tag entity type

The Tag entity type:

public class Tag
{
    private readonly int _id;

    private Tag(int id, string label)
    {
        _id = id;
        Label = label;
    }

    public Tag(string label) => Label = label;

    public string Label { get; }

    public int Count { get; set; }
}

And its configuration in DbContext.OnModelCreating:

modelBuilder.Entity<Tag>(
    b =>
    {
        b.Property("_id");
        b.HasKey("_id");
        b.Property(e => e.Label);
    });

Similarly to Item, Tag hides its primary key and makes the Label property read-only.

The ItemsController

The Web API controller is pretty basic. It gets a DbContext from the dependency injection container through constructor injection:

private readonly ItemsContext _context;

public ItemsController(ItemsContext context)
    => _context = context;

It has methods to get all Items or an Item with a given name:

[HttpGet]
public IEnumerable<Item> Get()
    => _context.Set<Item>().Include(e => e.Tags).OrderBy(e => e.Name);

[HttpGet]
public Item Get(string itemName)
    => _context.Set<Item>().Include(e => e.Tags).FirstOrDefault(e => e.Name == itemName);

It has a method to add a new Item:

[HttpPost]
public ActionResult<Item> PostItem(string itemName)
{
    var item = _context.Add(new Item(itemName)).Entity;

    _context.SaveChanges();

    return item;
}

A method to tag an Item with a label:

[HttpPost]
public ActionResult<Tag> PostTag(string itemName, string tagLabel)
{
    var tag = _context
        .Set<Item>()
        .Include(e => e.Tags)
        .Single(e => e.Name == itemName)
        .AddTag(tagLabel);

    _context.SaveChanges();

    return tag;
}

And a method to delete an Item and all associated Tags:

[HttpDelete("{itemName}")]
public ActionResult<Item> DeleteItem(string itemName)
{
    var item = _context
        .Set<Item>()
        .SingleOrDefault(e => e.Name == itemName);

    if (item == null)
    {
        return NotFound();
    }

    _context.Remove(item);
    _context.SaveChanges();

    return item;
}

Most validation and error handling have been removed to reduce clutter.

The Tests

The tests are organized to run with multiple database provider configurations:

  • The SQL Server provider, which is the provider used by the application
  • The SQLite provider
  • The SQLite provider using in-memory SQLite databases
  • The EF in-memory database provider

This is achieved by putting all the tests in a base class, then inheriting from this to test with each provider.

Tip

You will need to change the SQL Server connection string if you're not using LocalDB. See Testing with SQLite for guidance on using SQLite for in-memory testing.

The following two tests are expected to fail:

  • Can_remove_item_and_all_associated_tags when running with the EF in-memory database provider
  • Can_add_item_differing_only_by_case when running with the SQL Server provider

This is covered in more detail below.

Setting up and seeding the database

XUnit, like most testing frameworks, will create a new test class instance for each test run. Also, XUnit will not run tests within a given test class in parallel. This means that we can set up and configure the database in the test constructor and it will be in a well-known state for each test.

Tip

This sample recreates the database for each test. This works well for SQLite and EF in-memory database testing but can involve significant overhead with other database systems, including SQL Server. Approaches for reducing this overhead are covered in Sharing databases across tests.

When each test is run:

  • DbContextOptions are configured for the provider in use and passed to the base class constructor
    • These options are stored in a property and used throughout the tests for creating DbContext instances
  • A Seed method is called to create and seed the database
    • The Seed method ensures the database is clean by deleting it and then re-creating it
    • Some well-known test entities are created and saved to the database
protected ItemsControllerTest(DbContextOptions<ItemsContext> contextOptions)
{
    ContextOptions = contextOptions;

    Seed();
}

protected DbContextOptions<ItemsContext> ContextOptions { get; }

private void Seed()
{
    using (var context = new ItemsContext(ContextOptions))
    {
        context.Database.EnsureDeleted();
        context.Database.EnsureCreated();

        var one = new Item("ItemOne");
        one.AddTag("Tag11");
        one.AddTag("Tag12");
        one.AddTag("Tag13");

        var two = new Item("ItemTwo");

        var three = new Item("ItemThree");
        three.AddTag("Tag31");
        three.AddTag("Tag31");
        three.AddTag("Tag31");
        three.AddTag("Tag32");
        three.AddTag("Tag32");

        context.AddRange(one, two, three);

        context.SaveChanges();
    }
}

Each concrete test class then inherits from this. For example:

public class SqliteItemsControllerTest : ItemsControllerTest
{
    public SqliteItemsControllerTest()
        : base(
            new DbContextOptionsBuilder<ItemsContext>()
                .UseSqlite("Filename=Test.db")
                .Options)
    {
    }
}

Test structure

Even though the application uses dependency injection, the tests do not. It would be fine to use dependency injection here, but the additional code it requires has little value. Instead, a DbContext is created using new and then directly passed as the dependency to the controller.

Each test then executes the method under test on the controller and asserts the results are as expected. For example:

[Fact]
public void Can_get_items()
{
    using (var context = new ItemsContext(ContextOptions))
    {
        var controller = new ItemsController(context);

        var items = controller.Get().ToList();

        Assert.Equal(3, items.Count);
        Assert.Equal("ItemOne", items[0].Name);
        Assert.Equal("ItemThree", items[1].Name);
        Assert.Equal("ItemTwo", items[2].Name);
    }
}

Notice that different DbContext instances are used to seed the database and run the tests. This ensures that the test is not using (or tripping over) entities tracked by the context when seeding. It also better matches what happens in web apps and services.

Tests that mutate the database create a second DbContext instance in the test for similar reasons. That is, creating a new, clean, context and then reading into it from the database to ensure that the changes were saved to the database. For example:

[Fact]
public void Can_add_item()
{
    using (var context = new ItemsContext(ContextOptions))
    {
        var controller = new ItemsController(context);

        var item = controller.PostItem("ItemFour").Value;

        Assert.Equal("ItemFour", item.Name);
    }

    using (var context = new ItemsContext(ContextOptions))
    {
        var item = context.Set<Item>().Single(e => e.Name == "ItemFour");

        Assert.Equal("ItemFour", item.Name);
        Assert.Equal(0, item.Tags.Count);
    }
}

Two slightly more involved tests cover the business logic around adding tags.

[Fact]
public void Can_add_tag()
{
    using (var context = new ItemsContext(ContextOptions))
    {
        var controller = new ItemsController(context);

        var tag = controller.PostTag("ItemTwo", "Tag21").Value;

        Assert.Equal("Tag21", tag.Label);
        Assert.Equal(1, tag.Count);
    }

    using (var context = new ItemsContext(ContextOptions))
    {
        var item = context.Set<Item>().Include(e => e.Tags).Single(e => e.Name == "ItemTwo");

        Assert.Equal(1, item.Tags.Count);
        Assert.Equal("Tag21", item.Tags[0].Label);
        Assert.Equal(1, item.Tags[0].Count);
    }
}
[Fact]
public void Can_add_tag_when_already_existing_tag()
{
    using (var context = new ItemsContext(ContextOptions))
    {
        var controller = new ItemsController(context);

        var tag = controller.PostTag("ItemThree", "Tag32").Value;

        Assert.Equal("Tag32", tag.Label);
        Assert.Equal(3, tag.Count);
    }

    using (var context = new ItemsContext(ContextOptions))
    {
        var item = context.Set<Item>().Include(e => e.Tags).Single(e => e.Name == "ItemThree");

        Assert.Equal(2, item.Tags.Count);
        Assert.Equal("Tag31", item.Tags[0].Label);
        Assert.Equal(3, item.Tags[0].Count);
        Assert.Equal("Tag32", item.Tags[1].Label);
        Assert.Equal(3, item.Tags[1].Count);
    }
}

Issues using different database providers

Testing with a different database system than is used in the production application can lead to problems. These are covered at the conceptual level in Testing code that uses EF Core. The sections below cover two examples of such issues demonstrated by the tests in this sample.

Test passes when the application is broken

One of the requirements for our application is that "Items have a case-sensitive name and a collection of Tags." This is pretty simple to test:

[Fact]
public void Can_add_item_differing_only_by_case()
{
    using (var context = new ItemsContext(ContextOptions))
    {
        var controller = new ItemsController(context);

        var item = controller.PostItem("itemtwo").Value;

        Assert.Equal("itemtwo", item.Name);
    }

    using (var context = new ItemsContext(ContextOptions))
    {
        var item = context.Set<Item>().Single(e => e.Name == "itemtwo");

        Assert.Equal(0, item.Tags.Count);
    }
}

Running this test against the EF in-memory database indicates that everything is fine. Everything still looks fine when using SQLite. But the test fails when run against SQL Server!

System.InvalidOperationException : Sequence contains more than one element
   at System.Linq.ThrowHelper.ThrowMoreThanOneElementException()
   at System.Linq.Enumerable.Single[TSource](IEnumerable`1 source)
   at Microsoft.EntityFrameworkCore.Query.Internal.QueryCompiler.Execute[TResult](Expression query)
   at Microsoft.EntityFrameworkCore.Query.Internal.EntityQueryProvider.Execute[TResult](Expression expression)
   at System.Linq.Queryable.Single[TSource](IQueryable`1 source, Expression`1 predicate)
   at Tests.ItemsControllerTest.Can_add_item_differing_only_by_case()

This is because both the EF in-memory database and the SQLite database are case-sensitive by default. SQL Server, on the other hand, is case-insensitive!

EF Core, by design, does not change these behaviors because forcing a change in case-sensitivity can have a big performance impact.

Once we know this is a problem we can fix the application and compensate in tests. However, the point here is that this bug could be missed if only testing with the EF in-memory database or SQLite providers.

Test fails when the application is correct

Another of the requirements for our application is that "deleting an Item should delete all associated Tags." Again, easy to test:

[Fact]
public void Can_remove_item_and_all_associated_tags()
{
    using (var context = new ItemsContext(ContextOptions))
    {
        var controller = new ItemsController(context);

        var item = controller.DeleteItem("ItemThree").Value;

        Assert.Equal("ItemThree", item.Name);
    }

    using (var context = new ItemsContext(ContextOptions))
    {
        Assert.False(context.Set<Item>().Any(e => e.Name == "ItemThree"));
        Assert.False(context.Set<Tag>().Any(e => e.Label.StartsWith("Tag3")));
    }
}

This test passes on SQL Server and SQLite, but fails with the EF in-memory database!

Assert.False() Failure
Expected: False
Actual:   True
   at Tests.ItemsControllerTest.Can_remove_item_and_all_associated_tags()

In this case, the application is working correctly because SQL Server supports cascade deletes. SQLite also supports cascade deletes, as do most relational databases, so testing this on SQLite works. On the other hand, the EF in-memory database does not support cascade deletes. This means that this part of the application cannot be tested with the EF in-memory database provider.