如何在 WebApplicationFactory 中设置 SQLite?

How to setup SQLite in WebApplicationFactory?

我正在为我的 ASP.NET 核心 MVC 应用程序编写集成测试。测试即将向控制器发送 POST 请求,然后检查数据库是否已正确更新。

我有一个 CustomWebApplicationFactory 试图配置 SQLite 内存数据库,但可能我做错了什么。

public class CustomWebApplicationFactory<TStartup> : WebApplicationFactory<TStartup> where TStartup : class
{
    private SqliteConnection Connection;

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        Connection = new SqliteConnection("DataSource=:memory:");
        Connection.Open();

        builder.UseEnvironment("Development");

        builder.ConfigureTestServices(services =>
        {
            // Unregister existing database service (SQL Server).
            var descriptor = services.SingleOrDefault(
                d => d.ServiceType ==
                    typeof(DbContextOptions<AppDbContext>));

            if (descriptor != null) services.Remove(descriptor);

            // Register new database service (SQLite In-Memory)
            services.AddDbContext<AppDbContext>(options => options.UseSqlite(Connection));
        });
    }

    protected override void Dispose(bool disposing)
    {
        base.Dispose(disposing);
        Connection.Close();
    }
}

我的测试是这样的:

public class OrderControllerTests : IClassFixture<CustomWebApplicationFactory<Startup>>
{
    private readonly HttpClient _httpClient;
    private readonly AppDbContext _context;

    public OrderControllerTests(CustomWebApplicationFactory<Startup> factory)
    {
        _httpClient = factory.CreateDefaultClient();

        var scopeFactory = factory.Services.GetService<IServiceScopeFactory>();

        using var scope = scopeFactory.CreateScope();

        _context = scope.ServiceProvider.GetService<AppDbContext>();
    }

    [Fact]
    public async Task Create_Post_OrderIsCreated()
    {
        // ...
        _context.Customers.Add(customer);
        _context.SaveChanges();
       // ...
    }
}

当我 运行 测试时,行 _context.Customers.Add(customer); 触发了 CustomWebApplicationFactory.Dispose() 方法,我得到一个错误:

System.ObjectDisposedException : Cannot access a disposed context instance. A common cause of this error is disposing a context instance that was resolved from dependency injection and then later trying to use the same context instance elsewhere in your application. This may occur if you are calling 'Dispose' on the context instance, or wrapping it in a using statement. If you are using dependency injection, you should let the dependency injection container take care of disposing context instances.

Object name: 'AppDbContext'.

错误信息描述的很详细,但是不知道怎么解决。为什么要处理数据库上下文?

我想我已经弄明白了,问题是 _context 仅在 _scope 中可用,所以我删除了 using,现在我在 [之间共享了数据库=16=] 测试。每个测试还可以选择清空和填充数据库。

private readonly HttpClient _httpClient;
private readonly AppDbContext _context;
private readonly IServiceScope _scope;

public OrderControllerTests(CustomWebApplicationFactory<Startup> factory)
{
    _httpClient = factory.CreateDefaultClient();
    _scope = (factory.Services.GetRequiredService<IServiceScopeFactory>()).CreateScope();
    _context = _scope.ServiceProvider.GetRequiredService<AppDbContext>();
    // database is now shared across tests
    _context.Database.EnsureCreated();
}

@Muflix 我无法在你的评论问题后添加评论,因为太长了,所以在这里......

我正在使用 xUnit 和 Shouldly:

Rather than create the scoped service in the constructor and then access from the test(s) I have been injecting the factory (stored as private readonly CustomWebApplicationFactory<Startup> _factory;) then in the tests I use the factory to create a scope and access a scoped service (such as dbcontext) from within each test. The tests share the database due to the fact that the WebApplicationFactory is a class fixture or collection fixture and this maintains a single database connection through its use of the DatabaseFixture member (note that this is not strictly being used as a fixture here it is simply instantiated as a member of the web application factory, it is so called because it is used elsewhere in my code as a unit testing fixture). The constructor in the WebApplicationFactory correctly instantiates the DatabaseFixture class which in turn opens the database connection and only closes it when the WebApplicationFactory (and therefore the DatabaseFixture) is disposed.

我的测试:

public class MyControllerTests : IClassFixture<MySQLAppFactory<Startup>> // see below for MySQLAppFactory
{
    private readonly MySQLAppFactory<Startup> _factory; // shared for ALL tests in this class (classFixture)

    public MyControllerTests(MySQLAppFactory<Startup> factory)
    {
        _factory = factory;
    }

    [Theory] //example of many tests all of which use SAME _factory instance
    [JsonArrayData("Path-to-Somewhere/MyData.json")] // pass some data
    public async Task Post_should_do_whatever(MyRequest request) // I'm using Shouldly
    {
        var client = _factory.CreateClient(); // create a client form the single instance of webApplicationFactory
        var httpContent = new StringContent(JsonConvert.SerializeObject(request), Encoding.UTF8, "application/json");
        var response = await client.PostAsync($"api/my", httpContent);

        response.StatusCode.ShouldBe(HttpStatusCode.Whatever);
    }
}

MySQLAppFactory 在构造期间创建了自己的单个 DatabaseFixture (TestDatabase)(它从服务中删除了现有的数据库上下文,并替换为使用此 DatabaseFixture 的上下文)。这个相同的数据库 class 仍然存在(连接保持打开状态)并且在整个集成测试中使用它。每次创建新上下文时,都会附加到同一个数据库连接。它与用于单元测试的基础 class 相同(但在单元测试的情况下,我主要使用 SQLite 派生数据库,而不是 MySQL)。


public class MySQLAppFactory<TStartup> : WebApplicationFactory<TStartup> where TStartup : class
{
    private readonly DatabaseFixture _databaseFixture; // THIS factory has THE single Database Fixture instance here

    private bool _disposed;

    public MySQLAppFactory()
    {
        _databaseFixture = new MySQLFixture(); // Create the single instance of MySQL fixture here
    }

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder
            .UseEnvironment("Testing")
            .ConfigureServices(services => //also (as of ASP NET Core 3.0) runs after TStartup.ConfigureServices
            {
                 // remove DbcontextOptions from original API project
                 var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<MyContext>)); services.Remove(descriptor);

                // add the test database context instead
                services.AddScoped<MyContext>(_ => _databaseFixture.GetContext());

                var sp = services.BuildServiceProvider();
            });
    }

    protected override void Dispose(bool disposing)
    {
        if (!_disposed)
        {
            if (disposing)
            {
                _databaseFixture?.Dispose();
            }
        }

        base.Dispose(disposing);
        _disposed = true;
    }

    ~MySQLAppFactory() => Dispose(false);
}

夹具(底座):

public abstract class DatabaseFixture : IDisposable
{
    private readonly object _seedLock = new object();

    private bool _disposed = false;

    public DbConnection dbConnection { get; protected set; }

    protected abstract bool IsInitialized(bool init = false);

    protected abstract DbContextOptions<IMAppContext> GetBuildOptions();

    // Note that DbContext instances created in this way should be disposed by the 
    // calling code (typically with a using {} block).
    public MyTestContext GetContext(DbTransaction transaction = null)
    {
        var context = new MyTestContext(GetBuildOptions());
        if (transaction != null)
        {
            context.Database.UseTransaction(transaction);
        }
        return context;
    }

    protected void Seed()
    {
        // lock and seed here
    }

    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }

    protected virtual void Dispose(bool disposing)
    {
        if (!_disposed)
        {

            if (disposing)
            {
                // dispose managed state (managed objects) that implement IDisposable
                dbConnection?.Dispose();
            }

            // free unmanaged resources (unmanaged objects) and override a finalizer below.
            ////if anything exists here then the finalizer is required

            // set large fields to null to help release them faster.

            _disposed = true;
        }
    }

}

The Fixture(MySQL派生——因为我也用其他的):

public sealed class MySQLFixture : DatabaseFixture
{
    private bool _initialised = false;

    private bool _disposed = false;

    private readonly string _connectionString;

    private readonly string _databaseName;

    public MySQLFixture()
    {
        _databaseName = "some name possibly derived from config or guid etc";
        _connectionString = "using _databasename and possibly config, build or environment etc";

        dbConnection = new MySqlConnection(_connectionString);
        Seed();
    }

    protected override bool IsInitialized(bool init = false)
    {
        if (!init)
        {
            return _initialised;
        }
        else
        {
            _initialised = init;
            return _initialised;
        }
    }

    protected override DbContextOptions<IMAppContext> GetBuildOptions()
    {
        return new DbContextOptionsBuilder<IMAppContext>().UseMySQL(dbConnection).Options;
    }

    protected override void Dispose(bool disposing)
    {
        if (!_disposed)
        {
            if (disposing)
            {
                // dispose managed state (managed objects) that implement IDisposable
            }

            // free unmanaged resources (unmanaged objects) and override a finalizer below.
            ////if anything exists here then the this.finalizer is required below
            dbConnection.Open();
            var command = dbConnection.CreateCommand();
            command.CommandText = $"DROP SCHEMA IF EXISTS `{_databaseName.ToLower()}`";
            command.ExecuteNonQuery();

            // set large fields to null to help release them faster.
        }

        base.Dispose(disposing);
        _disposed = true;
    }

    ~MySQLFixture() => Dispose(false);
}