65.9K
CodeProject 正在变化。 阅读更多。
Home

单元测试你的数据库类

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.20/5 (3投票s)

2022 年 5 月 16 日

CPOL

4分钟阅读

viewsIcon

27816

downloadIcon

611

如何创建可单元测试的数据访问类

引言

在本文中,我们将解释如何创建一种易于单元测试的数据库访问类,该类使用纯 ADO.NET 类实现,无需复杂的框架。测试将使用 XUnit 和 Moq 实现。示例使用 C# 和 .NET 5 实现,但也可在其他版本的 .NET 中实现,例如 .NET Core 3.1。

背景

传统上,使用 ADO.NET 的开发人员会创建 Data 类,直接在其上实现用于管理数据库访问的对象,通常我们会使用连接对象的具体实现(例如 SqlConnection)来构建数据访问类。

这种方式不允许创建依赖于接口存在的类的模拟对象。接口允许我们创建一个假的(fake)对象来实现模拟。

我发现许多开发人员认为 ADO.NET 类(如 SQLCommandSQLConnection)的具体实现缺乏接口,因此无法进行数据库类的模拟。事实上,存在一个通用的接口允许我们这样做。

https://docs.microsoft.com/en-us/dotnet/api/system.data.idbconnection?view=netcore-3.1

IDbConnection 允许我们将其注入到类中,而不是使用连接的具体实现,或者在代码中通过 new 创建它。

在我们的代码中,由于实际上在数据库访问类的所有实例中都使用相同的对象可能会产生并发问题,因此我们使用委托(delegate)向 db 类传递一个函数,而不是直接传递派生自 IDbConnection 的对象实例。这确保了我们在类实例化时使用的对象对于该类是唯一的,从而避免了并发问题。

将类实现为可单元测试

我们如何实现它呢?嗯,要在真实程序中使用它来访问数据库,我们需要遵循三个简单的步骤。

第一步

startup.cs 类中配置要注入到对象中的函数。

public void ConfigureServices(IServiceCollection services)
{
    // Rest of code .....
    string connectionStr = Configuration.GetConnectionString("Wheater");
    services.AddScoped<IMoqReadyService, MoqReadyService>(  
     x => new MoqReadyService(() => new SqlConnection(connectionStr)));
}

在此代码片段中,您会注意到我们从配置中获取连接字符串,并且工厂函数被编写为在被调用时创建一个新的 SqlConnection 对象。

第二步

创建数据访问类,并将函数作为参数注入到构造函数中。

 /// <summary>
 /// Factory for IDb Connection
 /// </summary>
 private Func<IDbConnection> Factory { get; }

 /// <summary>
 /// Class Constructor
 /// </summary>
 /// <param name="factory">The IdbConnection compatible factory function</param>
 public MoqReadyService(Func<IDbConnection> factory)
 {
     this.Factory = factory;
 }

如您所见,我们将函数注入到类中的构造函数中,并将其存储在 private 变量中。

第三步

调用工厂并创建其余需要的对象。

作为最后一步,在我们的方法内部调用工厂来创建 SqlConnection 的实例(如本例中配置的那样),并创建其余的 ADO.NET 对象。

public async Task<List<WeatherForecast>> GetForecastMoqableAsync(DateTime startDate)
{
   var t = await Task.Run(() =>
   {
       // This invoke the factory and create the SqlCommand object
       using IDbConnection connection = this.Factory.Invoke();
       
       using IDbCommand command = connection.CreateCommand();
       command.CommandType = CommandType.Text;
       command.CommandText = "SELECT * FROM WeatherInfo WHERE Date = @date";
       command.Parameters.Clear();
       command.Parameters.Add(new SqlParameter("@date", SqlDbType.DateTime) 
      { Value = startDate });
  //.... Rest of the code....  

这可能因我们在此方法中使用什么操作而异,但使用该指令创建 IDbConnection 实现是相同的。

using IDbConnection connection = this.Factory.Invoke();

总而言之,要创建可测试的类,操作如下:

实现测试代码

现在实现测试代码非常直接。我们只需要更改工厂实现为一个 Mock 对象,然后替换并配置所有基于此初始模拟的对象。

XUnit 代码中的主要步骤是创建 IdbConnection 模拟对象,如下面的代码片段所示。

public class MoqSqlTest
{
   readonly MoqReadyService service;
   readonly Mock<IDbConnection> moqConnection;
   public MoqSqlTest()
   {
       this.moqConnection = new Mock<IDbConnection>(MockBehavior.Strict);
       moqConnection.Setup(x => x.Open());
       moqConnection.Setup(x => x.Dispose());
       this.service = new MoqReadyService(() => moqConnection.Object);
   }
   // Continue the code.....

在此代码片段中,您可以观察到如何基于 IDbConnection 创建 moq 对象以及部分测试的配置。在创建了这个基础对象之后,其余测试的创建取决于您想要测试的数据访问函数的类型。让我们在下一节中看看。

Using the Code

代码提供了两个测试类的示例,用于测试从数据库读取和插入信息的类方法。

使用 Data Reader 测试读取操作。

[Trait("DataReader", "1")]
[Fact(DisplayName = "DataReader Moq Set Strict Behaviour to Command Async")]
public async Task MoqExecuteReaderFromDatabaseAsync()
{
      // Define the data reader, that return only one record.
      var moqDataReader = new Mock<IDataReader>();
      moqDataReader.SetupSequence(x => x.Read())
          .Returns(true) // First call return a record: true
          .Returns(false); // Second call finish

      // Record to be returned
      moqDataReader.SetupGet<object>(x => x["Date"]).Returns(DateTime.Now);
      moqDataReader.SetupGet<object>(x => x["Summary"]).Returns("Sunny with Moq");
      moqDataReader.SetupGet<object>(x => x["Temperature"]).Returns(32);

      // Define the command to be mock and use the data reader
      var commandMock = new Mock<IDbCommand>();

      // Because the SQL to mock has parameter we need to mock the parameter
      commandMock.Setup(m => m.Parameters.Add
                       (It.IsAny<IDbDataParameter>())).Verifiable();
      commandMock.Setup(m => m.ExecuteReader())
      .Returns(moqDataReader.Object);

      // Now the mock if IDbConnection configure the command to be used
      this.moqConnection.Setup(m => m.CreateCommand()).Returns(commandMock.Object);

      // And we are ready to do the call.
      List<WeatherForecast> result = 
           await this.service.GetForecastMoqableAsync(DateTime.Now);
      Assert.Single(result);
      commandMock.Verify(x => x.Parameters.Add(It.IsAny<IDbDataParameter>()), 
                         Times.Exactly(1));
 }

使用 Mock Behaviour Strict 测试 Insert 操作。

[Trait("ExecuteNonQuery", "1")]
[Fact(DisplayName = "Moq Set Strict Behaviour to Command Async")]
public async Task MoqExecuteNonQueryStrictBehaviourforCommandAsync()
{
     WeatherForecast whetherForecast = new()
     {
          TemperatureC = 25,
          Date = DateTime.Now,
          Summary = "Time for today"
      };

       // Configure the mock of the command to be used
       var commandMock = new Mock<IDbCommand>(MockBehavior.Strict);
       commandMock.Setup(c => c.Dispose());
       commandMock.Setup(c => c.ExecuteNonQuery()).Returns(1);
            
       // Use sequence when several parameters are needed
       commandMock.SetupSequence(m => m.Parameters.Add(It.IsAny<IDbDataParameter>()));
            
       // You need to set this if use strict behaviour. 
       // Depend of your necessity for test
       commandMock.Setup(m => m.Parameters.Clear()).Verifiable();
       commandMock.SetupProperty<CommandType>(c => c.CommandType);
        commandMock.SetupProperty<string>(c => c.CommandText);
            
       // Setup the IdbConnection Mock with the mocked command
       this.moqConnection.Setup(m => m.CreateCommand()).Returns(commandMock.Object);

       // SUT
       var result = await service.SetForecastAsync(whetherForecast);
       Assert.Equal(1, result);
       commandMock.Verify(x => x.Parameters.Add
                   (It.IsAny<IDbDataParameter>()), Times.Exactly(3));
}

请注意,在这种情况下,我们使用严格模式(strict behaviour)创建模拟对象,我们也可以使用宽松模式(Loose behaviour)创建,使用哪种模式取决于您想在类中测试什么。

宽松模式可以使测试更简洁,但您可能会丢失有关类中要测试内容的详细信息。

以下是使用与上一个代码示例相同的类进行宽松模式的示例。

[Trait("ExecuteNonQuery", "2")]
[Fact(DisplayName = "Moq Set Loose Behaviour to Command Async")]
public async Task MoqExecuteNonQuerySetLooseBehaviourToCommandAsync()
{
      WeatherForecast whetherForecast = new()
      {
           TemperatureC = 25,
           Date = DateTime.Now,
           Summary = "Time for today"
      };

      // Configure the mock of the command to be used
      var commandMock = new Mock<IDbCommand>(MockBehavior.Loose);
       commandMock.Setup(c => c.ExecuteNonQuery()).Returns(1);

      // Use sequence when several parameters are needed
      commandMock.SetupSequence(m => m.Parameters.Add(It.IsAny<IDbDataParameter>()));

      // Setup the IdbConnection Mock with the mocked command
      this.moqConnection.Setup(m => m.CreateCommand()).Returns(commandMock.Object);

      // SUT
      var result = await service.SetForecastAsync(whetherForecast);
      Assert.Equal(1, result);
      commandMock.Verify(x => x.Parameters.Add
                        (It.IsAny<IDbDataParameter>()), Times.Exactly(3));
}

关注点

我发现一些开发人员倾向于在简单的数据库操作中使用非常庞大的框架,如 Entity Framework,其理由如下:

  • ADO.NET 类无法进行单元测试
  • ADO.NET 无法进行异步操作

您可以下载的简单示例代码允许您使数据库调用异步化,并且还可以对类进行单元测试,而无需 EF 的开销。

我并非反对 EF,它在与数据库的复杂接口方面非常有用,但当我只需要与数据库进行少量交互或执行一些 insert 操作时,我更喜欢简单的 ADO.NET 操作。

我通常与微服务打交道,这也是我每天处理的数据库情况。

您也可以在本文的视频版本中看到:

历史

  • 2022年5月16日:初版
© . All rights reserved.