【问题标题】:How to create a dummy database for functional tests in .NET Core?如何在 .NET Core 中为功能测试创建一个虚拟数据库?
【发布时间】:2021-01-12 03:19:00
【问题描述】:

我的应用是基于 .NET Core 和 Entity Framework 6 构建的。我针对 db 上下文进行了一些复杂的 C# LINQ 查询。虽然我有模拟数据库并检查查询结果的 XUnit 单元测试,但我发现行为并不总是相同的。如何编写测试来创建某种关系虚拟数据库,该数据库的行为与真实数据库完全相同,为数据库播种,执行和测试查询结果,然后删除/拆除数据库?我一直找不到东西。有一些文章建议单独创建本地数据库,但我想要一种技术,它可以在我的测试套件中完成包括数据库创建和删除在内的所有操作。

【问题讨论】:

  • EF 核心支持内存数据库提供程序。查看docs.microsoft.com/en-us/ef/core/providers/in-memory/…
  • @jegtugado,OP 希望 其行为与真实数据库完全相同 - 内存中的行为不像真实数据库。
  • 没有其他方法可以实际验证查询是否正常工作,然后针对您将在生产中使用的实际数据库运行测试。接受这组测试会很慢并且不能并行运行。在运行测试之前创建一个数据库或使用预定义的空数据库,用TransactionScope 包装每个测试而不提交它,因此下一个测试也将从空数据库开始。只有这样,您才能 100% 确定您的数据库上下文查询逻辑工作正常。

标签: c# linq .net-core entity-framework-6 xunit.net


【解决方案1】:

如果您想针对真实数据库编写测试(这是一个好主意,imo,如果您能够最大限度地减少此类测试的数量以避免测试运行时间过长),您别无他法,只能管理这个手动数据库:每次测试后创建、初始化和恢复它。

然后你有几个选择:

  • 使用唯一或几个预定义的数据库(如果有多个数据库,您可以并行化测试);
  • 使用带有TestContainers之类的docker来按需创建数据库。

前一种方法更简单,后者通常提供更多控制。

现在你已经解决了一个问题,还剩下三个问题:

  1. 执行 DDL 以初始化数据库结构;
  2. 测试所需的种子数据并对其进行清理;
  3. 将其与您的测试框架集成。

问题 1

基本上,您需要应用与生产环境相同的方法,无论是 EF 迁移、其他迁移库、dbproj 还是手动应用脚本。

这可能就像使用 SqlCommand 执行唯一的脚本一样简单

async Task ExecuteAsync(string script)
{
    await using var connection = new SqlConnection(connectionString);
    await using var command = connection.CreateCommand();
    command.CommandText = script;
    await connection.OpenAsync();
    return await command.ExecuteNonQueryAsync();
}

或者你可以使用DbUp 之类的东西来处理更复杂的场景。

问题 2

有几种方法可以播种您的数据:

  • 每个测试都会创建它需要的实体;
  • 重用一段代码,一次性为所有测试创建所有实体;
  • 手工编写的sql脚本;
  • 使用一些库。我知道一些:NDbUnit,它有点过时了,但有一些或多或少的新分叉和Reseed,它仍处于开发阶段(我是它的作者);

还有一些事后清理的方法:

  • 为每次测试从头开始创建数据库;
  • 从备份中恢复数据库;
  • 从快照恢复数据库;
  • 将测试包装在事务中,然后将其还原;
  • 在同一测试中手动恢复在每个测试中所做的更改;
  • 使用插入和删除sql脚本将数据恢复到初始状态;
  • 使用库为您插入和删除数据。 Reseed 也可以处理,还有Respawn

问题 3

您需要通过在夹具/夹具/测试运行之前和之后执行一些操作来设置/拆卸数据库来与您的测试框架集成。不同的测试框架为此提供了不同的方法:

  • 对 Joshua 在另一个答案中描述的 xUnit 使用 Collection fixtures 方法;
  • 为 NUnit 使用 OneTimeSetupOneTimeTearDownFixtureSetUpFixtureTearDownSetUpTearDown 属性;
  • AssemblyInitializeAssemblyCleanupClassInitializeClassCleanupTestInitializeTestCleanup 用于 MSTest。

我个人使用DbUpTestContainersReseed 来准备一个用于测试的数据库。

这是在 docker 中启动 db 的 sn-p:

public sealed class SqlServerContainer: IAsyncDisposable
{
    private readonly string scriptsFolder;
    private readonly Func<string, bool> scriptFilter;
    private readonly MsSqlTestcontainer server;

    public string ConnectionString => server.ConnectionString;

    public SqlServerContainer(string scriptsFolder)
        : this(scriptsFolder, _ => true)
    {
    }

    public SqlServerContainer(string scriptsFolder, Func<string, bool> scriptFilter)
    {
        this.scriptsFolder = scriptsFolder ?? throw new ArgumentNullException(nameof(scriptsFolder));
        this.scriptFilter = scriptFilter ?? throw new ArgumentNullException(nameof(scriptFilter));
        this.server = CreateDatabaseContainer();
    }

    public async Task StartAsync()
    {
        await StartServerAsync();
        EnsureDatabase.For.SqlDatabase(server.ConnectionString);

        var migrationResult = MigrateDatabase(
            server.ConnectionString,
            scriptsFolder,
            scriptFilter);

        if (!migrationResult.Successful)
        {
            throw new InvalidOperationException("Can't apply database migrations", migrationResult.Error);
        }
    }

    private async Task StartServerAsync()
    {
        try
        {
            await server.StartAsync();
        }
        catch (TimeoutException ex)
        {
            throw new InvalidOperationException(
                "Can't start sql server container, make sure docker is running",
                ex);
        }
    }

    public ValueTask DisposeAsync() => 
        this.server.DisposeAsync();

    private static MsSqlTestcontainer CreateDatabaseContainer() =>
        new TestcontainersBuilder<MsSqlTestcontainer>()
            .WithDatabase(new DbContainerConfiguration("MsSqlContainerDb", "!A1B2c3d4_"))
            .WithName($"sql-db_{Guid.NewGuid()}")
            .Build();

    private static DatabaseUpgradeResult MigrateDatabase(
        string connectionString,
        string scriptsPath,
        Func<string, bool> scriptFilter)
    {
        var upgrade = DeployChanges.To
            .SqlDatabase(connectionString, "dbo")
            .WithScriptsFromFileSystem(scriptsPath, scriptFilter)
            .LogToConsole()
            .JournalTo(new NullJournal())
            .WithTransactionPerScript()
            .Build();
            
        return upgrade.PerformUpgrade();
    }
}

public sealed class DbContainerConfiguration : TestcontainerDatabaseConfiguration
{
    private const string ServerImage = "mcr.microsoft.com/mssql/server:latest";
    private const string PasswordKey = "SA_PASSWORD";
    private const string EulaKey = "ACCEPT_EULA";
    private const int ServerPort = 1433;

    public DbContainerConfiguration(string database, string password) : base(ServerImage, ServerPort)
    {
        Environments[EulaKey] = "Y";
        Password = password;
        Database = database;
    }

    public override string Username => "sa";

    public override string Password
    {
        get => Environments[PasswordKey];
        set => Environments[PasswordKey] = value;
    }

    public override IWaitForContainerOS WaitStrategy => Wait
        .ForUnixContainer()
        .UntilPortIsAvailable(ServerPort);
}

【讨论】:

    【解决方案2】:

    我认为 Joshua 提供的与 xUnit 集成的组合,以及以下内容应该允许您测试数据库实例。我已经包含了我为测试EF6 Migrations 而创建的 POC 的部分源代码。您需要设置数据库的主要类型是DbMigratorHarness&lt;T&gt;,它可用于创建新数据库。它需要迁移配置类型,它设置代码优先迁移:

    var harness = new DbMigratorHarness<Migrations.Configuration>();
    harness.LatestMigration();
    
    var context = new MyDbContext(harness.ConnectionString);
    // Do work with context
    

    通过EfFixture 类型公开线束或上下文将允许您访问隔离的数据库实例。

    这里是DbMigratorHarness&lt;T&gt;的部分实现。完整源代码可在github.com/joncloud/ef6-migration-testing 获得。

    using System;
    using System.Data.Entity.Infrastructure;
    using System.Data.Entity.Migrations;
    using System.Data.SqlClient;
    
    public class DbMigratorHarness<T> : IDisposable
        where T : DbMigrationsConfiguration, new()
    {
        public string ConnectionString { get; }
    
        readonly DbMigrator _migrator;
        
        public DbMigratorHarness()
        {
            ConnectionString = CreateNewSqlConnection();
            CreateDatabase();
    
            var dbConnectionInfo = new DbConnectionInfo(ConnectionString, "System.Data.SqlClient");
            var configuration = new T
            {
                TargetDatabase = dbConnectionInfo
            };
            _migrator = new DbMigrator(configuration);
        }
    
        static string CreateNewSqlConnection()
        {
            // Note it may be important to change the connection string here depending upon how your database connection works.
            var baseConnectionString = "Data Source=.;Initial Catalog=TestDbContext;Integrated Security=true;";
            var builder = new SqlConnectionStringBuilder(baseConnectionString);
            builder.InitialCatalog += Guid.NewGuid().ToString();
            return builder.ConnectionString;
        }
    
        string GetDatabaseName()
        {
            var builder = new SqlConnectionStringBuilder(ConnectionString);
            return builder.InitialCatalog;
        }
    
        string GetMasterConnectionString()
        {
            var builder = new SqlConnectionStringBuilder(ConnectionString);
            builder.InitialCatalog = "master";
            return builder.ConnectionString;
        }
    
        void CreateDatabase()
        {
            var masterConnectionString = GetMasterConnectionString();
            using (var connection = new SqlConnection(masterConnectionString))
            {
                connection.Open();
    
                using (var command = connection.CreateCommand())
                {
                    var databaseName = GetDatabaseName();
                    command.CommandText = $@"
                        CREATE DATABASE [{databaseName}]
                    ";
                    command.ExecuteNonQuery();
                }
            }
        }
    
        void DropDatabaseIfExists()
        {
            var masterConnectionString = GetMasterConnectionString();
            using (var connection = new SqlConnection(masterConnectionString))
            {
                connection.Open();
    
                using (var command = connection.CreateCommand())
                {
                    var databaseName = GetDatabaseName();
                    command.CommandText = $@"
                        IF EXISTS(SELECT 1 FROM sys.databases WHERE name = '{databaseName}')
                        BEGIN
                            ALTER DATABASE [{databaseName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
                            DROP DATABASE [{databaseName}];
                        END
                    ";
                    command.ExecuteNonQuery();
                }
            }
        }
    
        public void LatestMigration()
        {
            _migrator.Update();
        }
    
        #region IDisposable Support
        private bool disposedValue = false; // To detect redundant calls
    
        protected virtual void Dispose(bool disposing)
        {
            if (!disposedValue)
            {
                DropDatabaseIfExists();
    
                disposedValue = true;
            }
        }
        
        ~DbMigratorHarness()
        {
            // Do not change this code. Put cleanup code in Dispose(bool disposing) above.
            Dispose(false);
        }
    
        // This code added to correctly implement the disposable pattern.
        public void Dispose()
        {
            // Do not change this code. Put cleanup code in Dispose(bool disposing) above.
            Dispose(true);
            GC.SuppressFinalize(this);
        }
        #endregion
    }
    

    【讨论】:

      【解决方案3】:

      使用 xUnit,您可以使用 Collection Fixtures 来完成类似的事情。

      何时使用:当您想创建一个测试上下文并在多个测试类中的测试之间共享它,并在测试类中的所有测试完成后对其进行清理。

      有时您会希望在多个测试类之间共享一个夹具对象。用于类夹具的数据库示例是一个很好的示例:您可能希望使用一组测试数据初始化数据库,然后将该测试数据保留在适当的位置以供多个测试类使用。您可以使用 xUnit.net 的集合夹具功能在多个测试类中的测试之间共享单个对象实例。

      要使用collection fixtures,你需要采取以下步骤:

      • 创建fixture类,并将启动代码放入fixture类构造函数中。
      • 如果fixture类需要进行清理,在fixture类上实现IDisposable,将清理代码放在Dispose()中 方法。
      • 创建集合定义类,用 [CollectionDefinition] 属性装饰它,给它一个唯一的名称,它将 识别测试集合。
      • 将 ICollectionFixture 添加到集合定义类中。
      • 使用您提供给测试的唯一名称,将 [Collection] 属性添加到将成为集合一部分的所有测试类 集合定义类的 [CollectionDefinition] 属性。
      • 如果测试类需要访问夹具实例,请将其添加为构造函数参数,它将自动提供。

      您可以创建一些类 EfFixture,它实现了 IDisposable 并组成了您的 Entity Framework DbContext 的实例。在此类的构造函数中,您将运行脚本或迁移以使用类似 (localdb)\mssqllocaldb 的东西作为服务器来创建数据库模式。同样在构造函数中,您可以为数据库播种。

      然后,在Dispose 方法中,您将从服务器中删除您的数据库。

      public class EfFixture
      {
         public EfFixture()
         {
            // Initialize the YourDbContext property, run migrations or scripts to create
            // the schema, seed the database.
         }
      
         public Dispose()
         {
            // Tear down your test database.
         }
      
         public DbContext YourDbContext { get; }
      }
      

      您需要一个特殊的空类,它具有CollectionDefinition 属性并实现ICollectionFixture&lt;EfFixture&gt;

      [CollectionDefinition("EfCollection")]
      public class EfCollection : ICollectionFixture<EfFixture>
      {
      }
      

      然后,您使用Collection 属性将您的测试类标记为集合的一部分。您可以将EfFixture 的实例传递给您的测试类的构造函数。

      [Collection("EfCollection")]
      public class TestClass1
      {
         public TestClass1(EfFixture fixture)
         {
            Fixture = fixture;
         }
      
         EfFixture Fixture { get; }
      }
      

      【讨论】:

        猜你喜欢
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 2021-03-24
        • 2021-07-16
        • 2018-08-16
        • 1970-01-01
        • 1970-01-01
        相关资源
        最近更新 更多