在没有生产数据库系统的情况下进行测试

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

提示

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

存储库模式

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

public interface IBloggingRepository
{
    Task<Blog> GetBlogByNameAsync(string name);

    IAsyncEnumerable<Blog> GetAllBlogsAsync();

    void AddBlog(Blog blog);

    Task SaveChangesAsync();
}

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

public class BloggingRepository : IBloggingRepository
{
    private readonly BloggingContext _context;

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

    public async Task<Blog> GetBlogByNameAsync(string name)
        => await _context.Blogs.FirstOrDefaultAsync(b => b.Name == name);

    // Other code...
}

这没有太复杂的内容:存储库仅仅是对 EF Core 上下文进行封装,并提供方法来执行数据库查询和更新。 需要注意的一个关键点是,我们的 GetAllBlogs 方法返回 IAsyncEnumerable<Blog>(或 IEnumerable<Blog>),而不是 IQueryable<Blog>。 返回后者意味着查询运算符仍然可以基于结果进行组合,这要求 EF Core 仍参与查询的翻译;这将违背最初拥有存储库的目的。 IAsyncEnumerable<Blog> 允许我们轻松替代或模拟存储库返回的结果。

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

services.AddScoped<IBloggingRepository, BloggingRepository>();

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

private readonly IBloggingRepository _repository;

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

[HttpGet]
public async Task<Blog> GetBlog(string name)
    => await _repository.GetBlogByNameAsync(name);

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

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

    var controller = new BloggingControllerWithRepository(repositoryMock.Object);

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

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

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

SQLite 内存中

可以轻松将 SQLite 配置为测试套件的 EF Core 提供程序,而非生产数据库系统(例如 SQL 服务器);有关详细信息,请参阅 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 }));