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

在本页中,我们将讨论编写自动化测试的方法,这些测试不涉及与应用程序在生产中运行的数据库系统交互,而是通过将数据库交换为 测试替身 来实现。 有多种类型的测试替身和方法可用于执行此操作,建议通读“选择测试策略”,以充分了解不同的选项。 最后,还可以针对生产数据库系统进行测试;这在针对生产数据库系统进行测试中介绍。

提示

此页面展示了 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);

    // Other code...
}

它没有太多内容:存储库仅包装 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 }));