单元测试你的数据库类






4.20/5 (3投票s)
如何创建可单元测试的数据访问类
引言
在本文中,我们将解释如何创建一种易于单元测试的数据库访问类,该类使用纯 ADO.NET 类实现,无需复杂的框架。测试将使用 XUnit 和 Moq 实现。示例使用 C# 和 .NET 5 实现,但也可在其他版本的 .NET 中实现,例如 .NET Core 3.1。
背景
传统上,使用 ADO.NET 的开发人员会创建 Data
类,直接在其上实现用于管理数据库访问的对象,通常我们会使用连接对象的具体实现(例如 SqlConnection
)来构建数据访问类。
这种方式不允许创建依赖于接口存在的类的模拟对象。接口允许我们创建一个假的(fake)对象来实现模拟。
我发现许多开发人员认为 ADO.NET 类(如 SQLCommand
或 SQLConnection
)的具体实现缺乏接口,因此无法进行数据库类的模拟。事实上,存在一个通用的接口允许我们这样做。
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日:初版