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

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

提示

本页介绍 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 的原因。 但请注意,内存中数据库植根于上下文的内部服务提供程序中;虽然在大多数情况下上下文共享相同的服务提供程序,但使用不同的选项配置上下文可能会触发使用新的内部服务提供程序。 在这种情况下,对于应共享内存中数据库的所有上下文,将 的同一实例InMemoryDatabaseRootUseInMemoryDatabase显式传递给 , (这通常是通过) 静态InMemoryDatabaseRoot字段来完成的。

事务

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

视图

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

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