软件修改对应之道

本文探讨了软件修改的四个主要原因:添加特性、修正BUG、改善设计与优化,并介绍了测试驱动开发(TDD)的概念及其应用。通过案例分析,阐述了如何利用单元测试工具,如JUnit,对软件进行快速、准确的测试。此外,文章还详细解释了测试驱动开发的基本流程和关键步骤,旨在提高软件开发过程的效率和质量。
摘要由CSDN通过智能技术生成

软件修改的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;

    }

}

 

 

EclipsePackage 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传递呢?之前我们说过单元测试不访问数据库,单元测试运行快.显然是不应该创建连接的.在这里,可以考虑接口提取技术来解开InvoiceUpdateResponderConnection的依赖关系.分析一下,我们需要的只是从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本身,都能得到很好的测试覆盖.

 

 

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值