使用内联查询对Dapper进行单元测试

22

我知道有几个与我的问题类似的问题。

但我认为以上两个问题都没有明确的答案适合我的要求。

现在我正在开发一个新的WebAPI项目,并分离WebAPI项目和DataAccess技术。 我没有问题测试WebAPI控制器,因为我可以模拟数据访问类。

但对于DataAccess类来说就是另一回事了,因为我在其中使用Dapper和内联查询,我有点困惑如何通过使用单元测试来测试它。 我问了一些朋友,他们更喜欢进行集成测试而不是单元测试。

我想知道的是,是否可能对使用Dapper和内联查询的DataAccess类进行单元测试。

假设我有这样一个类(这是一个通用存储库类,因为很多代码都有相似的查询,根据表名和字段区别)

public abstract class Repository<T> : SyncTwoWayXI, IRepository<T> where T : IDatabaseTable
{
       public virtual IResult<T> GetItem(String accountName, long id)
       {
            if (id <= 0) return null;

            SqlBuilder builder = new SqlBuilder();
            var query = builder.AddTemplate("SELECT /**select**/ /**from**/ /**where**/");

            builder.Select(string.Join(",", typeof(T).GetProperties().Where(p => p.CustomAttributes.All(a => a.AttributeType != typeof(SqlMapperExtensions.DapperIgnore))).Select(p => p.Name)));
            builder.From(typeof(T).Name);
            builder.Where("id = @id", new { id });
            builder.Where("accountID = @accountID", new { accountID = accountName });
            builder.Where("state != 'DELETED'");

            var result = new Result<T>();
            var queryResult = sqlConn.Query<T>(query.RawSql, query.Parameters);

            if (queryResult == null || !queryResult.Any())
            {
                result.Message = "No Data Found";
                return result;
            }

            result = new Result<T>(queryResult.ElementAt(0));
            return result;
       }

       // Code for Create, Update and Delete
  }

以上代码的实现方式如下:
public class ProductIndex: IDatabaseTable
{
        [SqlMapperExtensions.DapperKey]
        public Int64 id { get; set; }

        public string accountID { get; set; }
        public string userID { get; set; }
        public string deviceID { get; set; }
        public string deviceName { get; set; }
        public Int64 transactionID { get; set; }
        public string state { get; set; }
        public DateTime lastUpdated { get; set; }
        public string code { get; set; }
        public string description { get; set; }
        public float rate { get; set; }
        public string taxable { get; set; }
        public float cost { get; set; }
        public string category { get; set; }
        public int? type { get; set; }
}

public class ProductsRepository : Repository<ProductIndex>
{
   // ..override Create, Update, Delete method
}
3个回答

31

这是我们的方法:

  1. 首先,您需要在 IDbConnection 上方进行抽象以便于进行模拟:

public interface IDatabaseConnectionFactory
{
    IDbConnection GetConnection();
}
  • 您的代码库将从该工厂获取连接,并在其上执行Dapper查询:

  • public class ProductRepository
    {
        private readonly IDatabaseConnectionFactory connectionFactory;
    
        public ProductRepository(IDatabaseConnectionFactory connectionFactory)
        {
            this.connectionFactory = connectionFactory;
        }
    
        public Task<IEnumerable<Product>> GetAll()
        {
            return this.connectionFactory.GetConnection().QueryAsync<Product>(
                "select * from Product");
        }
    }
    
  • 您的测试将创建一个带有一些示例行的内存数据库,并检查存储库如何检索它们:

  • [Test]
    public async Task QueryTest()
    {
        // Arrange
        var products = new List<Product>
        {
            new Product { ... },
            new Product { ... }
        };
        var db = new InMemoryDatabase();
        db.Insert(products);
        connectionFactoryMock.Setup(c => c.GetConnection()).Returns(db.OpenConnection());
    
        // Act
        var result = await new ProductRepository(connectionFactoryMock.Object).GetAll();
    
        // Assert
        result.ShouldBeEquivalentTo(products);
    }
    

    我猜实现内存数据库的方法有很多种;我们使用了在SQLite数据库之上的OrmLite

    public class InMemoryDatabase
    {
        private readonly OrmLiteConnectionFactory dbFactory = new OrmLiteConnectionFactory(":memory:", SqliteOrmLiteDialectProvider.Instance);
    
        public IDbConnection OpenConnection() => this.dbFactory.OpenDbConnection();
    
        public void Insert<T>(IEnumerable<T> items)
        {
            using (var db = this.OpenConnection())
            {
                db.CreateTableIfNotExists<T>();
                foreach (var item in items)
                {
                    db.Insert(item);
                }
            }
        }
    }
    

    1
    只有一点要注意,工厂不是必需的来获取IDbConnection的抽象表示(因为它已经是一个接口),而是为了能够在存储库内构建新连接。如果您不需要这样做(并且在Web API请求的上下文中可能不需要创建多个连接),则可以直接将IDbConnection传递给存储库。 - Ignacio Calvo
    1
    @IgnacioCalvo 我们确实需要同时使用多个连接,例如在执行可以并行运行的异步查询时。 - Mikhail Shilkov
    connectionFactoryMock在哪里可以找到这个实例? - Kob_24
    我们使用了 Moq 库,所以 var connectionFactoryMock = new Mock<IDatabaseConnectionFactory>(); - Mikhail Shilkov

    6

    我根据 @Mikhail 的做法进行了适应,因为在添加 OrmLite 包时遇到了问题。

    internal class InMemoryDatabase
    {
        private readonly IDbConnection _connection;
    
        public InMemoryDatabase()
        {
            _connection = new SQLiteConnection("Data Source=:memory:");
        }
    
        public IDbConnection OpenConnection()
        {
            if (_connection.State != ConnectionState.Open)
                _connection.Open();
            return _connection;
        }
    
        public void Insert<T>(string tableName, IEnumerable<T> items)
        {
            var con = OpenConnection();
    
            con.CreateTableIfNotExists<T>(tableName);
            con.InsertAll(tableName, items);
        }
    }
    

    我创建了一个 DbColumnAttribute,这样我们就可以为类的属性指定特定的列名。

    public sealed class DbColumnAttribute : Attribute
    {
        public string Name { get; set; }
    
        public DbColumnAttribute(string name)
        {
            Name = name;
        }
    }
    

    我为CreateTableIfNotExistsInsertAll方法添加了一些IDbConnection扩展。

    这非常简单,所以我还没有正确映射类型

    internal static class DbConnectionExtensions
    {
        public static void CreateTableIfNotExists<T>(this IDbConnection connection, string tableName)
        {
            var columns = GetColumnsForType<T>();
            var fields = string.Join(", ", columns.Select(x => $"[{x.Item1}] TEXT"));
            var sql = $"CREATE TABLE IF NOT EXISTS [{tableName}] ({fields})";
    
            ExecuteNonQuery(sql, connection);
        }
    
        public static void Insert<T>(this IDbConnection connection, string tableName, T item)
        {
            var properties = typeof(T)
                .GetProperties(BindingFlags.Public | BindingFlags.Instance)
                .ToDictionary(x => x.Name, y => y.GetValue(item, null));
            var fields = string.Join(", ", properties.Select(x => $"[{x.Key}]"));
            var values = string.Join(", ", properties.Select(x => EnsureSqlSafe(x.Value)));
            var sql = $"INSERT INTO [{tableName}] ({fields}) VALUES ({values})";
    
            ExecuteNonQuery(sql, connection);
        }
    
        public static void InsertAll<T>(this IDbConnection connection, string tableName, IEnumerable<T> items)
        {
            foreach (var item in items)
                Insert(connection, tableName, item);
        }
    
        private static IEnumerable<Tuple<string, Type>> GetColumnsForType<T>()
        {
            return from pinfo in typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance)
                let attribute = pinfo.GetCustomAttribute<DbColumnAttribute>()
                let columnName = attribute?.Name ?? pinfo.Name
                select new Tuple<string, Type>(columnName, pinfo.PropertyType);
        }
    
        private static void ExecuteNonQuery(string commandText, IDbConnection connection)
        {
            using (var com = connection.CreateCommand())
            {
                com.CommandText = commandText;
                com.ExecuteNonQuery();
            }
        }
    
        private static string EnsureSqlSafe(object value)
        {
            return IsNumber(value)
                ? $"{value}"
                : $"'{value}'";
        }
    
        private static bool IsNumber(object value)
        {
            var s = value as string ?? "";
    
            // Make sure strings with padded 0's are not passed to the TryParse method.
            if (s.Length > 1 && s.StartsWith("0"))
                return false;
    
            return long.TryParse(s, out long l);
        }
    }
    

    您仍然可以像@Mikhail在第三步中提到的那样使用它。

    6
    我想在这个问题上增加另一个角度和解决方案,采取不同的方法来解决它。
    可以将Dapper视为仓储类的依赖项,因为它是一个我们无法控制的外部代码库。因此,测试它并不真正属于Unit Testing的责任范畴(正如你所提到的,更符合集成测试)。
    话虽如此,我们实际上不能直接模拟Dapper,因为它只是设置在IDbConnection接口上的扩展方法。我们可以模拟所有System.Data代码,直到我们到达IDbCommand为止,这时Dapper才真正发挥作用。然而,那将是很大的工作量,在大多数情况下不值得这么做。
    相反,我们可以创建一个简单的IDapperCommandExecutor可模拟的接口:
    
    public interface IDapperCommandExecutor
    {
        IDbConnection Connection { get; }
    
        T Query<T>(string sql, object? parameters = null);
    
        // Add other Dapper Methods as required...
    }
    
    

    这个接口可以很容易地使用Dapper实现:

    
    public class DapperCommandExecutor : IDapperCommandExecutor
    {
        public DapperCommandExecutor(IDbConnection connection)
        {
            Connection = connection;
        }
    
        IDbConnection Connection { get; }
    
        T Query<T>(string sql, object? parameters = null) 
            => Connection.QueryAsync<T>(sql, parameters);
    
        // Add other Dapper Methods as required...
    }
    
    

    那么你只需要改变以下内容:

    var queryResult = sqlConn.Query<T>(query.RawSql, query.Parameters);
    

    to

    var queryResult = commandExecutor.Query<T>(query.RawSql, query.Parameters);
    

    然后在你的测试中,你可以创建一个模拟的命令执行器

    
    public class MockCommandExecutor : Mock<IDapperCommandExecutor>
    {
    
        public MockCommandExecutor()
        {
            // Add mock code here...
        }
    
    }
    
    

    总之,我们不需要测试Dapper库,它可以在单元测试中被模拟。这个模拟的Dapper命令执行器将减少对内存数据库的额外依赖,并可以降低您的测试复杂度。

    网页内容由stack overflow 提供, 点击上面的
    可以查看英文原文,
    原文链接