简体   繁体   中英

Unit testing with EF Core and in memory database

I am using ASP.NET Core 2.2, EF Core and MOQ. As you can see in the following code, I have two tests, and running both together, with both database name "MovieListDatabase" I got an error in one of the tests with this message:

Message: System.ArgumentException : An item with the same key has already 
been added. Key: 1

If I run each one separately they both pass.

And also, having a different database name in both tests, like "MovieListDatabase1" and "MovieListDatabase2" and running both together it pass again.

I have two questions: Why does this happen? and how can I refactor my code to re-use the in-memory database in both tests and make my test to look a bit cleaner?

 public class MovieRepositoryTest
{
    [Fact]
    public void GetAll_WhenCalled_ReturnsAllItems()
    {

        var options = new DbContextOptionsBuilder<MovieDbContext>()
            .UseInMemoryDatabase(databaseName: "MovieListDatabase")
            .Options;

        // Insert seed data into the database using one instance of the context
        using (var context = new MovieDbContext(options))
        {
            context.Movies.Add(new Movie { Id = 1, Title = "Movie 1", YearOfRelease = 2018, Genre = "Action" });
            context.Movies.Add(new Movie { Id = 2, Title = "Movie 2", YearOfRelease = 2018, Genre = "Action" });
            context.Movies.Add(new Movie { Id = 3, Title = "Movie 3", YearOfRelease = 2019, Genre = "Action" });
            context.SaveChanges();
        }

        // Use a clean instance of the context to run the test
        using (var context = new MovieDbContext(options))
        {
            var sut = new MovieRepository(context);
            //Act
            var movies = sut.GetAll();

            //Assert
            Assert.Equal(3, movies.Count());
        }
    }

    [Fact]
    public void Search_ValidTitlePassed_ReturnsOneMovie()
    {
        var filters = new MovieFilters { Title = "Movie 1", YearOfRelease = 2018, Genre = "Action" };

        var options = new DbContextOptionsBuilder<MovieDbContext>()
            .UseInMemoryDatabase(databaseName: "MovieListDatabase")
            .Options;

        // Insert seed data into the database using one instance of the context
        using (var context = new MovieDbContext(options))
        {
            context.Movies.Add(new Movie { Id = 1, Title = "Movie 1", YearOfRelease = 2018, Genre = "Action" });
            context.Movies.Add(new Movie { Id = 2, Title = "Movie 2", YearOfRelease = 2018, Genre = "Action" });
            context.Movies.Add(new Movie { Id = 3, Title = "Movie 3", YearOfRelease = 2019, Genre = "Action" });
            context.SaveChanges();
        }

        // Use a clean instance of the context to run the test
        using (var context = new MovieDbContext(options))
        {
            var sut = new MovieRepository(context);

            //Act
            //var movies = _sut.Search(_filters);
            var movies = sut.Search(filters);

            //Assert
            Assert.Single(movies);
        }
    }
}

And this is the repository class

 public class MovieRepository: IMovieRepository
{
    private readonly MovieDbContext _moviesDbContext;
    public MovieRepository(MovieDbContext moviesDbContext)
    {
        _moviesDbContext = moviesDbContext;
    }

    public IEnumerable<Movie> GetAll()
    {
        return _moviesDbContext.Movies;
    }

    public IEnumerable<Movie> Search(MovieFilters filters)
    {
        var title = filters.Title.ToLower();
        var genre = filters.Genre.ToLower();
        return _moviesDbContext.Movies.Where( p => (p.Title.Trim().ToLower().Contains(title) | string.IsNullOrWhiteSpace(p.Title))
                                                   & (p.Genre.Trim().ToLower().Contains(genre) | string.IsNullOrWhiteSpace(p.Genre))
                                                   & (p.YearOfRelease == filters.YearOfRelease | filters.YearOfRelease == null)
                                             );
    }
}

Thanks

It looks like you might want a class fixture .

When to use: when you want to create a single test context and share it among all the tests in the class, and have it cleaned up after all the tests in the class have finished.

Create a separate class to setup whatever data your tests will share, and to clean it up when the tests are finished running.

public class MovieSeedDataFixture : IDisposable
{
    public MovieDbContext MovieContext { get; private set; } = new MovieDbContext();

    public MovieSeedDataFixture()
    {
        MovieContext.Movies.Add(new Movie { Id = 1, Title = "Movie 1", YearOfRelease = 2018, Genre = "Action" });
        MovieContext.Movies.Add(new Movie { Id = 2, Title = "Movie 2", YearOfRelease = 2018, Genre = "Action" });
        MovieContext.Movies.Add(new Movie { Id = 3, Title = "Movie 3", YearOfRelease = 2019, Genre = "Action" });
        MovieContext.SaveChanges();
    }

    public void Dispose()
    {
        MovieContext.Dispose();
    }
}

Then use it in your tests by extending the IClassFixture<T> interface.

public class UnitTests : IClassFixture<MovieSeedDataFixture>
{
    MovieSeedDataFixture fixture;

    public UnitTests(MovieSeedDataFixture fixture)
    {
        this.fixture = fixture;
    }

    [Fact]
    public void TestOne()
    {
        // use fixture.MovieContext in your tests

    }
}

You can resolve the issue by appending the timestamp with the name of database name.

var myDatabaseName = "mydatabase_"+DateTime.Now.ToFileTimeUtc();

var options = new DbContextOptionsBuilder<BloggingContext>()
                .UseInMemoryDatabase(databaseName: myDatabaseName )
                .Options;

Although I have not seen this in documentation, it seems only one database with given name is created in the memory. Hence if you have same name this kind of exception may occur.

Similar discussion is there on this thread :

optionsBuilder.UseInMemoryDatabase("MyDatabase"); 

This creates/uses a database with the name “MyDatabase”. If UseInMemoryDatabase is called again with the same name, then the same in-memory database will be used, allowing it to be shared by multiple context instances.

And this github issue also suggests the same approach to add a unique string with database name Hope this helps.

Thanks, I did some changes in the fixture class and is working fine, even when I run both tests together.

Here is the change:

public class MovieSeedDataFixture : IDisposable
{
    public MovieDbContext MovieContext { get; private set; }

    public MovieSeedDataFixture()
    {
        var options = new DbContextOptionsBuilder<MovieDbContext>()
            .UseInMemoryDatabase("MovieListDatabase")
            .Options;

        MovieContext = new MovieDbContext(options);

        MovieContext.Movies.Add(new Movie { Id = 1, Title = "Movie 1", YearOfRelease = 2018, Genre = "Action" });
        MovieContext.Movies.Add(new Movie { Id = 2, Title = "Movie 2", YearOfRelease = 2018, Genre = "Action" });
        MovieContext.Movies.Add(new Movie { Id = 3, Title = "Movie 3", YearOfRelease = 2019, Genre = "Action" });
        MovieContext.SaveChanges();
    }

    public void Dispose()
    {
        MovieContext.Dispose();
    }
}

I just want to add additional solution for this discussion and mention a unique behavior in my test case.

The easiest way is to create a context factory and initiate it with a unique database name.

   public static class ContextFactory
    {
        public static SampleContextCreateInMemoryContractContext()
        {
            var options = new DbContextOptionsBuilder<SchedulingContext>()
               .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
               .Options;


            return new SampleContext(options);
        }
     }

Avoid using a static data when dealing with in memory context, in memory database context will try to mount all the data from the previous context even it has a different database name, weird :).

Since you are using XUnit then you can implement IDisposable interface and drop database after all executions.

    public void Dispose()
    {
        context.Database.EnsureDeleted();
        context.Dispose();
    }

For the developers who are working with NUnit, they can use a function with [TearDown] attribute for same operation

The test is giving a big error using the fixture class:

Message: System.AggregateException : One or more errors occurred. (No database provider has been configured for this DbContext. A provider can be configured by overriding the DbContext.OnConfiguring method or by using AddDbContext on the application service provider. If AddDbContext is used, then also ensure that your DbContext type accepts a DbContextOptions<TContext> object in its constructor and passes it to the base constructor for DbContext.) (The following constructor parameters did not have matching fixture data: MovieSeedDataFixture fixture)

---- System.InvalidOperationException : No database provider has been configured for this DbContext. A provider can be configured by overriding the DbContext.OnConfiguring method or by using AddDbContext on the application service provider. If AddDbContext is used, then also ensure that your DbContext type accepts a DbContextOptions object in its constructor and passes it to the base constructor for DbContext. ---- The following constructor parameters did not have matching fixture data: MovieSeedDataFixture fixture

I Have created an empty constructor to use the fixture class but, I guess it need to use the constructor with the options:

public class MovieDbContext: DbContext
{
    public MovieDbContext()
    {
    }

    public MovieDbContext(DbContextOptions<MovieDbContext> options) : base(options)
    {

    }

    public DbSet<Movie> Movies { get; set; }
}

I think another approach is to reconstruct and empty the in-memory database after each test. Also, the boiler-plate code related to constructing the database can be written once for all test classes. The following example shows one way to do it:

Base class

public abstract class InMemoryTestBase
{
    protected IApplicationDbContext DbContext { get; private set; }

    protected InMemoryTestBase()
    {
        Init();
    }

    protected abstract void Reset();

    private void Init()
    {
        var options = new DbContextOptionsBuilder<ApplicationDbContext>()
            .UseInMemoryDatabase("ApplicationDbContext")
            .Options;

        DbContext = new ApplicationDbContext(options);

        Populate();
        DbContext.SaveChanges();

        Reset();
    }

    private void Populate()
    {
        DbContext.EnsureDeleted();

        PopulateApplicationUserData();
    }

    private void PopulateApplicationUserData()
    {
        DbContext.Set<ApplicationUser>().AddRange(ApplicationUserTestData.ApplicationUserData);
        DbContext.Set<ApplicationUserRole>().AddRange(ApplicationUserTestData.ApplicationUserRoleData);
    }

An example test class

public class GetApplicationUserCountQueryHandlerTests : InMemoryTestBase
{
    private IRequestHandler<GetApplicationUserCountQuery, int> _handler;
    
    protected override void Reset()
    {
        _handler = new GetApplicationUserCountQueryHandler(DbContext);
    }


    [Fact]
    public async Task Handle_ShouldReturnAllUserCountIfFilteringNonArchived()
    {
        int count = await _handler.Handle(new GetApplicationUserCountQuery, default);

        count.Should().Be(ApplicationUserTestData.ApplicationUserData.Count);
    }

    // other tests come here
 }

The base class does all the initialization. The same in-memory database is reused, but it is emptied to avoid tests working on data modified by other tests.

The only aspect I do not particularly like is that the explicit Reset functionality in the actual test class, but it is very short and that code must be somewhere in the class anyway.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM