测试时不使用生产数据库系统

在本页中,我们将讨论编写自动化测试的技术,这些测试不涉及应用程序在生产环境中运行的数据库系统,方法是将数据库交换为 测试双精度。 有多种类型的测试双精度和方法可用于执行此操作,建议全面阅读 "选择测试策略 ",以充分了解不同的选项。 最后,还可以针对生产数据库系统进行测试:这是在 针对生产数据库系统进行测试中介绍的。

提示

本页显示了 xUnit 技术,但其他测试框架(包括 NUnit)中存在类似的概念。

存储库模式

如果决定在不涉及生产数据库系统的情况下编写测试,则建议采用存储库模式执行此操作的方法:有关此方面的更多背景信息,请参阅 本部分。 实现存储库模式的第一步是将 EF Core LINQ 查询提取到单独的层,稍后我们将进行存根或模拟。 下面是博客系统的存储库接口示例:

public interface IBloggingRepository
{
    Blog GetBlogByName(string name);

    IEnumerable<Blog> GetAllBlogs();

    void AddBlog(Blog blog);

    void SaveChanges();
}

...下面是用于生产用途的部分示例实现:

public class BloggingRepository : IBloggingRepository
{
    private readonly BloggingContext _context;

    public BloggingRepository(BloggingContext context)
        => _context = context;

    public Blog GetBlogByName(string name)
        => _context.Blogs.FirstOrDefault(b => b.Name == name);

它没有太多内容:存储库只是包装 EF Core 上下文,并公开对其执行数据库查询和更新的方法。 需要注意的一个关键点是,我们的 GetAllBlogs 方法返回 IEnumerable<Blog>,而不是 IQueryable<Blog>。 返回后者意味着查询运算符仍可以通过结果组成,这要求 EF Core 仍参与转换查询:这将失败首先拥有存储库的目的。 IEnumerable<Blog> 允许我们轻松存根或模拟存储库返回的内容。

对于 ASP.NET Core应用程序,我们需要通过将以下内容添加到应用程序的ConfigureServices依赖项注入中将存储库注册为服务:

services.AddScoped<IBloggingRepository, BloggingRepository>();

最后,控制器会注入存储库服务而不是 EF Core 上下文,并在其中执行方法:

private readonly IBloggingRepository _repository;

public BloggingControllerWithRepository(IBloggingRepository repository)
    => _repository = repository;

[HttpGet]
public Blog GetBlog(string name)
    => _repository.GetBlogByName(name);

此时,应用程序根据存储库模式构建:与数据访问层(EF Core)的唯一接触点现在通过存储库层构建,该层充当应用程序代码与实际数据库查询之间的中介。 现在,只需通过调出存储库或用你喜欢的模拟库模拟测试来编写测试。 下面是使用常用 Moq 库的基于模拟的测试的示例:

[Fact]
public void GetBlog()
{
    // Arrange
    var repositoryMock = new Mock<IBloggingRepository>();
    repositoryMock
        .Setup(r => r.GetBlogByName("Blog2"))
        .Returns(new Blog { Name = "Blog2", Url = "http://blog2.com" });

    var controller = new BloggingControllerWithRepository(repositoryMock.Object);

    // Act
    var blog = controller.GetBlog("Blog2");

    // Assert
    repositoryMock.Verify(r => r.GetBlogByName("Blog2"));
    Assert.Equal("http://blog2.com", blog.Url);
}

在此处查看完整的示例代码。

SQLite 内存中

可以轻松将 SQLite 配置为测试套件的 EF Core 提供程序,而不是生产数据库系统 (,例如SQL Server) ;有关详细信息,请参阅 SQLite 提供程序文档。 但是,在测试时,通常最好使用 SQLite 的 内存中数据库 功能,因为它在测试之间提供简单的隔离,并且不需要处理实际的 SQLite 文件。

若要使用内存中 SQLite,请务必了解每当打开低级别连接时会创建一个新数据库,并删除了关闭该连接的内容。 在正常使用中,EF Core DbContext 会根据需要打开和关闭数据库连接(每次执行查询时),以避免长时间保留连接。 但是,使用内存中 SQLite 时,每次都会重置数据库:因此,作为一种解决方法,我们在将连接传递给 EF Core 之前打开连接,并仅在测试完成时将其安排关闭:

    public SqliteInMemoryBloggingControllerTest()
    {
        // Create and open a connection. This creates the SQLite in-memory database, which will persist until the connection is closed
        // at the end of the test (see Dispose below).
        _connection = new SqliteConnection("Filename=:memory:");
        _connection.Open();

        // These options will be used by the context instances in this test suite, including the connection opened above.
        _contextOptions = new DbContextOptionsBuilder<BloggingContext>()
            .UseSqlite(_connection)
            .Options;

        // Create the schema and seed some data
        using var context = new BloggingContext(_contextOptions);

        if (context.Database.EnsureCreated())
        {
            using var viewCommand = context.Database.GetDbConnection().CreateCommand();
            viewCommand.CommandText = @"
CREATE VIEW AllResources AS
SELECT Url
FROM Blogs;";
            viewCommand.ExecuteNonQuery();
        }

        context.AddRange(
            new Blog { Name = "Blog1", Url = "http://blog1.com" },
            new Blog { Name = "Blog2", Url = "http://blog2.com" });
        context.SaveChanges();
    }

    BloggingContext CreateContext() => new BloggingContext(_contextOptions);

    public void Dispose() => _connection.Dispose();

现在 CreateContext,测试可以使用我们在构造函数中设置的连接返回上下文,确保我们有一个具有种子数据的干净数据库。

在此处查看 SQLite 内存中测试的完整示例代码。

内存中提供程序

测试概述页中所述,强烈建议不要使用内存中提供程序进行测试; 请考虑改用 SQLite实现存储库模式。 如果决定使用内存中,下面是一个典型的测试类构造函数,在每次测试之前设置并设定新的内存中数据库种子:

public InMemoryBloggingControllerTest()
{
    _contextOptions = new DbContextOptionsBuilder<BloggingContext>()
        .UseInMemoryDatabase("BloggingControllerTest")
        .ConfigureWarnings(b => b.Ignore(InMemoryEventId.TransactionIgnoredWarning))
        .Options;

    using var context = new BloggingContext(_contextOptions);

    context.Database.EnsureDeleted();
    context.Database.EnsureCreated();

    context.AddRange(
        new Blog { Name = "Blog1", Url = "http://blog1.com" },
        new Blog { Name = "Blog2", Url = "http://blog2.com" });

    context.SaveChanges();
}

在此处查看内存中测试的完整示例代码。

内存中数据库命名

内存中数据库通过简单的字符串名称进行标识,并且可以通过提供同一名称多次连接到同一数据库, (这就是上述示例在每个测试) 之前必须调用 EnsureDeleted 的原因。 但是,请注意,内存中数据库根植于上下文的内部服务提供程序中;虽然在大多数情况下,上下文共享相同的服务提供商,但使用不同的选项配置上下文可能会触发使用新的内部服务提供商。 在这种情况下,请显式将同一实例 InMemoryDatabaseRoot 传递给 UseInMemoryDatabase 所有应共享内存中数据库的上下文, (通常通过具有静态 InMemoryDatabaseRoot 字段) 来完成此操作。

事务

请注意,默认情况下,如果启动事务,内存中提供程序将引发异常,因为不支持事务。 你可能希望通过将 EF Core 配置为忽略 InMemoryEventId.TransactionIgnoredWarning ,以无提示方式忽略事务,如上述示例中所示。 但是,如果代码实际上依赖于事务语义(例如,依赖于回滚实际回滚更改)则测试不起作用。

视图

内存中提供程序允许通过 LINQ 查询定义视图,使用 ToInMemoryQuery

modelBuilder.Entity<UrlResource>()
    .ToInMemoryQuery(() => context.Blogs.Select(b => new UrlResource { Url = b.Url }));