You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何Mock SqlConnection、SqlCommand?怎样重构DomainEventsMigrator支持单元测试

问题解答

1. SqlConnection、SqlCommand 直接Mock的可行性

System.Data.SqlClient 和 Microsoft.Data.SqlClient 下的 SqlConnection、SqlCommand、SqlBulkCopy 都是密封类,没有对外暴露可用于Mock的公共接口或抽象基类,常规Mock框架(Moq、NSubstitute等)无法直接对其打桩。
如果你硬要在不重构的前提下做单元测试,只能用 Microsoft Fakes 这类可以直接修改IL做运行时打桩的工具,但是配置复杂、执行速度慢,性价比极低,完全不推荐,最优解是对现有类做简单重构解耦。

2. 现有类重构方案(兼顾可测性与架构合理性)

核心思路是拆分职责:把「具体Sql读写逻辑」和「迁移流程编排、日志、错误处理逻辑」拆开,让迁移类只依赖抽象接口,不耦合具体的Sql实现。

第一步:定义读写抽象接口

// 源数据读取抽象
public interface IDomainEventSourceReader
{
    Task<IDataReader> GetEventBatchAsync(long fromId, long toId, CancellationToken cancellationToken = default);
}

// 目标数据写入抽象
public interface IDomainEventDestinationWriter
{
    Task WriteEventBatchAsync(IDataReader eventDataReader, CancellationToken cancellationToken = default);
}

第二步:实现Sql版的读写类(这部分单独做集成测试即可)

public class SqlDomainEventSourceReader : IDomainEventSourceReader
{
    private readonly string _connectionString;
    public SqlDomainEventSourceReader(string connectionString) => _connectionString = connectionString;

    public async Task<IDataReader> GetEventBatchAsync(long fromId, long toId, CancellationToken cancellationToken = default)
    {
        using var sourceConnection = new SqlConnection(_connectionString);
        await sourceConnection.OpenAsync(cancellationToken);
        var query = @"SELECT StreamId, MessageId, EventDate, EventDataType, EventPayloadJson, IsActive
                      FROM dbo.tblExecutionPathDomainEvents WITH (NOLOCK)
                      WHERE Id >= @FromId AND Id < @ToId
                      ORDER BY Id";
        using var command = new SqlCommand(query, sourceConnection);
        command.Parameters.Add("@FromId", SqlDbType.BigInt).Value = fromId;
        command.Parameters.Add("@ToId", SqlDbType.BigInt).Value = toId;
        // 注意这里用CommandBehavior.CloseConnection,reader关闭时自动释放连接
        return await command.ExecuteReaderAsync(CommandBehavior.CloseConnection, cancellationToken);
    }
}

public class SqlDomainEventDestinationWriter : IDomainEventDestinationWriter
{
    private readonly string _connectionString;
    public SqlDomainEventDestinationWriter(string connectionString) => _connectionString = connectionString;

    public async Task WriteEventBatchAsync(IDataReader eventDataReader, CancellationToken cancellationToken = default)
    {
        using var destinationConnection = new SqlConnection(_connectionString);
        await destinationConnection.OpenAsync(cancellationToken);
        using var bulkCopy = new SqlBulkCopy(destinationConnection)
        {
            DestinationTableName = "dbo.tblExecutionPathDomainEvents",
            BulkCopyTimeout = 3600,
            BatchSize = 1000
        };
        await bulkCopy.WriteToServerAsync(eventDataReader, cancellationToken);
    }
}

第三步:重构DomainEventsMigrator,只依赖抽象接口

public class DomainEventsMigrator : IDomainEventsMigrator
{
    private readonly IDomainEventSourceReader _sourceReader;
    private readonly IDomainEventDestinationWriter _destinationWriter;
    private readonly ILogger<DomainEventsMigrator> _logger;

    public DomainEventsMigrator(
        IDomainEventSourceReader sourceReader,
        IDomainEventDestinationWriter destinationWriter,
        ILogger<DomainEventsMigrator> logger)
    {
        _sourceReader = sourceReader;
        _destinationWriter = destinationWriter;
        _logger = logger;
    }

    public async Task MoveBatchAsync(MigrationBatch batch)
    {
        var stopWatch = Stopwatch.StartNew();
        IDataReader? reader = null;
        try
        {
            reader = await _sourceReader.GetEventBatchAsync(batch.FromId, batch.ToId);
            stopWatch.Stop();
            _logger.LogInformation("Read attempt successful in {Elapsed}, FromId = {FromId}, ToId = {ToId}", 
                stopWatch.Elapsed, batch.FromId, batch.ToId);

            stopWatch = Stopwatch.StartNew();
            await _destinationWriter.WriteEventBatchAsync(reader);
            stopWatch.Stop();
            _logger.LogInformation("Write attempt successful in {Elapsed}, FromId = {FromId}, ToId = {ToId}",
                stopWatch.Elapsed, batch.FromId, batch.ToId);
        }
        catch (Exception ex)
        {
            stopWatch.Stop();
            if (reader == null)
            {
                _logger.LogError(ex, "Read attempt failed in {Elapsed}, FromId = {FromId}, ToId = {ToId}",
                    stopWatch.Elapsed, batch.FromId, batch.ToId);
            }
            else
            {
                _logger.LogError(ex, "Write attempt failed in {Elapsed}, FromId = {FromId}, ToId = {ToId}",
                    stopWatch.Elapsed, batch.FromId, batch.ToId);
            }
        }
        finally
        {
            reader?.Dispose();
        }
    }
}

*额外优化:原来的日志字符串拼接改成了结构化日志参数,避免字符串分配同时方便日志检索;把AddWithValue改成显式指定SqlDbType,避免类型推导错误。

3. 重构后的单元测试方案

现在你只需要Mock两个抽象接口就可以完成所有迁移流程逻辑的单元测试,用任意主流Mock框架都可以快速实现,需要覆盖的核心场景:

  • 正常读写成功场景:验证读写接口被调用了一次,入参和batch的FromId、ToId一致,两条Info日志正常输出
  • 源数据读取失败场景:模拟SourceReader抛出异常,验证读取失败的Error日志正常输出,DestinationWriter的方法没有被调用
  • 源读取成功、写入失败场景:模拟DestinationWriter抛出异常,验证写入失败的Error日志正常输出

至于SqlDomainEventSourceReader和SqlDomainEventDestinationWriter这两个具体实现类,不需要做单元测试,直接用SQL Server LocalDB或者Docker临时实例做集成测试,验证Sql语句、参数、BulkCopy配置的正确性即可,性价比远高于硬Mock。


内容的提问来源于stack exchange,提问作者xkcd

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.10.03 23:48:02