首页 新闻 论坛 群组 Blog 文档 下载 读书 Tag 网摘 搜索 开源 FAQ 第二书店 博文视点 程序员
频道: 研发 数据库 中间件 信息化 视频 .NET Java 游戏 移动 服务: 人才 外包 培训
    图书品种:235680
       
热门搜索: ASP.NET Ajax Spring Hibernate Java

有些情况下,要开始给一个类编写测试还是比较容易的。不过要是遗留代码的话往往就不那么简单了。可能会遇到一些难以解开的依赖。在痛下决心将一批类弄进测试用具从而让以后的日子好过些之后,最令人窝火的事情莫过于却又发现要进行一堆挤在一块儿的修改。你要把一个新特性加进系统,同时发现为此得修改三四个紧密相关的类,其中每一个类都是不花上几个钟头就没指望能放入测试之下的。你心里头当然清楚要是捏着鼻子干完这事儿的话代码肯定会变得容易对付得多,然而,真的必须得一个一个的来解开所有这些依赖吗?那可不一定。

通常有一个办法值得一试,那就是所谓“退一层测试”,“退后一层”,从而找到一个地点能够同时给多处修改编写测试。比如要对一系列私有方法进行修改,只需为某一个公有方法编写测试就行了。或者,要对某个对象所持有的一组互相协作的对象进行测试,我们只需测试前者的接口即可。采用这种方法不仅能够达到覆盖所作修改的目的,还能在代码重构方面提供更大的自由度;在不违反测试所限定的行为的前提下,测试覆盖之下的代码无论怎么改都不要紧。

高层测试对代码重构也是比较有用的。与精细到类的测试相比,人们一般更喜欢较为高层的测试,因为他们觉得接口上如果有一大堆零零碎碎的测试的话改起来就要难一些了。然而实际往往比人们想象得要简单,因为你可以先改测试再改代码,以安全的小步骤来一点一点地改进代码的结构。

不过,高层测试虽说是个重要的工具,但并不能替代单元测试。而是为最终将单元测试安置到位而进行的铺垫。

那么我们到底该如何才能将这些“覆盖测试”安置妥当呢?首先便是确定测试的地点。如果还没有的话,建议你看一看第11章。该章描述了所谓的“影响结构图”,影响结构图是一个强大的工具,可以用它来推断出测试地点。而本章则描述了拦截点(interception point)的概念,并展示了如何寻找到拦截点。此外还描述了在代码中所能找到的最佳拦截点,即所谓的汇点(pinch point)。我会告诉你如何寻找到这些点,以及当你想要编写测试来覆盖将要修改的代码时,寻找到的这些点将会给你带来什么样的帮助。

12.1  拦截点

给定一处修改,在程序中存在某些点能够探测到该修改的影响,我们把这些点称为拦截点。寻找拦截点的难易程度跟具体的应用程序是有关系的。比如,假设一个应用程序中的各个部件都搅和在一块,没多少自然接缝的话,在其中要想寻找到一个合适的拦截点就相当费工夫,不进行一点影响分析以及解开大量的依赖是别想达到目的的。

最佳切入点莫过于先确定需要进行修改的点,并从这些修改点开始一路向外追踪影响。每个可以探测影响的点都是一个拦截点,但并非每个都是最佳拦截点,在整个过程中你都需要自己进行判断。

12.1.1  简单的情形

现在,假想我们需要修改一个名为Invoice的Java类,目的是要更改该类计算费用的方式。Invoice类上面的那个计算总计费用的方法叫做getValue,如下所示:

public class Invoice

{

    ...

    public Money getValue() {

        Money total = itemsSum();

        if (billingDate.after(Date.yearEnd(openingDate))) {

            if (originator.getState().equals("FL") ||

                    originator.getState().equals("NY"))

                total.add(getLocalShipping());

            else

                total.add(getDefaultShipping());

        }

        else

            total.add(getSpanningShipping());

        total.add(getTax());

        return total;

    }

    ...

}

我们需要修改发货到纽约的运输费用的计算方式,因为立法机关刚刚增加了一项税,从而对纽约那边的运输业务造成了影响,而且不幸的是,我们只得把这项费用算到客户头上。首先我们把负责运输费用计算的代码提取到一个新类ShippingPricer当中。如下所示:

public class Invoice

{

    public Money getValue() {

        Money total = itemsSum();

        total.add(shippingPricer.getPrice());

        total.add(getTax());

        return total;

    }

}

这么一来,原先由getValue完成的工作现在就全部改由ShippingPricer来完成了。之后我们还得对Invoice的构造函数作一点改动,在里面创建一个知道开票日期的ShippingPricer对象。

要找出拦截点,就得从修改点起一路追踪影响:getValue方法将会返回一个与原先不同的值。我们发现Invoice中并没有其他方法使用getValue,但另一个类却使用了它,那就是BillingStatement类的makeStatement方法。图12-1展示了这一点:

此外我们还要对Invoice的构造函数作改动,所以还得看一看哪些代码是依赖于它的。本例中要进行的改动是在Invoice的构造函数中创建一个ShippingPricer对象。该对象除了影响使用了它的方法之外别无其他影响,而唯一一个使用了它的方法便是getValue。图12-2展示了这一影响:

图12-1  getValue影响了BillingStatement. makeStatement

图12-2  对getValue的影响

我们可以将上面两幅图合并在一起,结果见图12-3:

图12-3  影响链

现在的问题是,我们的拦截点在哪里?其实我们可以将上图中的任意一个椭圆节点当作拦截点,当然,前提是我们得对它们所对应的实体(方法/类/变量等)有访问权才行。我们可以尝试通过shippingPricer变量来进行测试,然而它是Invoice类的一个私有变量,所以无法访问。实际上,就算在测试中可以访问shippingPricer变量,它也只能算是一个相当“窄”的拦截点,通过它可以感知到我们对构造函数所作的改动(创建shippingPricer),并可以确保shippingPricer的行为如我们所预期的那样,但是却无法通过它来确保getValue的改动也是良好的。

我们也可以为BillingStatement的makeStatement方法编写测试,通过检查其返回值来确保修改的正确性,但实际上还有更好的办法,那就是针对Invoice的getValue编写测试;这一方案甚至还更加省事。没错,能够将BillingStatement纳入测试之下固然是件好事,但在目前的情况下其实并没这个必要。等到后面真要修改BillingStatement类时再将它纳入测试也不迟。

一般来说拦截点离修改点越近越好。之所以这样说,有如下几方面的原因。首先是安全性。从修改点至拦截点,途中的每一步都好比是逻辑论证过程中的一步。从根本上我们要表达的其实就是这样的话:“我们之所以可以在这儿测试,是因为这儿影响了这儿,后者进而影响了那儿,那儿最终影响了我们所测试的这个东西。”论证过程中的步骤越多,我们就越难判断论证的正确性。有时候唯一能有信心的做法就是在拦截点编写测试,然后回到修改点对代码作一点小小的改动,再观察测试失败了没有。的确,有些情况下你不得不退而采用该技术,但应该不会总是需要这么做。拦截点的选择离修改点越近越好的另一个原因就是在离得较近的地方安置测试通常比较容易一些。但这也并不是绝对的;具体还是要看实际的代码。从修改点至拦截点的步骤越多,安置测试就越难。通常你得在大脑里面模拟代码运行来确定一个测试是否覆盖了某块遥远的功能。

拿本例来说,我们想要对Invoice进行修改,最佳的测试点或许就是getValue。可以在测试用具中创建一个Invoice对象,以各种方式来设置它,然后通过调用getValue(并检查其返回值)来固定住行为,以防被我们的修改所破坏。

12.1.2  高层拦截点

大多数情况下,对于一次修改来说,我们所能找到的最佳拦截点就是被修改类上的一个公共方法。这类拦截点容易寻找,也容易使用,但有时候并非最佳的选择。关于这一点,只要我们把Invoice例子稍微扩展一点就不难看出来。

假设除了修改Invoice的运输费用计算方式之外,还得修改一个名为Item的类,给它添加一个成员变量来记录其运输方式。此外在BillingStatement中还需要给每个托运人配置一个单独的细目分类。图12-4展示了目前的设计的UML图。

如果这几个类都没有测试的话,我们可以通过给每个类分别编写测试,并进行所需的修改开始。这种做法当然是可行的,但并非最有效率的。更高效的做法是试着找出一个能够用来刻画这块代码的特征的高层拦截点。这么做的好处有两点:首先我们需要进行的解依赖可能减少了,另外我们的“软件夹钳”所夹住的代码块也更大。有了能够刻画这组类的特征的测试,重构工作也就得到了更多的守护。比如在更改Invoice和Item类的结构时,我们就可以使用BillingStatement的测试来作为不变式。下面就是为同时刻画BillingStatement、Invoice和Item类而编写的一个启动测试:

图12-4  扩展的开票系统

void testSimpleStatement() {

    Invoice invoice = new Invoice();

    invoice.addItem(new Item(0,new Money(10));

    BillingStatement statement = new BillingStatement();

    statement.addInvoice(invoice);

    assertEquals("", statement.makeStatement());

}

如上面的代码,我们可以搞清BillingStatement为只含一项货物的发货单生成的是什么说明文本,并在测试中使用它。接下来,我们可以添加更多的测试,看看对于发货单和货物的不同组合,说明文本的格式如何变化。对于那些将引入接缝的代码块,编写测试用例的时候要格外小心。

那么,使得BillingStatement成了一个理想的拦截点的原因在于,在它这一个点上我们就能够探测到一簇类的修改所造成的影响。图12-5展示了即将进行的修改的影响结构图。

图12-5  账单系统的影响结构图

从上图中我们可以看到,一切影响都可以通过makeStatement探测到。或许并不容易,但至少是可行的,而且别忘了这可是“在单一地点探测所有影响”。在设计中,我把这类地点称作汇点(pinch point)。汇点是影响结构图中的交通要冲,在这类地点编写的测试能够覆盖大量的修改。若能在设计中找到汇点的话,你的工作就会轻松许多。

不过,关于汇点,有一个关键的地方是要记住的,那就是它们是由具体的修改点来决定的。有时候就算一个类有多个客户,对它的一组修改也仍然存在着一个很好的汇点。为了说明这一点,再次回顾一下我们的开票系统,这次我们把视野稍微拉远一点(见图12-6):

图12-6  加入了详细目录的开票系统

有一点并没有注意到,那就是Item还有一个叫做needsReorder的方法。每当需要了解是否需要进行重新排序时,InventoryControl类便会调用这个方法。那么,就我们刚才所讨论的修改而言,现在有了needsReorder方法的加入,影响结构图会有什么样的变动呢?答案是没有丝毫变动。往Item类中添加shippingCarrier成员变量根本不会影响到needsReorder方法,所以汇点还是原来那个汇点,即BillingStatement。

让我们稍微改变一下场景。假设我们还需要作另一处改动。需要往Item中添加一个方法,以便能够获取/设置货物(Item)的供应商。此外InventoryControl和BillingStatement这两个类会使用供应商的名字。图12-7显示了以上场景对影响结构图造成的影响。

现在事情看来似乎没刚才那么顺利了。我们的修改所产生的影响可以通过BillingStatement的makeStatement方法测得,也可以通过InventoryControl的run方法所影响到的变量测得,但问题是不再存在一个单一的拦截点了。不过,合起来看的话,run和makeStatement这两个方法是可以被看作汇点的:它们加起来也只是两个方法而已,比起我们要进行的修改(需触及8个方法/变量),这还算是比较经济的。如果我们在这两个方法处编写测试,就能覆盖大量的修改工作。

汇点

汇点是影响结构图中的隘口和交通要冲,在汇点处编写测试的好处就是,只需针对少数几个方法编写测试,就能够达到探测大量其他方法的改动的目的。

图12-7  账单系统的全景图

对于某些代码基来说,在其中寻找一组修改的汇点是件相当容易的事,但也有很多时候几乎是不可能找到的。某个类或方法可能会直接影响一大堆东西,于是以它为中心延展出来的影响结构图看起来可能就会相当复杂。这时候该怎么办呢?办法之一便是重新考量我们的修改点。问问自己是不是太“贪”了,考虑能否一次仅为其中一两个修改点寻找汇点。最后,要是实在没法找出汇点的话,那就按就近原则直接给每个修改编写测试吧。

寻找汇点的另一个办法就是找出方法或类的被使用方式之间的共同之处(第11章中介绍了影响结构图)。例如,某个方法或变量可能会有三个用户,但这并不就意味着这三个用户使用它的方式各不相同。假设我们想对上例中的Item类的needsReorder方法作一点重构。我没有展示代码,但只要画出影响结构图我们就能看出,InventoryControl的run和BillingStatement的makeStatement这两个方法构成一个汇点,但这个汇点已经无法再缩小了。然而一般而言,能否只为这些类当中的一个编写测试呢?对此关键的一个问题就是:“如果破坏该方法,在那个地方能否感知到?”答案取决于该方法是怎样被使用的。如果它在一组对象上的使用方式都是一样的,则只需在其中一处测试即可。建议你跟你的同事一起试试这一分析过程吧。

查看所有评论(0)条】

最近评论



正在载入评论列表...
热点评论