C# 中的单元测试数据库命令

Unit Test Database Commands in C#

我目前正在尝试为将数据插入数据库的方法创建单元测试。我知道我需要模拟或创建一个假 class 以便我的测试数据不会添加到我的数据库中,但我不确定如何去做。有人有想法吗?

这是我的控制台“用户界面”涉及的代码。这是在我的“业务”C# 项目中:

/// <summary>
/// Gets the connection string.
/// </summary>
private static readonly string _connectionString = ConfigurationManager.ConnectionStrings["Database"].ConnectionString;

/// <summary>
/// Inserts a new admin into the Admins database table.
/// </summary>
/// <param name="admin">Admin record.</param>
public static void InsertNewAdmin(Admin admin)
{
    var adminDatabaseWriter = new AdminDatabaseWriter(_connectionString);
    adminDatabaseWriter.Insert(admin);
}

它从那里进入我的 AdminDatabaseWriter class 并在方法“插入”中执行以下操作:

/// <summary>
/// Inserts a new admin into the Admins database table.
/// </summary>
/// <param name="admin">Admin record.</param>
public void Insert(Admin admin)
{
    using SqlConnection sqlConnection = new(_connectionString);
    sqlConnection.Open();

    using SqlCommand sqlCommand = DatabaseHelper.CreateNewSqlCommandWithStoredProcedure("InsertNewAdmin", sqlConnection);
    sqlCommand.Parameters.AddWithValue("@PIN", admin.PIN);
    sqlCommand.Parameters.AddWithValue("@AdminType", admin.AdminTypeCode);
    sqlCommand.Parameters.AddWithValue("@FirstName", admin.FirstName);
    sqlCommand.Parameters.AddWithValue("@LastName", admin.LastName);
    sqlCommand.Parameters.AddWithValue("@EmailAddress", admin.EmailAddress);
    sqlCommand.Parameters.AddWithValue("@Password", admin.Password);
    sqlCommand.Parameters.AddWithValue("@AssessmentScore", admin.AssessmentScore);

    var userAddedSuccessfully = sqlCommand.ExecuteNonQuery();
    sqlConnection.Close();

    if (userAddedSuccessfully < 0)
    {
        throw new AdminNotAddedToDatabaseException("User was unsuccessful at being uploaded to the database for an unknown reason.");
    }
}

同样,我正在尝试对附加的最后一段代码进行单元测试。什么是测试我的代码实际上将对象添加到数据库而不实际将其添加到数据库的最佳方法。

在为方法 Insert 编写单元测试之前,您应该问问自己您究竟希望对什么进行“单元测试”。 Insert return 什么都没有(无效),所以没有 return 值来测试。但是,它会在满足某些条件时抛出异常,并且它还有一个副作用(调用 ExecuteNonQuery 将一些数据放入数据库)。

无论你决定测试什么场景(无论是抛出异常还是发生副作用),你都应该告诉你的测试框架,当它到达发生副作用的那一行时,它应该用另一个操作替换它实际上什么都不做(不仅这样实际的数据库不会变得“脏”,而且因为外部依赖项可能会消耗时间甚至更糟,使您的测试失败,因为它们本身就失败了,基本上我们不关心这样的依赖项在我们测试的对象的上下文中)。

所有标准测试 frameworks/libraries 都提供了对异常和副作用进行断言的工具,但请注意,不仅在针对 return 值或异常,但也当您希望验证副作用本身发生时。因为无论哪种方式,我们都希望防止外部依赖项干扰我们的测试。

无论您使用什么测试框架,通常首选在产品代码中使用接口,以促进应用程序的灵活性和可测试性,并且它还可以让您模拟“副作用”方法,例如 ExecuteNonQuery(即 ISqlCommand 而不是 SqlCommand)。这是因为当您指示模拟接口方法时,框架会创建一个新的虚拟 class 实现相同的接口。

总而言之,可以通过以下方式测试对象是否“添加”到数据库中:

  1. 更改代码以使用接口(ISqlConnection、ISqlCommand)。
  2. 模拟所需接口的方法(OpenExecuteNonQueryClose)。
  3. 正在执行 Insert.
  4. 验证 ExecuteNonQuery 已被调用。

(是否正确添加对象另当别论,可能需要集成测试。)

问题中出现的这段代码具有三个职责:

  1. 创建 SqlCommand 并用参数填充它;
  2. 执行SqlCommand最后会有一些副作用;
  3. 检查执行结果,如果值不是预期的则抛出。

显然,这些职责中的每一个都需要进行测试,问题是对每个职责都使用相同的测试方法意义不大。虽然您可以对第 1 点和第 3 点进行单元测试(更多内容见下文),但尝试对第 2 点进行单元测试并无益处。 SqlCommand 执行涉及与外部系统(即数据库)的交互,即您需要实施某种数据库组件隔离。而且您没有那么多选择,而每一个都远非理想。

选项是(至少我知道的选项):

  1. 针对抽象编写代码(如另一个回复中建议的ISqlCommand)并模拟它们(使用诸如NSubstitute之类的东西),这样你就可以在尝试访问真实数据库时不做任何事情。您只需要在 mocks 上断言 then 以查看是否以正确的顺序使用预期的参数调用了正确的方法。这种方法在几个方面是不好的:
    • 数据库交互未经测试,它可能隐藏错误(如错误的存储过程名称或实现或无效参数);
    • 测试变得脆弱,因为通过使用模拟您可以揭示方法实现细节。例如,方法调用重新排序或添加在测试方法 api 级别不可见的其他参数这样简单的事情可能会使测试失败,而更改本身在业务逻辑方面可能是绝对正确的,因此不应该导致任何测试失败。
  2. 在内存中使用一些数据库替换,如 SqliteEF Core
    • 尽管我认为这种方法比前一种方法更好,但由于您的数据库或多或少与真实数据库相似,因此缺乏功能和行为差异仍然导致数据库交互未得到适当测试。

解决该问题的另一种方法是针对真实数据库(不是生产数据库,而是专门为测试准备的)编写集成测试而不是单元测试。你只需要清理它 before/after 测试执行。 我个人认为这种方法最适合用于测试数据库交互,因为您是在最接近生产环境的环境中测试您的应用程序行为。缺点是测试设置有点复杂,测试运行很可能需要更多时间。像 Reseed (I'm authoring it) or Respawn 这样的库可能有助于简化设置和优化执行速度。

现在回到职责 1 和职责 3 的单元测试。稍微改变一下方法设计,应该可以为它们编写单元测试。您可能会提取一些额外的方法,每个方法都将明确处理每个职责。因此,最初的 Insert 现在可以看作是三种方法的组合:

  1. CreateParameters 准备 SqlCommand 个参数——您将能够通过纯单元测试断言参数已正确填充;
  2. Execute 使用真实数据库执行命令——这可以在集成测试中进行测试;
  3. AssertUserCreated 如果用户创建失败则抛出异常——同样纯单元测试就足够了。

这将使您达到 100% 的测试覆盖率,这不是必需的。如果参数创建逻辑和存储过程结果验证逻辑像现在这样直截了当,则第 1 点和第 3 点可以不进行测试。

当然还可以进一步改进设计,使其不仅更易于测试,而且更具可读性,这只是一个想法。