如果您想针对真实数据库编写测试(这是一个好主意,imo,如果您能够最大限度地减少此类测试的数量以避免测试运行时间过长),您别无他法,只能管理这个手动数据库:每次测试后创建、初始化和恢复它。
然后你有几个选择:
- 使用唯一或几个预定义的数据库(如果有多个数据库,您可以并行化测试);
- 使用带有TestContainers之类的docker来按需创建数据库。
前一种方法更简单,后者通常提供更多控制。
现在你已经解决了一个问题,还剩下三个问题:
- 执行 DDL 以初始化数据库结构;
- 测试所需的种子数据并对其进行清理;
- 将其与您的测试框架集成。
问题 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 使用 OneTimeSetup、
OneTimeTearDown、FixtureSetUp、FixtureTearDown、SetUp、TearDown 属性;
- 将
AssemblyInitialize、AssemblyCleanup、ClassInitialize、ClassCleanup、TestInitialize、TestCleanup 用于 MSTest。
我个人使用DbUp、TestContainers 和Reseed 来准备一个用于测试的数据库。
这是在 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);
}