软件修改的4个起因
添加特性
对与一个系统要添加功能时,理想情况是可以独立编写新功能,并且可以不修改代码(完全不修改是不可能的,这里指只修改调用处代码),很容易的就插入到现有系统中,如果能做到的话,说明现有系统有良好的扩展性,符合面向对象设计原则中的开闭原则,既对扩展开放,对修改关闭.但大多数情况是添加一个功能导致我们之前的代码被修改,引出更多的BUG.
所以,添加特性时我们要把握观察一点,既是否有行为改变,如果有行为改变,就可以认定是在修改代码.
例:
publicclass CDPlayer { /** * 添加音乐轨迹列表 * @param Track track * */ publicvoid addTrackList( Track track ) {
} }
|
假设我们现在有一个CD播放软件,上面这个类为CD播放提供了功能处理支持,不包括界面部分代码,现在我们提出一个需求,要加入一项功能,替换CD音乐轨迹,我们加入下面的方法
publicvoid repLaceTrackList( String trackName , Track track ) { } |
并且在软件界面上去加入一个替换按钮,事件中来调用上面repLaceTrackList执行替换,我们有没有改变行为呢,答案是肯定的,因为我们多了一个按钮,系统会多花一点时间来渲染.所以,添加特性时,我们同时也在修改代码.
修正BUG
修正BUG时也必然会修改代码,这个是很明显的,同时修正BUG如果没有一个测试环境的保护,同样也有可能会引出更多的BUG.
改善设计(重构)
我们为了改善现有设计,使其拥有更好的可维护性与扩展性,会进行重构,重构是指在不改变软件行为的前提下,修改现有代码的结构.同样也是如果没有测试保护的重构,会带来更多BUG,而且这种结构的破坏如果失控,将是灾难性的.所以通常我们不重构或者更贴切的说是对重构无法下手.
优化
优化与重构有着相似之处,都是包括软件行为不变的前提下进行的,重构是改善可代码的结构,而优化是指运行的效率,内存占用,硬盘空间占用等资源使用率的改善.
综上所述,4种导致代码修改的原因中,分别可能修改软件的结构, 功能与资源使用情况.
| 添加特性 | BUG修正 | 改善设计 | 优化 |
结构 | 改变 | 改变 | 改变 | -- |
功能 | 改变 | 改变 | -- | -- |
资源使用 | -- | -- | -- | 改变 |
软件修改的方法
编辑并祈祷式的修改
软件修改方式有2种,书中有趣的将它们称为编辑并祈祷与覆盖并修改.
首先我们来看第一种编辑与祈祷,在修改前要先理解现有代码,然后动手修改,完成后,运行查看效果,最后对系统整体的一个修复,确保没有影响到别的地方,最后一步是必不可少的.也就是说,我们在改动时一边祈祷,祈祷没有改出什么问题,最后还用额外时间来验证做的是否正确.
常规的软件修改方式(编辑并祈祷)
覆盖并修改式的修改
第二种方式,覆盖并修改是指我们用测试覆盖所要修改处的代码,在测试的保护下去修改代码,这样我们可放心的去修改.
传统的测试总是开发完成后执行的,一个问题的反馈需要很长的时间.这种方式实际上是在通过测试来验证正确性,这虽然是一个目标,但力度不够.
我们还可以用测试来检测变化.传统术语来讲,这可叫做回归测试,也就是周期性的运行测试,检查已经行为是否运行良好.但是问题在于,如果回归测试层面只是在界面层,我们无法在编写代码时便得知我们的结果运行是否正确.
为了解决界面层回归测试覆盖力度不够的问题,我们引入单元测试,单元测试也是一种回归测试,它是一组独立的测试,每个测试中针对的是一个类的具体方法…具体的测试代码称为测试用具,对与单元测试的工具也有很多,目前用到比较多的是xunit,对应到JAVA中为 Junit,稍后我们对junit进行简单的使用讲解,目前只要理解概念就好.关于一个单元测试有以下几个定义
l 单元测试运行的快,运行不快的不是单元测试
l 单元测试中不与数据库有交互
l 单元测试不调用文件
l 单元测试不操作设备
虽然上述几种测试也非常的有用,而且通常也会用到单元测试的方式去编写,但必须有效区分,它们并不是单元测试,因为单元测试是可以让用户在编写代码时快速运行的.
TDD测试驱动开发介绍
一个高效的软件开发过程对软件开发人员来说是至关重要的,决定着开发是痛苦的挣扎,还是不断进步的喜悦,最近兴起的一些软件开发过程相关的技术,提供一些比较高效、实用的软件过程开发方法。其中比较基础、关键的一个技术就是测试驱动开发(Test-Driven Development)。虽然TDD光大于极限编程,但测试驱动开发完全可以单独应用。
TDD的基本思路就是通过测试来推动整个开发的进行。而测试驱动开发技术并不只是单纯的测试工作。
测试驱动开发的基本思想就是在开发功能代码之前,先编写测试代码。也就是说在明确要开发某个功能后,首先思考如何对这个功能进行测试,并完成测试代码的编写,然后编写相关的代码满足这些测试用例。然后循环进行添加其他功能,直到完全部功能的开发。
测试驱动开发的基本过程如下:
1) 明确当前要完成的功能。
2) 快速完成针对此功能的测试用例编写。
3) 测试代码编译不通过。
4) 编写对应的功能代码。
5) 测试通过。
6) 对代码进行重构,并保证测试通过。
7) 循环完成所有功能的开发。
单元测试工具使用
首用我们模拟一个计算器处理类,分别有加减乘除方法,创建类Calculator.java
package com.test.demo01;
publicclass Calculator { privatestaticintresult; // 静态变量,用于存储运行结果
publicvoid add(int n) { result = result + n ; }
publicvoid substract(int n) { result = result - n; // Bug: 正确的应该是 result =result-n }
publicvoid multiply(int n) { } // 此方法尚未写好
publicvoid divide(int n) { result = result / n; }
publicvoid square(int n) { result = n * n; }
publicvoid squareRoot(int n) { for (;;) ; // Bug : 死循环 }
publicvoid clear() { // 将结果清零 result = 0; }
publicint getResult() { returnresult; } }
|
在Eclipse的Package Explorer试图中选中待测试的类->右键->new->Junit TestCase
创建测试用具
setup()方法勾选表示自动创建每个测试方法执行前要调用的方法.
勾选要测试的方法
点击完成后,自动生成测试代码,我们只需要针对每个待测方法编写测试逻辑即可.首先,要持有待测试类的实例,因为我们的计算类中保存结果为静态存储,所以在执行完一个算法时,结果会影响到下次计算.为了保证第一个测试都是独立进行的,我们在 setup()方法中调用clear(),每次执行测试前都将结果清0.
private Calculator cal = new Calculator() ;
@Before publicvoid setUp() throws Exception { cal.clear() ; }
|
编写测试逻辑:
@Test public void testAdd() { cal.add(2); cal.add(3); assertEquals(5, cal.getResult()); }
@Test public void testSubstract() { cal.add(10); cal.substract(2); assertEquals(8, cal.getResult()); }
@Ignore publicvoid testMultiply() { }
@Test publicvoid testDivide() { cal.add(8); cal.divide(2); assertEquals(4, cal.getResult()); } |
assertEquals()方法是断言比较,比较第一个值与第二个值是否相等,如果不等会有提示.
运行测试用具结果:
测试编写技巧介绍
单元测试理想情况就像上边我们见到的Calculator类,它可以在很轻松的在测试用具中创建实例,并且方法参数也可以轻易创建.但是我们实际的项目当中绝大多数的类是不能直接创建的, 初期设计不良,没有考虑程序扩展性能.屡次修改都是在原基础代码上进行修改.都可能导致我们无法很轻松的编写测试,下面我们争对碰到的各种情况给出一个解决方式.
朴素化参数与接口抽取
例:
/** * 发票更新响应者 * */ publicclass InvoiceUpdateResponder {
/**构造方法 * @param Connection dbconn 数据库连接 * @param Object servlet servlet请求对象 * */ public InvoiceUpdateResponder( Connection dbconn , Object servlet ) { }
/**获取更新结果*/ public String getRepondseText() { returnnull ; }
/**更新发票*/ publicvoid update() {} } |
上边示例代码是一个发票更新处理类,它从Servlet中接收发票ID信息,通过查询数据库,将相关ID的发票信息处理更新,并且能够能过getRepondseText()返回结果.
假设,我们现在要对getRepondseText进行修改,按我们的方式,应该先对InvoiceUpdateResponder类编写测试用具,但是我们不难看出,想要为这个类直接实例化,是不太容易的,因为它依赖了Connection dbconn , Object servlet,当然这里只是在说明场景,我们实际项目并没有这样做的.
为了能够实例化类,我们是否考虑创建一个真实的DB连接,与servlet传递呢?之前我们说过单元测试不访问数据库,单元测试运行快.显然是不应该创建连接的.在这里,可以考虑接口提取技术来解开InvoiceUpdateResponder与Connection的依赖关系.分析一下,我们需要的只是从DB能拿到数据,并不是真的需要操作CONN对象,这里可以建立一个数据接口,
publicinterface IConnData { /**根据发票ID获取发票信息*/ public Invoice getInvoiceListByID( String id ) ; }
|
依赖处代码改为
public InvoiceUpdateResponder( IConnData dbconn , Object servlet ) { } |
现在我们数据库对象的依赖问题就得到了解决,接下来看Object servlet,显然真实创建一个Servlet对象也是不太可能的,我们只是想从Object servlet中得到用户提交上来的一个ID信息,所以这里可以将参数直接改为 String ID,将参数朴素化.
public InvoiceUpdateResponder( IConnData dbconn , String id ) { } |
然后我们就可以很轻松的编写测试用具:
@Test publicvoid testGetRepondseText() { InvoiceUpdateResponder res = new InvoiceUpdateResponder( new IConnData() { public Invoice getInvoiceListByID(String id) { returnnull; } } , "id001") ;
Assert.assertEquals( null , res.getRepondseText() ) ; } |
上边提到的接口抽取与参数朴素化似乎有点太刻意的假设,有人可能会说,我们当然有dao数据接口,也当然不会把一个servlet对象向业务层直接传递.没错,我们只是在用这样一个例子,去说明一个对象因为直接依赖过重的参数时,导致无法实例化的情况.
总结我们修改的步骤如下:
1 确定改动点
2 找出测试点
3 解依赖
4编写测试
5 修改或者重构,用测试验证结果.
感知与分离
同样还是上边提到的问题,我们的系统从来就没有那么理想化,不进行任何修改就可以进行测试用具的编写,为了达到测试目的,上边我们利用了参数朴素化与接口提取的方式成功的解依赖,然而这并不是解依赖的唯一目的.
例如,我们待测试的类会对别的类产生影响,我们希望能通过伪装为其他类来接收到这些影响内容,如果没有解依赖,也就是说没有依赖接口,我们就无法做到这一点.依赖倒置原则也同样是面向对象设计中的一个重要原则,如果我们在设计之初就做好了这些工作,自然可以很轻松的进行测试编写.
感知:无法访问到代码计算出的值时,通过解依赖来感知这些内容.
分离:无法将一个代码很好的安放到测试用具中时,需要解依赖技术将其分离.
例:
/** /** * POS系统业务处理 * */ publicclass PosBizProcess { private Object print ;
public PosBizProcess( Object print ) { this.print = print ; }
/**根据用户购买的商品计算总值,如果计算无误,则打印小票*/ publicvoid reckoning( List<Object> mercList ) { //计算..
//调用真实打印方法 this.print.sendPrintContent( mercList ) ; }
} |
我们模拟一个超市收银软件的业务处理部份,代码中reckoning()根据用户购买的商品进行计算,然后利用构造函数中依赖的打印对象向打印机下发打印内容.
设想一下,如果我们现在想要为reckoning编写测试,自然会调用到关于硬件操作的代码,前边说过,单元测试运行快,不操作硬件,我们是否要准备一台打印机呢?
其实,我们只是想测试一下reckoning的排版与计算结果,也就是他提交了什么内容给打印机.我们可以利用接口抽取方式来解依赖,而后成功的感知打印结果.
首先创建一个接口负责打印.
publicinterface IPrinter { publicvoid sendPrintContent( String content ) ; } |
修改PosBizProcess对原对象的依赖,改为依赖接口.
/** * POS系统业务处理 * */ publicclass PosBizProcess { private IPrinter print ;
public PosBizProcess( IPrinter print ) { this.print = print ; }
/**根据用户购买的商品计算总值,如果计算无误,则打印小票*/ publicvoid reckoning( List<Object> mercList ) { //计算..
//调用真实打印方法 this.print.sendPrintContent( "" ) ; }
} |
经过修改,我们的功能没有任何的改变,只是引入了一个接口,但是这样我们就可以通过创建伪对象,也就是伪装成合作者,这里指打印对象,来感知具体的内容了.
创建伪装的合作者:
publicclass FakePrinter implements IPrinter { private String result = null ;
publicvoid sendPrintContent(String content) { result = content ; }
public String getLastContent() { returnresult ; } } |
FakePrinter中同样实现了IPrinter接口,不过它可不是真的去发给打印机,而将每一次的结果都记录下来,方便我们测试.到了这一步,我们就可以完全抛开硬件的依赖进行测试了.
@Test publicvoid testReckoning() { FakePrinter fake = new FakePrinter() ; PosBizProcess pos = new PosBizProcess( fake ) ; pos.reckoning( null ) ; Assert.assertEquals( "结果" , fake.getLastContent() ) ; } |
新生方法
当向一个系统中加入特性,并且这个特性可以用全新的代码来编写时,建议将新代码写入新的法方,在用的新特性的地方调用,这样可能无法保证可以将调用点覆盖到测试保护之下,但至少可以保证新编写的代码可以编写测试,同时也保证了代码的清晰.
例:
publicclass TransactionGate { publicvoid postEntries( List<Object> entries ) { for( Object entry : entries ) { //日期处理 entry.doPostDate() ; }
transactionBundle.getListManager().add( entries ) ; } } |
如上例所示,,我们有一个方法,将一组entry对象迭代,并且调用其中的日期处理方法,最后将处理完成后的结果加入到transactionBundle中.
假设现在要添加一个功能,我们要求被处理日期的对象不能重复,也就是说是没有添加到transactionBundle之中的.通常的做法我们很快就能想到,在for中去加一条判断语句,判断是否已经存在.只处理存在的.为了能保存未重复的结果,我们还需要加入一个新的List对象.
publicvoid postEntries( List<Object> entries ) { List<Object> unList = new ArrayList() ;
for( Object entry : entries ) { if( transactionBundle.getListManager().hasEntry( entry ) ) { //日期处理 entry.doPostDate() ; unList.add( unList ) ; } }
transactionBundle.getListManager().add( unList ) ; } |
当然,我们在加入if( transactionBundle.getListManager().hasEntry( entry ) )这句判断之后还不忘写上 // under lines add by peter.Wang 2012.3.22的字样,分析一下,我们这样加入的代码,可以保证新特性能覆盖在测试范围之下吗,为过滤重复这一边能编写测试,我们无从下手.不能保证新加入的代码执行正确.这样做,新代码与老代码之间没有分界点.
说到这里,直接加入代码到老的方法中带来的问题可能还不够明显,假设,现在又有新的特性要添加,需要对地些未重复项的做处理,这时我们如果还是按着之前的思路,代码应该会被改成这个样子:
publicvoid postEntries( List<Object> entries ) { List<Object> unList = new ArrayList() ;
for( Object entry : entries ) { if( transactionBundle.getListManager().hasEntry( entry ) ) { //日期处理 entry.doPostDate() ; unList.add( unList ) ; } }
//加入新的处理功能 for( Object entry : unList ) { //...此处省略500行 }
transactionBundle.getListManager().add( unList ) ; } |
一个庞大的方法就这样产生了,而且每一次的修改都无法写入测试之中.方法责任变的混乱,越来越不容易读懂.
其实过滤重复与我们的doPoseDate()日期处理,是完全可以分离的两个功能,我的系统中这样的方法有责任不清的有很多,上边的示例介绍了一个本来很小的方法是如何长大的,如果我们在编写时注意到这些问题,系统中的难懂的大方法也就越来越少,修改也就变的容易了,但是对于那些已经长成的大方法如何处理呢?
对于一个混乱的方法修改,可以按照刚才的思路逆推回去,严格按照如下步骤进行:
1 找出修改点
2 如果修改的方法中有代码可以连续单块出现,那么在此处插入一个虚拟的方法调用,并且先注释掉.
3 确定需要原方法中的哪些局部变量
4 确定返回值
5 使用TDD方式来开发新的方法
6 取消注释.
经过这样修改,我们的可以将被抽出的部分进行测试编写,也就保证了修改的安全,就以上边被我们写成的大方法为例,进行一步一步的反推示例:
1找出修改点
分析代码可以看出,方法postEntries()只是处理日期,并将结果存放.但因为我们将过滤重复与新的处理功能全加到本方法中,现在变的很大.修改点是将多余代码提出去.
2 如果修改的方法中有代码可以连续单块出现,那么在此处插入一个虚拟的方法调用,并且先注释掉.
单块连续出现的是我们的重复数据对象List<Object> unList = new ArrayList() ;
我在此处先写一个假设的方法getUnlist();
3 确定参数与返回值,因为我们要实现数据过滤,所以源数据必须是有的,还有就是过 滤后的结果.
修改方法签名为
Public List<object> getUnlist( List<Object> sourceList ) ;
5按照前边说过的TDD方式进行开发,首先编写测试.
@Test publicvoid testGetUnlist() { TransactionGate gate = new TransactionGate() ; List<Object> sourceList = new ArrayList<Object>() ;
sourceList.add( "aaa" ) ; sourceList.add( "aaa" ) ; sourceList.add( "bbb" ) ; List<Object> resuList = gate.getUnlist(sourceList) ; Assert.assertEquals( 2 , resuList.size() ) ; } |
测试条件中添加2个重复的数据,最后比较结果集中的大小,如果过滤成功,结果应该为2,现在方法当然是不满足条件,为了达到目的,我们去添加新方法的代码来满足测试条件.
public List<Object> getUnlist( List<Object> entries ) { List<Object> resu = new ArrayList<Object>() ;
if( transactionBundle.getListManager().hasEntry( entry ) ) { resu.add( unList ) ; }
return resu ; } |
而调用处代码现在应该简化成了这样
publicvoid postEntries( List<Object> entries ) { List<Object> unList = getUnList( entries ) ;
for( Object entry : entries ) { //日期处理 entry.doPostDate() ; } //加入新的处理功能 for( Object entry : unList ) { //...此处省略500行 }
transactionBundle.getListManager().add( unList ) ; } |
现在再看,因为将过滤重复数据写成了新方法,这样依赖重复数据的新功能也可以被提出了,我们的postEntries变得短小精悍,责任清晰,最重要的是重复过滤,新加入的功能,还有postEntries本身,都能得到很好的测试覆盖.