使用 EF Core 和内存数据库进行单元测试

Unit testing with EF Core and in memory database

我正在使用 ASP.NET Core 2.2、EF Core 和最小起订量。正如您在下面的代码中看到的,我有两个测试,并且 运行 将两者结合在一起,两个数据库名称都为 "MovieListDatabase" 我在其中一个测试中收到错误消息:

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

如果我运行每一个都通过。

而且,在两个测试中使用不同的数据库名称,例如 "MovieListDatabase1" 和 "MovieListDatabase2" 以及 运行 将两者结合在一起,它再次通过。

我有两个问题: 为什么会这样?以及如何重构我的代码以在两个测试中重新使用内存数据库并使我的测试看起来更干净一些?

 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);
        }
    }
}

这是存储库 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)
                                             );
    }
}

谢谢

您可以通过在时间戳后附加数据库名称来解决问题。

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

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

内存中只创建了一个给定名称的数据库。 (Documentation) 因此,如果你有相同的名字,这种异常可能会发生。

那里有类似的讨论:

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.

this github 问题也建议使用相同的方法添加具有数据库名称的唯一字符串 希望这有帮助。

看起来你可能想要 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.

创建一个单独的 class 来设置您的测试将共享的任何数据,并在测试完成后清理它 运行。

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();
    }
}

然后通过扩展 IClassFixture<T> 接口在您的测试中使用它。

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

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

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

    }
}

使用夹具 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 : 没有为此 DbContext 配置数据库提供程序。可以通过覆盖 DbContext.OnConfiguring 方法或在应用程序服务提供者上使用 AddDbContext 来配置提供者。如果使用 AddDbContext,则还要确保您的 DbContext 类型在其构造函数中接受 DbContextOptions 对象,并将其传递给 DbContext 的基本构造函数。 ---- 以下构造函数参数没有匹配的夹具数据: MovieSeedDataFixture fixture

我已经创建了一个空的构造函数来使用 fixture class 但是,我想它需要使用带有以下选项的构造函数:

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

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

    }

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

谢谢,我对固定装置 class 进行了一些更改并且工作正常,即使我 运行 两个测试一起进行也是如此。

更改如下:

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();
    }
}

我只想为这个讨论添加额外的解决方案,并在我的测试用例中提到一个独特的行为。

最简单的方法是创建上下文工厂并使用唯一的数据库名称启动它。

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


            return new SampleContext(options);
        }
     }

在内存上下文中处理时避免使用静态数据,内存数据库上下文将尝试从以前的上下文中加载所有数据,即使它具有不同的数据库名称,很奇怪:)。

由于您使用的是 XUnit,因此您可以实现 IDisposable 接口并在所有执行后删除数据库。

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

对于使用 NUnit 的开发人员,他们可以使用具有 [TearDown] 属性的函数来进行相同的操作

我认为另一种方法是在每次测试后重建并清空内存数据库。此外,与构建数据库相关的样板代码可以为所有测试 class 编写一次。以下示例显示了一种方法:

基地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);
    }

示例测试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
 }

基 class 完成所有初始化。重复使用相同的内存数据库,但它被清空以避免测试处理其他测试修改的数据。

我不太喜欢的唯一方面是实际测试中的显式重置功能 class,但它非常短,而且该代码无论如何都必须在 class 中的某个地方。