浅谈企业软件架构(4)
第四章 单元测试
单元测试:开发者编写的一小段代码,用于检验被测代码中的一个很明确的功能是否正确。通常而言,一个单元测试是用于判断某个特定条件(或者场景)下某个特定函数的行为。
编写单元测试是一种验证行为,验证编写的功能单元是否满足设计需求;更是一种设计行为,根据测试反馈调整改进设计逻辑,特别如果使用了TDD开发模式测试先行,可以迫使我们把程序设计成易于调用和可测试的,帮助我们不知不觉中解除软件中的耦合;同样,它更是一种编写文档的行为,它是展示函数或类如何调用的最佳文档;最后,它具有回归性,自动化的单元测试避免了代码出现回归,编写完成之后,可以随时随地的快速运行测试。
单元测试是一种由程序员自行编写测试的工作。就是测试代码撰写者依据其所设想的方式执行是否产生了预期的结果。但是我们经常看到的现状就是项目中大部分的单元测试都是写在主要的程序代码已经设计、完成编码之后,再来实现的单元测试是一项麻烦和困难的工作,尤其在项目最后期限压力下,单元测试也就变成了项目中可有可无的工作和经常被忽略的过程。
某个场景:项目的后期项目组遇着一个重要的需求调整,迫于时间压力和用户抱怨,程序员加班加点解决了这个问题,可是刚松一口气的,发布使用的第二天用户及报告了更多的问题和更大的抱怨——修正一个BUG引入了更多的BUG。
如果一开始就编写了丰富的单元测试代码,至少在这种情况下我们可以回归覆盖测试全部的单元测试代码,降低加入新代码导致的新问题的可能性。测试驱动的开发(TDD)让单元测试成为开发过程必须的一个部分,程序员通过编写单元测试来驱动功能逻辑代码的编写。TDD是Kent Beck在他的<<测试驱动开发 >>(Addison-Wesley Professional,2003)使用下面2个原则来定义TDD:
除非你有一个失败的自动测试,永远不要写一单行代码.
阻止重复
第一个原则是可以看成由测试来“拉”动你编写程序代码,在没有测试失败的情况下,就不要考虑去先编写代码。这个原则的目的是阻止我们去实现那些在解决方案中不需要的功能.
第二个原则目的就是在你的程序中不应该包含重复的代码.到处COPY代码只会让我们的程序更加快速的腐化,变得杂乱无章和不可维护。更多关于单元测试的资料请参考<<测试驱动开发>>及网上其他资料。
4.1 单元测试工具——NUnit
本文主要介绍项目开发如何使用NUnit单元测试工具,我们使用的版本为:NUnit 2.5,更多NUnit使用方法和其他单元测试工具请参阅网上其他介绍文档。如下图:
绿色 描述目前所执行的测试都通过
黄色 意味某些测试忽略,但是这里没有失败
红色 表示有失败
4.2 单元测试
现在我们在前面的解决方案中加入类型为类库的单元测试项目命名为:UnitTest我们把默认的类文件Class1.cs更名为CustomerBizTest.cs。同时,复制前面DemoWinForm项目的App.Config文件到UnitTest项目中,单元测试项目也要使用NHibernate的配置信息,如下图:
项目引用UNnit安装目录下的nunit.framework.dll类库和项目引用Biz、Model项目如下图:
CustomerBizTest.cs初始代码如下:
![](https://www.cnblogs.com/Images/OutliningIndicators/ContractedBlock.gif)
using System.Collections.Generic;
using System.Linq;
using System.Text;
using NUnit.Framework;
using Biz;
using Model;
namespace UnitTest
{
[TestFixture]
public class CustomerBizTest
{
private CustomerBiz _customerBiz;
private Int32 _id = 100;
[SetUp]
protected void SetUp()
{
_customerBiz = new CustomerBiz(); //创建CustomerBiz对象
}
现在我们根据前面的CustomerBiz类来加入单元测试代码,现在的CustomerBiz对象只有4个业务接口分别为Add,Get,Edit,Delete。
4.2.1. 编写Add接口测试代码
![](https://www.cnblogs.com/Images/OutliningIndicators/ContractedBlock.gif)
{
Customer customer = new Customer() ;
customer.CustomerId = _id;
customer.Firstname = "Howard";
customer.Lastname = "Wu";
customer.Gender = "男";
customer.Address = "华龙人家";
bool result = _customerBiz.Add(customer); //提交对象到数据层
Assert.IsTrue(result);
Customer customer2 = _customerBiz.Get(_id); //从数据层重新获得对象
Assert.AreEqual(customer.CustomerId, customer2.CustomerId);
Assert.AreEqual(customer.Firstname, customer2.Firstname);
}
现在我们配置单元测试项目来运行NUnit GUI界面,把UnitTest项目设置成启动项目,配置该项目Debug属性如下图:
Start external program设置层Nunit安装目录下的NUnit.exe,现在我们开始运行调试系统会自动启动NUnit.exe然后File——》Open Project 打开编译目录下的UnitTest.Dll 如下图:
运行测试GUI效果如下,测试表明我们代码通过了单元测试,如下图:
再次运行测试,我们会看到失败的测试结果图如下:
这是怎么回事呢?其实这是由于我们两次向数据层新增提交了相同标识的对象(CustomerId都是100)导致数据层返回的错误。这也是企业软件开发的一个特殊性,每一次业务操作完毕都要把业务层数据持久化到关系数据库中,并在后面的业务逻辑中需要是查询出来。
如果我们只针对某条测试数据来写测试用例,可能在后面的某个时候这条记录被人为的从数据库中删除了,或者我们的重复新增了相同标识的对象就会导致测试失败。修改我们测单元测试代码每次测试完成是删除新增的Customer对象,这样就可以确保下一次运行测试时不会失败了。
4.2.2. 编写删除Delete接口测试代码
![](https://www.cnblogs.com/Images/OutliningIndicators/ContractedBlock.gif)
{
Customer customer = _customerBiz.Get(_id);
bool result = _customerBiz.Delete(customer);
Assert.IsTrue(result);
Customer customer2 = _customerBiz.Get(_id);
Assert.IsNull(customer2);
}
编译成功我们运行测试,这次我们发现无论运行多少测我们的测试都会通过了。如下图:
4.2.3. 编写业务逻辑测试代码
目前为止我们的例子中Biz层中Add,Get,Edit,Delete四个接口操作可以看着数据操作外,其实还没有真正意义上的业务逻辑接口,现在我们来编写包含一个简单业务逻辑接口的Biz类。我给Model对象Customer类增加一个状态标识Active属性来标明该对象是启用状态。
修改表增加Active字段类型为整型如下图:
修改Customer.cs代码如下:
![](https://www.cnblogs.com/Images/OutliningIndicators/ContractedBlock.gif)
using System.Collections.Generic;
using System.Text;
namespace Demo
{
public class Customer
{
public virtual int CustomerId { get; set; }
public virtual string Firstname { get; set; }
public virtual string Lastname { get; set; }
public virtual string Gender { get; set; }
public virtual string Address { get; set; }
public virtual string Remark { get; set; }
public virtual int Active { get; set; }
}
}
修改 Customer.hbm.xml 代码如下:
![](https://www.cnblogs.com/Images/OutliningIndicators/ContractedBlock.gif)
<hibernate-mapping xmlns="urn:nhibernate-mapping-2.2">
<class name="Demo.Customer, Demo" table="Customer">
<id name="CustomerId" type="Int32" unsaved-value="null">
<column name="CustomerId" length="4" sql-type="int" not-null="true" />
<generator class="assigned" />
</id>
<property name="Firstname" type="String">
<column name="Firstname" length="50" sql-type="varchar" not-null="false"/>
</property>
<property name="Lastname" type="String">
<column name="Lastname" length="50" sql-type="varchar" not-null="false"/>
</property>
<property name="Gender" type="String">
<column name="Gender" length="2" sql-type="char" not-null="false"/>
</property>
<property name="Address" type="String">
<column name="Address" length="50" sql-type="varchar" not-null="false"/>
</property>
<property name="Remark" type="String">
<column name="Remark" length="50" sql-type="varchar" not-null="false"/>
</property>
<property name="Active" type=" Int32">
<column name=" Active " length="4" sql-type="int" not-null="false"/>
</property>
</class>
</hibernate-mapping>
4.2.3.1. 重构CustomerBizTest类
根据企业开发中数据的特性,我们把添加一个新的Customer对象移至[SetUp]函数中,把删除本次测试新增的Customer对象代码移至[TearDown]函数中如下表,接下来我们就可以专注于写我们的业务逻辑代码了。
![](https://www.cnblogs.com/Images/OutliningIndicators/ContractedBlock.gif)
using System.Collections.Generic;
using System.Linq;
using System.Text;
using NUnit.Framework;
using Biz;
using Model;
namespace UnitTest
{
[TestFixture]
public class CustomerBizTest
{
private CustomerBiz _customerBiz;
private Int32 _id = 100;
[SetUp]
protected void SetUp()
{
_customerBiz = new CustomerBiz();
// 添加本次测试运行使用的对象
Customer customer = new Customer();
customer.CustomerId = _id;
customer.Firstname = "Howard";
customer.Lastname = "Wu";
customer.Gender = "男";
customer.Address = "中国";
_customerBiz.Add(customer);
}
[TearDown]
protected void TearDown()
{
Customer customer = _customerBiz.Get(_id);
_customerBiz.Delete(customer); // 删除本次测试运行使用的对象
}
4.2.3.2 编写启用功能的单元测试和业务逻辑代码
根据TDD开发原则,我们先编写启用Customer对象的单元测试逻辑,模拟场景是启用一个新增的Customer对象,我们在单元测试的Setup函数中增加了一个新的Customer对象,这样单元测试就直接针对该Customer对象进行逻辑断言验证了,代码如下:
![](https://www.cnblogs.com/Images/OutliningIndicators/ContractedBlock.gif)
{
Customer customer = _customerBiz.Get(_id);
Assert.AreEqual(customer.Active, 0); //状态是否为0
_customerBiz.Active(customer); //启用用户
Customer customer2 = _customerBiz.Get(_id);
Assert.AreEqual(customer.Active, 1); //状态是否为1
Assert.AreEqual(customer2.Active, 1); //状态是否为1
}
接着我们编写CustomerBiz类中的Active逻辑实现代码的实现,如下:
{
customer.Acitve = 1;
_customerDal.Edit(customer);
}
代码编写完毕后,我们运行单元测试,查看单元测试验证结果如何?
4.2.3.3. 运行单元测试
测试结果为通过,说明我们的业务逻辑代码满足了单元测试场景逻辑验证,我们完成这次简单的业务逻辑编写。到这里大家会说这个业务逻辑太简单了!这个里只能先说一个概念,后面会写一下稍微复杂的例子来进一步说明如何进行测试先行的开发。
4.3 结语
本章初略的对单元测试和TDD开发模式作了一个初步的介绍,通过使用单元测试工具,编写单元测试代码,尤其使用TDD开发模式来规范我们的编程习惯和养成编写单元测试的扎实的基本功,会为整个项目开发带来整体上变化,尤其当系统在编写了足够复杂的商业逻辑后,更换开发人员或者系统的重新升级维护时,有编写良好的单元测试代码会大大降低项目维护成本提高开发的效率。
测试驱动的开发还让项目组开发人员自觉的在编写单元测试过程中,减少类与类之间的依赖关系降低系统的耦合度,提高对象的聚合度或者说提高对象对功能封装程度。作为给初学者的介绍性文章,更多的单元测试内容请参考专业文章或书籍。之所以在这里先介绍的单元测试是因为后面的例子我们将更多的使用TDD模式来实现后面章节的例子。
下一章我们介绍并发和商业逻辑中的事务,只有很好的理解了业中的并发和事务的关系,我们才能在实际开发中准确把握用户的需求和确保系统中数据的一致性和完整性。