Armadillo: 使用属性和反射帮助进行内联 SQL 的单元测试






4.65/5 (21投票s)
本文提供了一种在发布新版本应用程序之前自动测试数据访问层 (DAL) 中嵌入的 SQL 的方法。
目录
1. 引言
如果您开发或维护过业务应用程序,您可能会多次遇到以下典型场景:
- 一个结构良好的应用程序,至少包含两个或三个层:UI、业务逻辑层和数据访问层 (DAL)。
- 一个关系型数据库管理系统 (RDBMS) 在后台进行所有数据持久化和数据完整性的繁重工作。
当许多人参与一个系统,具有特定的职责(分析师、开发人员、DBA 管理员等),并且有时需求相互矛盾时,您会发现 **数据库不是一成不变的**。
通常,数据库在开发过程中会比我们最初预期的要不断演变(新表、关系、字段名称和数据类型更改)。是的!**数据库是活的!**
一方面,这是好事:软件是为了改变而构建的。但另一方面,当我们试图在一个快速变化的环境和功能中保持应用程序的健壮性时,问题就出现了。
这种常见场景容易出错,并且源于应用程序和底层数据库之间的不同步。本文提供的工具和方法展示了一种在每次需要时自动检查数据访问层中 SQL 代码的语法正确性的方法。
这样,您就可以在发布新版本的软件之前,自动检测 SQL 代码中的数据库-应用程序不一致之处。在损害软件健壮性之前,有机会第二次检测 DAL 错误,这是很有用的。
现在,您大概知道“犰狳”这个名字的由来:将这种技术视为您日常编程工具箱中的防御性编程武器。
2. 背景
在深入探讨主题之前,我假设您具备以下基本知识:
提供的示例假定使用 MS SQL Server 数据库。然而,该技术不依赖于 RDBMS。通过少量修改,它可以应用于 Oracle 或 MySQL(事实上,犰狳框架已准备好实现这种可扩展性:只需实现一个接口即可支持新的数据库技术)。
示例假定了以下数据库架构(提供了 MS SQL Server 的脚本)。
您知道,经典的、极简的、用于收集客户、发票和发票明细的教学数据库。
3. 目标:典型的 DAL 代码
在开始测试之前,首先让我们看一下典型的 DAL,例如,DAL 程序集中的 Customer 代码。此代码旨在封装数据访问层,包含应用程序的所有 SQL,并管理连接和数据库 API。
using System;
using System.Data;
using OKOKO.Armadillo.Attributes;
using System.Data.SqlClient;
namespace OKOKO.MyBiz.Dal
{
/// <summary>
/// Customer DAL class. Implements a classical CRUD example
/// </summary>
public class Customer
{
public static Customer Create(string id, string name,
string surname, string phone)
{
//INSERT
SqlCommand cmd = new SqlCommand(
"INSERT INTO [Customer] ([id], [name], [surname], [phone])" +
"VALUES(@id, @name,@surname,@phone)" , Dal.Cnx);
cmd.Parameters.Add("@id", id);
cmd.Parameters.Add("@name", name);
cmd.Parameters.Add("@surname", surname);
cmd.Parameters.Add("@phone", phone);
cmd.ExecuteNonQuery();
//TODO: Build result...
return new Customer();
}
前面的代码展示了 Customer 类的典型 DAL 代码。第一个方法 Create() 使用 INSERT SQL 命令实现数据库中新客户的创建。
我故意省略了部分代码(TODO):用于构建业务逻辑层响应的部分,以便我们专注于 SQL,这是我们的主要目标。
public static Customer Read(string id)
{
//SELECT ID
SqlCommand cmd = new SqlCommand(
"Select * from customer where id=@id", //<-Sql code to be tested.
Dal.Cnx);
cmd.Parameters.Add("@id", id);
SqlDataReader dr = cmd.ExecuteReader();
//... read data and returns a Customers
dr.Close();
//TODO: Build result...
return new Customer();
}
第二个示例(Read() 方法)实现了一个方法,用于使用 SQL SELECT 子句从数据库检索客户。
Customer 类中的以下方法:Update()、Delete()、GetAll() 和 GetBySurname()(此处文章未显示)执行其预期功能:它们分别更新、删除、检索所有客户以及按姓氏检索数据库中的客户。
最后,在某些情况下,DAL 代码可能包含类似以下的代码:
public static string GetSQLCustomersOrdered()
{
return "select name, surname from" +
" customer order by surname, name";
}
public static string GetSQLCustomersWithPhone()
{
return "select name, surname from Xcustomer" +
" where phone is not null" +
" order by surname, name";
}
public static string GetSqlDeleteAll()
{
return "delete customer";
}
这些方法不执行任何 SQL 代码。相反,它们以 string 形式返回 SQL 语句以供进一步执行。好的,不用担心,我们也会尝试用犰狳来测试这些 SQL 代码。
如果您想深入了解,可以查看 Invoice 和 InvoiceLine 类的 DAL 代码的更多示例(请参见附件代码)。
现在,我们将描述运行演示的步骤,之后将提供实现细节。
4. 使用代码和演示
解压附加的 Zip 文件。您将找到以下材料:
- 一个 DAL 项目示例。
- 一个包含为此技术设计的属性的项目。
- 犰狳库(测试引擎和属性)。
- 一个 SQL 数据库脚本,用于为 MS SQL Server 创建一个小型数据库示例。
请遵循以下步骤
- 解压附加的文件。
- 执行 SQL 脚本以创建数据库(您需要安装 MS SQL Server)。
- 创建用户,授予并检查数据库访问权限。
- 更改数据库连接字符串的配置(文件:*Dal.cs*),以使用已创建的数据库和用户。
- 打开并编译解决方案。
- 打开 NUnit-GUI。
- 选择 *DalMyByz.Test.dll* 程序集进行单元测试。
- 运行测试。
NUnit 将执行引擎,该引擎测试所有用属性标记的 DAL 代码。NUnit 以绿色显示成功的测试,以红色显示失败的测试,并附带调试信息(有关详细信息,请参见 *Console.Out* 选项卡)。
如果一切顺利,所有测试都将通过,但有两个测试除外。测试日志如下:
Testing Class: [OKOKO.MyBiz.Dal.Customer]
Testing SqlExecute Method: +OK Create()
Testing SqlQuery Method: +OK Read()
Testing SqlExecute Method: +OK Update()
Testing SqlExecute Method: +OK Delete()
Testing SqlQuery Method: +OK GetAll()
Testing SqlQuery Method: -FAIL GetBySurname()
>> Invalid column name 'surnameBUG'.
Testing SqlTextQuery Method: +OK GetSQLCustomersOrdered()
Testing SqlTextQuery Method: -FAIL GetSQLCustomersWithPhone()
>> Invalid object name 'Xcustomer'.
Testing SqlTextExecute Method: +OK GetSqlDeleteAll()
Class [OKOKO.MyBiz.Dal.Customer] tested. Results: OK: 7/9 Failed:2
Testing Class: [OKOKO.MyBiz.Dal.Invoice]
Testing SqlExecute Method: +OK Create()
Testing SqlQuery Method: +OK Read()
Testing SqlExecute Method: +OK Update()
Testing SqlExecute Method: +OK Delete()
Testing SqlQuery Method: +OK GetAll()
Testing SqlQuery Method: +OK GetInvoicesByCustomer()
Testing SqlTextQuery Method: +OK GetSQLInvoicesOrdered()
Class [OKOKO.MyBiz.Dal.Invoice] tested. Results: OK: 7/7 Failed:0
Testing Class: [OKOKO.MyBiz.Dal.InvoiceLine]
Testing SqlExecute Method: +OK Create()
Testing SqlQuery Method: +OK Read()
Testing SqlExecute Method: +OK Update()
Testing SqlExecute Method: +OK Delete()
Testing SqlQuery Method: +OK GetAll()
Testing SqlQuery Method: +OK GetByInvoice()
Class [OKOKO.MyBiz.Dal.InvoiceLine] tested. Results: OK: 6/6 Failed:0
-----------------------------------------------------------------
Final results: 3 classes & 22 methods tested.
Results: OK: 20/22 Failed: 2
仔细检查日志显示:
- Customer 类中的 GetBySurname() 方法使用了错误的列名(surnameBUG)。删除“bug”,将其替换为 surname。
- Customer 类中的 GetSQLCustomersWithPhone() 方法使用了错误的表名(XCustomer)。通过将其替换为 Customer 来修复。
如果您重新构建并再次执行测试,一切都应该正常。输出将与以下内容类似:
Testing Class: [OKOKO.MyBiz.Dal.Customer]
...
Class [OKOKO.MyBiz.Dal.InvoiceLine] tested. Results: OK: 6/6 Failed:0
-----------------------------------------------------------------
Final results: 3 classes & 22 methods tested.
Results: OK: 22/22 Failed: 0
5. 幕后策略:属性
主要策略是将我们的 DAL 代码标记为犰狳框架提供的特殊属性。稍后,我们的测试引擎将通过反射找到这些 DAL 方法并在单元测试阶段相应地测试它们。
让我们回顾一下犰狳引入的属性:
DALClass
DALClass 是一个用于标记实现我们 DAL 代码的类的属性。Customer 类可以用此属性标记。这会通知犰狳在测试时处理此类。
使用示例:
using System;
using System.Data;
using OKOKO.Armadillo.Attributes;
using System.Data.SqlClient;
namespace OKOKO.MyBiz.Dal
{
/// <summary>
/// Customer DAL class. Implements a classical CRUD example
/// </summary>
[DalClass()]
public class Customer
{
...
SqlExecute
SQLExecute 是一个用于标记执行非查询 SQL 代码方法的属性。每当您遇到一个 **更改数据库状态** 的 SQL 片段时,请使用此属性,例如 INSERT、UPDATE、DELETE 等(非 SELECT)。
使用示例:
[SqlExecute()]
public static Customer Create(string id, string name,
string surname, string phone)
{
//INSERT
...
SqlQuery
SQLQuery 是标记执行查询 SQL 方法的理想属性。对于 **不更改** 数据库状态的查询方法,请使用此属性,即仅用于 SELECT。
使用示例:
[SqlQuery()]
public static Customer Read(string id)
{
//SELECT .. WHERE ID ...
....
SqlTextExecute
SQLTextExecute 用于标记返回 SQL 语句文本的方法,这些语句在执行时可能还会更改数据库。
使用示例:
[SqlTextExecute()]
public static string GetSqlDeleteAll()
{
return "delete customer";
}
SqlTextQuery
与前一个非常相似,SQLTextQuery 用于标记以文本形式返回 SQL 语句的方法,这些语句是 *纯粹* 的查询:它们在执行时不会更改数据库(*没有副作用*)。
使用示例:
[SqlTextQuery()]
public static string GetSQLCustomersOrdered()
{
return "select name, surname from customer order by surname, name";
}
通过使用犰狳框架提供的上述属性,我们已经标记了我们 DAL 代码的所有类和方法。
6. 一个简单的测试项目 (DalMyBiz.Test)
测试程序集非常直接且易于构建。它由一个类组成,包含三个方法:Init()、Down() 和 TestDB(),这些方法用 NUnit 属性标记,用于分别初始化、清理和执行测试。
犰狳测试引擎可以通过提供以下内容来执行:
- 一个数据库实现(在此例中为 MS SQL Server);
- 一个数据库连接;以及
- 要测试的程序集(例如 DalMyBiz。注意:这是以间接方式完成的:选择声明 Customer 类的程序集)。
using System;
using OKOKO.Armadillo.Framework;
using OKOKO.MyBiz;
using NUnit.Framework;
namespace OKOKO.MyBiz.Dal.Test
{
/// <summary>
/// Launch the tests
/// </summary>
[TestFixture()]
public class DalTest
{
[SetUp()]
public void Init()
{
Dal.Cnx.Open();
}
[TearDown()]
public void Down()
{
Dal.Cnx.Close();
}
[Test()]
public void TestDB()
{
IDbInfo dbInfo = new SqlServerDbInfo(Dal.Cnx);
//Selection of Sql Server as target DB
TestEngine te = new TestEngine(
System.Reflection.Assembly.GetAssembly(typeof(Customer)),
dbInfo);
}
}
}
7. 工作原理:测试引擎详解
测试引擎的核心位于 TestEngine 类的 TestSql() 方法中。此方法接收一个对要测试的 DAL 程序集的引用。使用反射,执行以下步骤:
- 查找所有用 DALClass 属性标记的公共类。
- 对于找到的每个类,搜索用我们的特殊属性之一(SqlQuery、SqlExecute、SqlTextQuery 或 SqlTextExecute)标记的公共静态方法。
- 根据标签,每个方法都有一个特殊的例程来测试它:CheckSqlQuery、CheckSqlExecute、CheckSqlTextQuery 和 CheckSqlTextExecute,分别接收对要用反射测试的方法的引用(System.MethodInfo)。
检查 SQL 查询方法
SQL 查询方法将针对数据库执行。我们假设查询没有副作用。也就是说,请确保在 SELECT 之后没有触发器更改您的数据库。因此,实现通过反射直接调用 DAL 方法。如果失败,我们将得到一个异常,否则一切正常。
检查 SQL 执行方法
在这种情况下,我们不能允许实际执行 SQL,因为它会更改数据库。这里的测试将是:
- 将数据库设置为仅解析模式(解析 SQL 但不执行它)。
- 调用 DAL 方法。
- 恢复数据库的正常模式。
与前一种情况类似,如果一切顺利,将不会发生任何事情,但如果 SQL 语句不正确,将会提供一个异常。
检查 SQL 文本查询和执行方法
正如我们之前解释的,标记有 SQLTextQuery 或 SQLTextExecute 属性的 DAL 方法应该返回一个包含 SQL 语句的字符串,而不是执行任何操作。因此,要执行的测试如下:
- 调用 DAL 方法并将 SQL 语句检索到一个 string 类型的变量中。
- 如果是 SQLTextExecute,则将数据库设置为安全模式:仅解析,以防止执行。
- 创建一个数据库命令并将语句发送到数据库。
- 恢复数据库模式(如果需要)。
同样,异常是指示 SQL 语句损坏的提示。
但参数怎么办?
是的,您可能会注意到:DAL 方法可以有参数,如果我们想通过反射调用它们,我们应该提供有效的值。
为了克服这个问题,犰狳为每个参数根据其类型创建默认值。请参阅 CreateDefaultParams(MethodInfo mi) 方法。因此,在大多数情况下应该不是问题。
无论如何,如果您希望控制测试期间使用的参数,则有一个替代方法:犰狳提供了另一个属性来指定测试的特定值:ParamValue。
让我们看一个小的例子:
[SqlExecute()]
public static bool Update(string id, string number,
string idCustomer, DateTime date)
{
...
}
在第一个示例中,测试引擎在调用此方法时不知道要传递给 idCustomer 的值。因此,它将尝试使用参数类型信息来生成一个。在这种情况下,CreateDefaultParams(MethodInfo mi) 将为字符串参数返回 "Hi!",为 DateTime 类型参数返回 "2000.01.02 03:04:05.006"。足够好了,不是吗?:-)
但是,如果您需要更改此行为并强制为参数指定值,请使用 ParamValue 属性。请看以下示例:
[SqlExecute()]
[ParamValue("idCustomer", "customer123")]
[ParamValue("number", 13)]
public static bool Update(string id, string number,
string idCustomer, DateTime date)
{
...
}
现在,犰狳有信息为参数 idCustomer = "customer123" 和参数 date = 13 提供特定值。其余参数将像以前一样推断(即创建)。
IDbInfo 接口
通过 IDbInfo 接口封装了对数据库的访问以进行测试。测试 SQL 所需的功能包括:
- 与数据库的连接;
- 一个 CommandFactory(返回 System.Data.IDbCommand);
- 一个将数据库设置为仅解析和非执行模式的方法;
- 一个用于恢复 SQL 语句执行的方法;以及
- 一个用于捕获以检查 SQL 语法错误异常。
犰狳框架还提供了一个 SQL Server 的默认实现(SqlServerDbInfo)。此实现利用了 MS SQL 的 SET NOEXEC ON/OFF 命令来启用或禁用 SQL 代码,而无需禁用 SQL 的解析和预编译。
8. 用法
如果您决定使用此技术,请在以下情况下运行单元测试:
- 更改 DAL 代码时;
- 数据库更改时;
- 或者在发布软件版本之前。
此时任何检测到的失败都允许您在分发新版本代码之前修复它。从而防止运行时失败。
何时使用
- 当您想确保 SQL 代码相对于目标数据库的语法正确性时。
- 当您的 SQL 代码被很好地封装在方法中时,最好是封装在特定的 DAL 类中。
- 这些方法仅处理 SQL 代码(即它们没有任何其他副作用)。
- 您的 DAL 方法标记为静态,因此 DAL 类不需要/保留任何状态。
- 由于测试的主要要求是将属性注入您的 DAL 代码,因此您可以将其与任何支持属性的 .NET 兼容语言一起使用。
何时不使用
- 当您没有将至少您的 SQL 代码封装在方法中时。
- 当您使用存储过程时。
- 当您的 SQL 代码有其他(非 DAL)的附带副作用(这是一个很大的缺点,我的朋友)或者您的方法是非静态时。
9. 待办事项列表
- 犰狳可以扩展以测量 SQL 代码执行过程中花费的时间。这有助于检测潜在的 SQL 或数据库优化瓶颈。
- 它还可以轻松扩展以支持 Oracle 或 MySQL 等其他 RDBMS。因此,如果您需要根据自己的需求进行调整,请随时进行。尝试从 IDbInfo 派生一个新类以满足您自己的需求。
- 新的 Visual Studio 2005 包含一个用于单元测试的命名空间(Microsoft.VisualStudio.TestTools.UnitTesting)。而且,亲爱的,这个框架中的属性似乎与 NUnit 中的属性非常非常相似。因此,将犰狳从 NUnit 移植到 Microsoft 的单元测试解决方案应该很容易实现。
10. 参考
- NUnit 框架:NUnit。
- 关于 NUnit 的入门教程:Marc Clifton 的高级单元测试,第一部分 - 概述。
11. 兴趣点
处理 SQL 错误可能是一场噩梦,特别是当代码文档不足且数据库经常更改时。
提供的方法和工具允许您在发布新版本之前测试 DAL SQL 代码。目标是使您的代码免受由数据库更改引起的任何 SQL 语法错误的影响。
如果单元测试以这种方式执行,SQL 代码将针对数据库进行测试(可以将其视为一种确保 SQL 预编译的形式)。因此,这可以避免在运行时给客户带来糟糕的意外。
我开发并应用这项技术是因为我确实需要确保一个拥有超过四千条 SQL 语句的运行系统的正确性。如果您无法自动化这一点,请想象一下在每次发布时手动测试它们。因此,如果这对其他人有所帮助,我将感到高兴。
请发表您的评论并分享您的意见。特别欢迎改进和使用经验。
12. 历史记录
- 2005 年 11 月 27 日 – 犰狳 ver. 1.00 发布。