Apworks框架实战(三):单元测试与持续集成

虽然这部分内容并没有过多地讨论Apworks框架的使用,但这部分内容非常重要,它与Apworks框架本身的设计紧密相关,也是进一步了解Apworks框架设计的必修课。

单元测试与持续集成概述

在敏捷开发过程中,单元测试是非常重要的。这不同于传统的瀑布开发模型,在瀑布模型中,单元测试的重要性体现的并不明显,因为在这种模型中,“测试”被强调为整个开发流程中的一个环节,也会有专门的测试团队来负责测试过程,于是,由开发人员负责的单元测试往往被忽略。另一方面,在项目刚刚开始时,由于团队对开发过程和规范的重视,开发人员会着手一部分单元测试的编写工作,但随着时间的推移、需求的变化以及项目进度的推进,出于项目上线或者产品发布的压力,开发团队逐渐更多地关注功能的实现和缺陷(Bug)修复,单元测试也就随手扔在一边。最终,我们能够看到的结果就是,绝大多数项目中都包含了单元测试的工程,但这些单元测试的工程又往往都是被遗弃已久,甚至是无法编译的。

然而,在实践敏捷开发的项目中,单元测试是非常重要的,这在文章的第一部分就做了简单的介绍。这种重要性源自于敏捷开发过程的基本特性:以持续集成的方式应对不断变化的客户需求。软件系统开发的一个重要特点就是需求的不确定性和可变性,传统的瀑布模型以按部就班的方式开发软件系统,很明显无法有效地应对这种可变性特点。敏捷开发过程以迭代的方式进行,项目负责人(Product Owner)将总体需求分割成多个用户故事(User Story),并根据重要优先级,在产品功能特性列表(Product Backlog)中对这些用户故事进行排序。另一方面,整个开发过程被分为多个迭代(Sprint),项目负责人会根据开发团队的预估点数(Estimated Points),结合上一个迭代团队的完成能力(Velocity),从产品功能特性列表中挑选一些用户故事来实现。在开发过程中,项目负责人以及客户都会参与进来,不仅如此,在每次迭代结束的时候,团队都会向项目负责人进行功能演示,倘若功能实现上有所出入,那么这些功能上的修改将被记录在案,以便在后续的迭代中改进,这样就能够保证所开发的软件系统不会偏离实际需求太远,为项目或产品的最终成功交付奠定了基础。

当然本文的主要宗旨并不是对敏捷开发过程进行论述,而是更多地考虑,当出现需求变更、功能改进,以及增添新功能的情况时,我们应该怎么办。遇到这样的问题,我们大致应该从两方面考虑:1、以哪种方式修改设计和代码最合适?最好是既简单快捷,又能尽量避免造成已有设计和代码的大范围修改;2、一旦发生设计和代码的修改,如何保证(或者说得知)这些修改不会影响到已经实现并经过严格测试的系统功能?不幸的是,要能够做好其中的任何一方面,都不是件容易的事情。前者要求整个软件系统有着良好的设计,而后者则要求这种设计是可测试的。

首先,团队应该更合理地将面向对象的分析和设计技术引入到软件系统的开发中来,对系统分析和设计引起足够的重视。或许有人会说当今流行的软件开发方法论有很多,比如面向函数式编程,也时常看到有一部分人会对面向过程的“面条式”编程情有独钟。当然,对于像我这种每天都浸泡在面向对象世界里的程序员而言,使用一些面向过程的方式编写一些小程序也别有一番趣味,但不得不承认的是,当今大型企业级复杂软件系统开发中,面向对象分析和设计技术仍然占据着权威性的主导地位,纵观流行的开发技术和平台:.NET、JAVA、C++都是以面向对象为基础的,Python、PHP、Ruby、Lua等等,对面向对象技术也有着很好的支持。事实上,面向对象技术已经为我们的第一个问题提供了答案,我们需要做的是,在项目中合理地利用这种技术,而这恰恰也就是最大的难点,它要求团队有着较高的技术素养和丰富的实战经验。

我曾经做过这样一个项目,在这个项目的一个迭代中,团队需要实现这样的功能:在一些特定的条件下,比如当用户在线注册3天后,或者每个季度结束的时候,能够在用户的个人信息主页上看到报表的显示链接,当用户点击这些链接时,能够打开并查看相应的报表。这样的需求实现起来并不困难,最直接的方式就是在打开页面的时候从后台对这些条件进行判断,以决定是否显示相应的链接。然而,在经过细致的分析之后,我们发现,使用基于面向对象的事件模型来解决这样的问题会显得更加自然,并且易于扩展:几乎所有的判定条件都是以“当……时,将会(将能够)……”的句式进行描述,这就是事件模型的经典应用场景。之后所发生的事情让我们庆幸当时的选择是正确的:在下一个迭代中,客户要求不仅要能够在用户的个人信息主页上看到报表的显示链接,而且还要以电子邮件的形式通知客户:我们已经为您准备了一份报表,请登录您的个人主页进行查看。接下来,我们向已有的系统添加了一个新的模块,用来侦听来自事件模型的消息,并且在消息处理器(Event Handler)中,根据消息数据和电子邮件模板来产生一封邮件,并将其发送出去。我们得到的结果是:完全没有改变已有代码的任何部分,因此已有代码不需要进行回归测试,我们仅仅是添加了一个模块,修改了程序的配置文件,并对这个模块做了单元测试和集成测试,整个过程仅仅花了团队不到一周的时间,而每个迭代却是覆盖了三周的时间。这不仅提高了团队的生产率,而且保证了项目和产品的质量。在这个案例中,我们没有选择那种直观并且易于实现的方式,而是对功能需求进行了细致的设计,并选择了一种相对较为复杂的方式,然而后续的故事验证了这种取舍的正确性。倘若我们选择了直观简易的方式,那么当客户需求更改或者功能需要添加的时候,我们需要对已有代码中所有产生报表链接的部分进行修改,添加电子邮件的发送功能,我们需要改变已有的测试用例,以满足新的需求,我们还需要对这些修改过的代码进行回归测试,以确保之前的报表链接能够正确产生,三周时间或许勉强能够完成这些工作。别忘了一件更让人头疼的事情:在下一个迭代中,客户要求我们不仅需要发送电子邮件,还需要根据用户自己的隐私设置,选择性地向他们发送短消息提醒。好吧!代码再改一次,测试用例再改一次,再做一次回归测试。其实,面向对象分析和设计的基本原则早就提醒过我们,这种做法会引来无穷的隐患:我们的做法从根本上违反了“开-闭原则(Open-Closed Principle)”!

以上是一个真实的案例,我相信重视并合理利用面向对象分析和设计技术的好处,不用我再用过多的笔墨去论证,我想阐述的是,开发前的分析和设计的确需要花费一定的时间,但团队不要在这方面过于吝啬,分析和设计做好了,便能够在后续开发过程中受益(比如节省时间、提高质量),而且多数情况下,团队的受益往往要多于之前在分析和设计上的付出。对于这一点,有些读者或许会有不同的观点,这也很正常,毕竟项目的实际情况会有所不一。比如嵌入式硬件驱动的开发,或者是化合物分子量计算算法的实现等等,在这些场景中,或许采用结构化编程的方式效率更高更快捷,于是也就不存在上面讨论的这些问题了。

单元测试的敏捷实践

既然我们对项目代码进行了改变,添加了新的模块也好,通过重构改善既有设计也好,我们总是需要保证这些改变不会影响到已有的功能实现,这也就是上面所提到的第二个问题。从开发人员的角度看,解决这个问题最好的办法就是每完成一次代码更改,都将所有的单元测试全部运行一次,确保所有的单元测试都能顺利通过,如果单元测试的代码覆盖率比较高的话,那么单元测试的全部通过就表示测试所覆盖的代码行为跟先前的行为是一致的,也就是说,新的更改并没有影响到已有的代码功能。从整个项目的角度出发,这其实是一种持续集成的软件开发实践:开发人员会经常集成这些代码变更,通常每个成员每天至少集成一次,也就是项目上每天会发生多次集成,每次集成都通过自动化过程(编译、部署、自动化测试)来验证,从而在尽可能早的阶段发现错误,减小因设计和代码的变更带来的质量风险。

由此可见,单元测试对敏捷项目是多么的重要。所以,单元测试不仅要写,而且也要进行合理的设计,以提高单元测试的代码覆盖率。通常来讲,单元测试的设计和编写应该遵循以下几个原则:

  • 单元测试应该仅测试代码中的一个特定功能。顾名思义,仅测试代码中的一个单元。有些文献中会认为单元测试的目的不是为了找到缺陷(Bug),而是为了逻辑的验证。这个观点我们之后再单独讨论
  • 基于上述原则,单元测试不能引入跟测试点无关的代码。假设我们需要对“变更用户的收货地址”这段代码进行单元测试,很多开发人员习惯在单元测试中访问数据库、读取消息队列等等,虽然从功能上讲,这些内容与代码的功能实现息息相关,但这些都不是“变更用户的收货地址”的核心功能。数据库的访问,应该由数据库的开发团队去测试,消息队列的读取,应该由消息队列的开发团队去测试。我们需要关心的是:假设数据库的访问是正确的,我们需要得知,当这段代码被执行后,用户的收货地址的值是否真的发生了变更;假设消息队列的访问是正确的,我们还需要得知,当这段代码被执行后,用户收货地址变更的消息是否真的能够被发送到消息队列中去。简而言之,单元测试需要关注的是一些“信号”:它们的发送和接收是否正确
  • 单元测试的执行应当足够快。持续集成要求每一次代码签入都要将所有的单元测试运行一次。如果单元测试执行速度太慢,不仅影响开发效率,而且也会阻碍持续集成的应用。事实上,如果我们遵循以上两条原则,单元测试的执行速度应该是可以满足需要的

于是,我们编写的代码就应该能够让针对这些代码的单元测试满足以上的原则,这也就是我们平时提的最多的“代码可测试性”。如何让代码可测试?采用基于抽象的面向对象设计技术,可以帮助我们满足这样的需求。

说明:在Stackoverflow上有过这样的讨论:由单例模式(Singleton)实现的代码可测试吗?在众多答案中,更为合理的解释是:虽然通过Fake技术可以实现代码的可测试性,但单例模式不是最好的设计。单例无非是更改了对象的生命周期,能够达到相同效果的一个更合理的设计是基于抽象(接口)进行设计,然后使用依赖注入框架来管理对象的生命周期。这种做法不仅灵活度高,而且设计本身是可测试的。

举一个很简单的例子:在ASP.NET MVC/Web API的控制器(Controller)中,我们会使用仓储来读取聚合根,然后执行相关的业务操作。比如很多情况下,我们会这么做:

public class MyController : ApiController
{
    private readonly CustomerRepository customerRepository 
        = new CustomerRepository();
    
    public IHttpActionResult GetCustomerById(Guid id)
    {
        var customer = customerRepository.GetByKey(id);
        // ...
        return Ok();
    }
}

这种设计大致可以用下面的UML类图表示:

image

上面的代码产生了MyController与CustomerRepository之间的关联(Association)关系。这种关系导致MyController的实现依赖于CustomerRepository。假设我们需要对GetCustomerById进行单元测试,我们势必需要构造一个MyController的实例,而此时CustomerRepository也被构造,于是对MyController的单元测试需要依赖于CustomerRepository的实现。从实现上看,我们首先需要配置一个Customer仓储,使其能够正常工作,然后将测试数据导入到仓储中,进而再对GetCustomerById进行所谓的单元测试。在测试运行时,测试用例会主动访问数据库或者其它的数据存储机制,来获得特定的数值,然后判定我们需要的结果是否正确。

相信很多项目会这么去做单元测试,当然不排除有些项目本身存在历史遗留问题的可能性,其实这种做法更多地包含了集成测试的元素:它整合了外部资源的访问。就单元测试而言,首先测试的执行是缓慢的,外部资源的访问大大降低了测试效率;其次测试是不稳定的,如果外部资源访问失败,或者测试数据发生了更改,我们的测试用例就会失败,而这却不是我们所需要的;最后,如果CustomerRepository的实现发生了变化,我们不仅需要对整个控制器进行重新编译,而且所有的单元测试都需要重新运行一次,紧耦合给我们带来了无限困扰。对上述代码的重构已经刻不容缓。

我们可以引入一个ICustomerRepository的接口,并使得MyController仅关联ICustomerRepository接口,而CustomerRepository则实现了这个接口。如果你还是在MyController中直接使用new关键字来新建CustomerRepository的实例,比如:

private readonly ICustomerRepository customerRepository 
    = new CustomerRepository();

那么这种做法与上面的做法还是没有区别:MyController仍然依赖于ICustomerRepository接口的一种实现。正确的做法应该是通过MyController的构造函数,将ICustomerRepository的实现类型传入,这样就完全解耦了MyController和CustomerRepository。参考代码如下:

public class MyController : ApiController
{
    private readonly ICustomerRepository customerRepository;
    
	public MyController(ICustomerRepository customerRepository)
	{
		this.customerRepository = customerRepository;
	}
	
    public IHttpActionResult GetCustomerById(Guid id)
    {
        var customer = customerRepository.GetByKey(id);
        // ...
        return Ok();
    }
}

public class CustomerRepositoryImpl : ICustomerRepository { }

以下是UML类图:

image

如果我们对这样的设计进行单元测试,我们可以使用Mock技术,创建ICustomerRepository的桩(Stub)对象,同时假设在调用这个桩对象的GetByKey方法时,返回某个特定的Customer实例,从而验证MyController中GetCustomerById方法的逻辑正确性。难怪社区中会有人认为,单元测试其实是一个验证的过程。基于这种设计,对于GetCustomerById方法的单元测试可以这样写(使用Moq Framework):

[TestMethod]
public void GetCustomerByIdTest()
{
	var customer = new Customer();
	Mock<ICustomerRepository> mockCustomerRepository
		= new Mock<ICustomerRepository>();
	mockCustomerRepository
		.Setup(x => x.GetByKey(It.IsAny<Guid>()))
		.Returns(customer);
	var myController = new MyController(mockCustomerRepository);
	var returnedCustomer = myController.GetCustomerById(Guid.NewGuid());
	Assert.AreEqual(customer, returnedCustomer);
}

回顾上面的单元测试设计原则,显而易见这样的单元测试是满足要求的。通过这个简单的案例我们也可以看到,合理的系统设计对于单元测试的编写是何等的重要。虽然Microsoft Visual Studio 2012/2013 Fake Framework(在Visual Studio 2010中需要额外安装Microsoft Pex and Moles扩展)还有TypeMock等收费的Mock框架通过一定的技术能够做到对于不可测试的代码进行单元测试,但这不是完美的解决方案,这些Mock框架还是有一定的局限性。从系统开发的角度出发,我们更希望能够让我们设计和开发的软件是稳定的、高效的、可测的、灵活的,以及可维护的。总而言之,我们的设计应该是可测的,这一点对于敏捷开发实践尤其重要,或者直接从单元测试入手,以测试驱动开发(Test-Driven Development)的方式,一步步地实现一个可测的设计。

在此我们不再细究单元测试中Stub、Mock以及Fake的概念,我们需要反复强调的是合理设计的重要性。对于面向对象分析与设计(OOAD)而言,遵循SOLID设计原则是非常重要的,系统设计的好坏将直接影响到项目管理(需求管理、资源分配、成本管理、进度管理等方面),甚至整个项目的成败。

依赖倒置原则(Dependency Inversion Principle)

依赖倒置原则是OOAD “SOLID”设计原则中“D”所表示的意思。这是一个很有趣的事情,让我们以通俗的方式来理解这个问题。仍然以我们上面改进后的设计为例,倘若现在我们要在Visual Studio中开发这么一个Web API,你会将这些类和接口写在哪个或者哪些程序集(Assembly)中?你会将所有的类和接口都定义在Web API这个项目中吗?

如果你的答案是:No,那么进一步考虑这个问题:你会将ICustomerRepository接口和它的实现类:CustomerRepository类定义在同一个项目(也就是Class Library项目)中吗?如下:

image

此时你的答案或许会是:Yes。我们再进一步思考,如果是这样的话,WebAPI项目就要依赖于Repositories项目,表面上看没什么不妥,而实际上每当CustomerRepository的实现发生更改,我们都要重新编译和发布整个WebAPI,而CustomerRepository的变更却又是WebAPI所不关心的,因为它根本无需关心ICustomerRepository接口是如何实现的。另一方面,如果我们新增加了一种ICustomerRepository的实现,那么这个新的实现也要引用MyProject.Repositories项目,于是,CustomerRepository的变更又会影响到这个新实现所在的程序集。

经过分析我们不难发现,合理的做法应该是将ICustomerRepository接口定义在WebAPI项目中,也就是:

image

原因很简单:WebAPI项目中的MyController仅依赖于ICustomerRepository接口,而不是CustomerRepository这个具体的实现类型。这也不难理解,接下来的事情就比较有趣了:在我们编写CustomerRepository代码的时候,我们要在Visual Studio中,在MyProject.Repositories项目上添加对MyProject.WebAPI项目的引用,否则无法获得ICustomerRepository这个接口的定义!简单地说,本来应该是A需要依赖B中的东西,来实现A自己的功能,现在反过来B需要引用A来实现A中抽象的部分。这就是依赖倒置的基本概念。

难道这么做不会产生循环引用吗?如果你还是试图在MyProject.WebAPI中引用MyProject.Repositories,以获取CustomerRepository实现类,那么你的设计仍旧是糟糕的。MyProject.WebAPI为什么要去关心ICustomerRepository接口的具体实现是什么样子的呢?完全没必要关心。那怎么办?使用依赖注入框架!(也就是“控制翻转/依赖注入(IoC/DI)”的由来)

其实,更为合理的设计应该是这样的:

image

MyProject.WebAPI和MyProject.Repositories都引用MyProject.RepositoryContracts项目,而在这个项目中,包含了所有Repository的接口定义。有兴趣的朋友可以思考一下,这种设计的优点在哪里。

小结

本文详细讨论了优秀的设计(尤其是面向对象分析与设计)对单元测试的重要性、单元测试对持续集成的重要性,以及持续集成对敏捷开发的重要性。要实践敏捷开发,一个优雅、合理的设计必不可少。文章最后还简单讨论了依赖倒置原则,这也是Apworks框架设计所遵循的基本原则。下一部分将介绍Apworks框架设计对OOAD设计原则的支持。

原文地址:https://www.cnblogs.com/daxnet/p/3665057.html