在测试之间共享数据库Sharing databases between tests

EF Core 测试示例演示了如何针对不同的数据库系统测试应用程序。The EF Core testing sample showed how to test applications against different database systems. 对于该示例,每个测试都会创建一个新数据库。For that sample, each test created a new database. 当使用 SQLite 或 EF 内存中数据库时,这是一个很好的模式,但在使用其他数据库系统时,这种模式可能会造成很大的开销。This is a good pattern when using SQLite or the EF in-memory database, but it can involve significant overhead when using other database systems.

此示例通过将数据库创建移到测试装置中来构建前面的示例。This sample builds on the previous sample by moving database creation into a test fixture. 这允许创建单个 SQL Server 数据库,并只为所有测试创建一次。This allows a single SQL Server database to be created and seeded only once for all tests.

提示

在继续操作之前,请务必完成EF Core 测试示例Make sure to work through the EF Core testing sample before continuing here.

为同一个数据库编写多个测试并不困难。It's not difficult to write multiple tests against the same database. 这一技巧以测试在运行时不会相互行程的方式进行操作。The trick is doing it in a way that the tests don't trip over each other as they run. 这需要了解:This requires understanding:

  • 如何在测试之间安全共享对象How to safely share objects between tests
  • 当测试框架并行运行测试时When the test framework runs tests in parallel
  • 如何使数据库处于每个测试的干净状态How to keep the database in a clean state for every test

装置The fixture

我们将使用测试装置在测试之间共享对象。We will use a test fixture for sharing objects between tests. 当你想要创建单个测试上下文并将其共享到类中的所有测试中并在类中的所有测试都完成后, XUnit 文档将指出应使用的装置。 "The XUnit documentation states that a fixture should be used "when you want to create a single test context and share it among all the tests in the class, and have it cleaned up after all the tests in the class have finished."

提示

此示例使用的是XUnit,但其他测试框架中也存在类似的概念,包括NUnitThis sample uses XUnit, but similar concepts exist in other testing frameworks, including NUnit.

这意味着我们需要将数据库创建和种子设定转移到夹具类。This means that we need to move database creation and seeding to a fixture class. 如下所示:Here's what it looks like:

public class SharedDatabaseFixture : IDisposable
{
    private static readonly object _lock = new object(); 
    private static bool _databaseInitialized; 
    
    public SharedDatabaseFixture()
    {
        Connection = new SqlConnection(@"Server=(localdb)\mssqllocaldb;Database=EFTestSample;ConnectRetryCount=0");
        
        Seed();
        
        Connection.Open();
    }

    public DbConnection Connection { get; }
    
    public ItemsContext CreateContext(DbTransaction transaction = null)
    {
        var context = new ItemsContext(new DbContextOptionsBuilder<ItemsContext>().UseSqlServer(Connection).Options);

        if (transaction != null)
        {
            context.Database.UseTransaction(transaction);
        }
        
        return context;
    }
    
    private void Seed()
    {
        lock (_lock)
        {
            if (!_databaseInitialized)
            {
                using (var context = CreateContext())
                {
                    context.Database.EnsureDeleted();
                    context.Database.EnsureCreated();

                    var one = new Item("ItemOne");
                    one.AddTag("Tag11");
                    one.AddTag("Tag12");
                    one.AddTag("Tag13");

                    var two = new Item("ItemTwo");

                    var three = new Item("ItemThree");
                    three.AddTag("Tag31");
                    three.AddTag("Tag31");
                    three.AddTag("Tag31");
                    three.AddTag("Tag32");
                    three.AddTag("Tag32");

                    context.AddRange(one, two, three);

                    context.SaveChanges();
                }

                _databaseInitialized = true;
            }
        }
    }

    public void Dispose() => Connection.Dispose();
}

现在,请注意构造函数的方式:For now, notice how the constructor:

  • 在装置的生存期内创建单个数据库连接Creates a single database connection for the lifetime of the fixture
  • 通过调用Seed方法创建并设定该数据库的种子Creates and seeds that database by calling the Seed method

立即忽略锁定;稍后我们将返回到它。Ignore the locking for now; we will come back to it later.

提示

创建和播种代码不需要是异步的。The creation and seeding code does not need to be async. 将其设为异步会使代码变得复杂,而不会提高测试的性能或吞吐量。Making it async will complicate the code and will not improve performance or throughput of tests.

数据库是通过先删除所有现有数据库,然后创建新的数据库创建的。The database is created by first deleting any existing database and then creating a new database. 这可确保数据库与当前 EF 模型匹配,即使自上次测试运行以来已更改。This ensures that the database matches the current EF model even if it has been changed since the last test run.

提示

使用respawn之类的内容(而不是每次重新创建),可以更快地 "清理" 现有数据库。It can be faster to "clean" the existing database using something like respawn rather than re-create it each time. 但是,在执行此操作时,必须注意确保数据库架构与 EF 模型保持最新。However, care must be taken to ensure that the database schema is up-to-date with the EF model when doing this.

释放装置后,数据库连接会被释放。The database connection is disposed when the fixture is disposed. 此时,您还可以考虑删除测试数据库。You may also consider deleting the test database at this point. 但是,如果多个测试类正在共享该装置,则这将需要额外的锁定和引用计数。However, this will require additional locking and reference counting if the fixture is being shared by multiple test classes. 此外,使测试数据库仍可用于调试失败的测试通常是很有用的。Also, it is often useful to have the test database still available for debugging failed tests.

使用装置Using the fixture

XUnit 具有一个通用模式,用于将测试装置与一类测试进行关联:XUnit has a common pattern for associating a test fixture with a class of tests:

public class SharedDatabaseTest : IClassFixture<SharedDatabaseFixture>
{
    public SharedDatabaseTest(SharedDatabaseFixture fixture) => Fixture = fixture;

    public SharedDatabaseFixture Fixture { get; }

XUnit 现在将创建单个夹具实例,并将其传递给测试类的每个实例。XUnit will now create a single fixture instance and pass it to each instance of the test class. (请记住,XUnit 会在每次运行测试时创建新的测试类实例。)这意味着将创建数据库并将其设定为种子,然后每个测试都将使用此数据库。(Remember from the first testing sample that XUnit creates a new test class instance every time it runs a test.) This means that the database will be created and seeded once and then each test will use this database.

请注意,不会并行运行单个类中的测试。Note that tests within a single class will not be run in parallel. 这意味着,即使DbConnection对象不是线程安全的,每个测试都可以安全地使用同一个数据库连接。This means it is safe for each test to use the same database connection, even though the DbConnection object is not thread-safe.

维护数据库状态Maintaining database state

测试通常需要对测试数据进行插入、更新和删除操作。Tests often need to mutate the test data with inserts, updates, and deletes. 但这些更改会影响到需要干净的种子数据库的其他测试。But these changes will then impact other tests which are expecting a clean, seeded database.

这可以通过在事务中运行转变测试来处理。This can be dealt with by running mutating tests inside a transaction. 例如:For example:

[Fact]
public void Can_add_item()
{
    using (var transaction = Fixture.Connection.BeginTransaction())
    {
        using (var context = Fixture.CreateContext(transaction))
        {
            var controller = new ItemsController(context);

            var item = controller.PostItem("ItemFour").Value;

            Assert.Equal("ItemFour", item.Name);
        }

        using (var context = Fixture.CreateContext(transaction))
        {
            var item = context.Set<Item>().Single(e => e.Name == "ItemFour");

            Assert.Equal("ItemFour", item.Name);
            Assert.Equal(0, item.Tags.Count);
        }
    }
}

请注意,在测试完成后将创建事务,并将其释放。Notice that the transaction is created as the test starts and disposed when it is finished. 释放事务将导致回滚该事务,因此其他测试将看不到任何更改。Disposing the transaction causes it to be rolled back, so none of the changes will be seen by other tests.

用于创建上下文的帮助器方法(请参阅上面的装置代码)接受此事务,并使用它来 DbContext。The helper method for creating a context (see the fixture code above) accepts this transaction and opts the DbContext into using it.

共享装置Sharing the fixture

您可能已注意到锁定了有关数据库创建和播种的代码。You may have noticed locking code around database creation and seeding. 这不是此示例所必需的,因为只有一类测试使用装置,因此仅创建单个装置实例。This is not needed for this sample since only one class of tests use the fixture, so only a single fixture instance is created.

但是,你可能想要将相同的装置用于多个测试类别。However, you may want to use the same fixture with multiple classes of tests. XUnit 将为这些类中的每个类创建一个装置实例。XUnit will create one fixture instance for each of these classes. 并行运行测试的不同线程可能会使用这些方法。These may be used by different threads running tests in parallel. 因此,请务必使用适当的锁定,以确保只有一个线程可以创建和播种数据库。Therefore, it is important to have appropriate locking to ensure only one thread does the database creation and seeding.

提示

这里简单lock明了。A simple lock is fine here. 无需尝试更复杂的操作,如任何无锁模式。There is no need to attempt anything more complex, such as any lock-free patterns.