【有效的单元测试】读书笔记第7章 可测的设计

来源:互联网 发布:厚街淘宝客服招聘 编辑:程序博客网 时间:2024/05/15 09:39

         java以语言结构的饿形式提供了答案,而程序员的任务就是找出对应的问题,以及用何种语言结构来解决何种问题。设计也一样。我们会学到各种解决方案,但是光知道解决方案是不够的,我么需要学会识别它们所解决的问题。本章主要识别常见的妨碍设计决策的可测性问题。

1、可测的设计

       可测的设计,其基本价值主张是能够更好的测试代码。更具体的说,对于实例化各个类、替换实现、模拟不同场景、调用特定执行路径,可测的设计使得他们更容易。可测性不是描述代码是否能被测试,而是指软件容易被测试。指的是程序员应该能够轻而易举地在单元测试中设置场景。设计越不可测,程序员编写测试越艰难。

       第一,模块化设计。设计由不同模块组合而成,每一个都服务于设计中的一个特定目的,正是这种性质使得设计变得模块化。通过将程序的整体功能分解为不同的责任,并指派给单独的组件,我们最终得到一个非常灵活的设计。

       每个单独的模块包含了满足自身功能所需的一切(自包含),通过将这些分离的模块组合成整体设计,我们引入了各种接缝,并从中构建出灵活性。这种编程风格强调模块之间的依赖应尽量少(松耦合)。

       由小模块来构建软件有助于大产品团队中队员之间的协作,因为产品的功能性变化往往更多的被隔离在代码的特定部分。这遵循了模块化设计的特征,系统被分离为功能元素,其相应的责任承载于特定的功能和能力上。

       这种受到模块化设计启发的结构能使系统逐渐扩展,只要模块本身足够的自包含和松耦合,那么仅仅需要插入新模块就能改变或新增功能,而功能关注点分散在代码中。这也直接有助于可测性,因为模块化设计的属性也就是使代码可测的属性。

        第二,SOLID设计原则。面向对象原则的好处在于,它们与可测性紧密结合。保持你的代码遵循SOLID设计原则,你就更有可能得到一个模块化的设计。

        a)单一职责原则(single responsibility principle,SRP),是指“类发生变化的原因应该只有一个”。也就是说,类要小而专注,并具有高内聚。方法也应该只有一个变化的原因。遵循SRP的代码容易理解和处理,反过来也容易测试,因为编写测试本质上是在指定期望的行为,表达对代码要解决的问题的理解。

        b)开闭原则(open-closed principle,OCP),类应当“对扩展开发,对修改关闭”。其实就是说在不改变源代码的情况下去改变类的行为,例如替换不同策略。类将具体责任委托给其他对象,可以使得测试在需要模拟具体场景时替换一个测试替身。

       c)里氏替换原则(liskov substitution principle,LSP),“子类应该能替换掉父类”。遵循LSP的类继承关系通过使用契约测试(contract test)来提高可测性---为一个接口编写的测试,可以用于该接口的所有实现。

       d)接口隔离原则(interface segregation principle,ISP)“许多具体的客户接口要好过一个宽泛的接口”。简言之,你要保持小而专注。小接口改善可测性的方式,就是使其更容易编写测试和使用测试替身。例如,一个测试可能希望对协作者A打桩,假冒协作者B,将协作者C替换为间谍。如果每个协作者拥有自己的小接口的话,就可以直接去实现测试替身。

       e)依赖反转原则(dependency inversion principle,DIP),“应该依赖于抽象,而不是细节”。极端的说,DIP认为类不应该实例化自己的协作者,而是传入接口。依赖注入是可测性的福音,因为不仅可以替换协作者,而且可以节约工作量。因为测试使用代码的方式,和生产环境一样。

       第三,上下文中的模块化设计。“不仅需要确保你的系统是按照模块化来设计的。同时也要认识到,无论系统多么巨大和美好,应该总是将其设计成另一个更大系统的一部分。”

       第四,以测试驱动出模块化设计。用测试来驱动代码,是借鉴模块化设计的最快手段。实现代码前先写测试,本来就是一种确保你从客户视角来塑造代码的方式。也就是说你得到的设计更加有可能满足目标,而且设计的可测性是不成问题的。

        鼓励模块化设计不仅仅是TDD中测试先行的概念。TDD实践者也频繁地重构代码,持续地分解过大的方法,引入更好的抽象,并消除重复。

2、可测性的问题

        程序员在费力的编写测试时,通常会遇到一些障碍。通常属于两类:访问受限,以及无法替换实现的某些部分。

      (1)无法实例化某个类

        程序员编写测试时,往往要做的第一件事情就是实例化某些对象。有时候无法实例化测试对象,但更常见的是无法实例化那个要传入被测对象的协作者。一个原因是,你依赖于没有考虑可测性的第三方库,还可能是因为你曾过于保守的处理可见性修饰符;还有一个原因是,静态代码的初始化块,它会假设你运行在成熟的生产环境中,那会导致在运行测试时出现无法预期的异常。例如:

public class DocumentRepository {private static final String API_KEY = "d869db7fe62fb07c";private static String sessionToken;static {String serverHostName = System.getenv("ACL_SERVER_HOST");SessionClient api = new SessionClientImpl(serverHostName);sessionToken = api.openSession(API_KEY);}public DocumentRepository() {...}}
     在笔记本电脑上运行测试时,可能会因为没有设置ACL_SERVER_HOST环境变量而碰到NullPointerException,也可能会因为不能穿过防火墙连接到真实服务器而碰到UnknownHostException。真正的问题在于,依赖是通过硬编码在静态初始化代码块中,你无能无力。

      (2)无法调用某个方法。

       即使所有对象都实例化完毕,测试还会在执行所需的交互或场景时熄火。例如调用一个private方法。另一种是无法找出方法期望接受的参数,例如Map,你不知道应当包含的内容。这时候有三种办法,一是重构设计,二是不测试,三是反射API绕过可见性修饰符。

      (3)无法观察到输出

       如果是void方法,不返回任何值;或者协作者与被测方法紧紧的缠绕在一起,无法用测试替身来替换;或者启动了线程,测试代码无法拦截线程的执行。

       这与其他主要的可测性问题有千丝万缕的联系:难以有选择替换实现的某些部分。

      (4)无法替换某个协作者

       如果生产代码包含了硬编码的new Collaborator(),那么就不能替换掉协作者。或者是有这样一种结构---方法链,getCollaborator().doStuff().askForStuff().doMoreStuff(),虽然能够替换掉协作者,但是意味着测试替身需要返回另一个测试替身,而它又要返回另一个测试替身,会带来不必要的艰难。

       (5)无法覆盖某个方法

         有时候不想替换协作者,而是被测对象的一部分。编译器不允许覆盖一个final方法,或者private、static方法。引入重量级的反射和字节码操作,都不是我们想要的。
3、可测的设计的指南

       (1)避免复杂的私有方法

        没有什么好办法来测试private方法,应该尽量避免直接测试private方法。

        不是不测,而是尽量避免。只要那些方法短小好记,并有助于public方法更容易阅读,那么仅通过public方法来测试就没有问题了。

         当private方法不那么直接了当,并且你觉得需要为之写测试时,应该重构代码,将private方法封装的逻辑转移到另一个对象中,成为public方法。

        (2)避免final方法

         很少有程序需要final方法,而你其实也并不需要它。将方法声明为final的主要目的是确保它不会被子类覆盖。“将类声明为final,可以阻止恶意的子类去添加终结器(finalizer)、复制和重载随机方法。”

          尽管在上下文中成立,但是不见得你就应该使用final修饰符。该逻辑有两个问题:第一,那些潜在的子类可能就是你身边的人写的;第二,反射API可以用于去除final修饰符。实际上,将方法声明为final的唯一合理情形就是,当你在运行期加载外部类的时候,或者你不信任同事(这有个更大的问题需要担心)。

         最大的问题在于:final关键字是否妨碍了你的测试。如果是的话,权衡一下糟糕的可测性带来的负担与使用final带来的好处,孰轻孰重。

         性能:由于final方法无法被覆盖,编译器可以内联inline该方法的实现来优化代码。但是“我不会纯粹因为性能原因而将方法或类声明为final,只有当你真的识别出性能问题之后,再去考虑它。”

        (3)避免static方法

          大多数static方法是不必要的。写这种方法,一是因为方法与特定实例无关,或者静态方法更容易进行全局访问。

          头号规则:对于某个方法,如果你预见到你将会在单元测试中为其打桩,那么就不要将其声明为static。

         (4)使用new时要当心

          关键字new是最常见的硬编码方式。如果你想在测试时为一个对象替换为测试替身,那么就不要用new在方法中实例化它,而应该作为参数被传入那个方法。

         (5)避免在构造函数中包含逻辑

           构造函数很难绕过去,因为子类的构造函数至少会触发超类至少一个的构造函数。因此,应当避免在构造函数中放置严重需要测试的代码或逻辑。更好的方式是,将所有代码抽取到可以被子类覆盖的protected方法中。

           总之,对于构造函数中的任何代码,确保你不会想要在单元测试中替换它们。如果发现了这种代码,最好将其移动到某个方法中,或者移动到某个你可以覆盖的方法中,或者参数对象中。

         (6)避免单例

          单例模式带来的烂代码耗费了软件工业数以百万级的美元。它曾是一种“确保类只有一个实例,并提供全局访问点”而设计出来得模式。在我看来,你根本不需要它。

          的确在有些情况下,你需要在运行期保留类的一个实例。但是单例模式往往也妨碍了测试区创建不同的变体。要想测试,得通过反射注入新实例,或者通过setter方法注入。

          最佳和更为可测的设计,是不强制唯一实例的单例,而是依赖于程序员的“我们只会在生产环境中实例化出一个对象”的约定。毕竟,如果你仍然需要防范猪一般的队友,那么有更大的麻烦在等着你。

         (7)组合优于继承。

          为了重用而继承,就好比杀鸡用牛刀。继承允许重用代码,但是也带来了严格的类继承关系,抑制了可测性。“继承的关键在于利用多态行为而非重用代码。成为一个类的子类,就不能再成为其他类的子类,将永远将构造函数固定在那个父类上,父类改变API时只能任其摆布,在编译期就已失去了变化的自由。而组合给了多种选择,可以重用不同的实现(本该重用父类方法的),可以在运行期改变主意。”

        (8)封装外部库

         不是所有人都像你一样擅长提出可测的设计。请谨慎地继承第三方外部库。从外部库继承更糟,因为你很难控制被继承的代码。记得自行设计一套测试友好的接口,用其将外部库包裹起来,以便容易的替换掉实际的实现。(将不可测的代码包裹在薄薄一层可测的代码之中)

        (9)避免服务查找

        大多数服务查找(比如调用静态方法来获取单例实例),是在看似干净的接口与可测性之间所做的糟糕权衡。接口只是看起来干净,因为那些无法明确作为构造函数参数的依赖,现在却隐藏在类中。在测试中替换那依赖从技术上讲不是不可能,但是需要更多的工作才能做到。

        

public class Assets {public Collection<Asset> search(String... keywords) {APIRequest searchRequest = createSearchRequestFrom(keywords);APICredentials credentials = Configuration.getCredentials();APIClient api = APIClient.getInstance(credentials);return api.search(searchRequest);}private APIRequest createSearchRequestFrom(String... keywords) {// omitted for brevity}}
写测试是这样:

@Testpublic void searchingByKeywords() {final String[] keywords = {"one", "two", "three"}final Collection<Asset> results = createListOfRandomAssets();APIClient.setInstance(new FakeAPIClient(keywords, results));Assets assets = new Assets();assertEquals(results, assets.search(keywords));}
       我们通过额外的步骤来配置服务查找提供者,使其在被请求时返回测试替身。尽管在测试代码中只有一行,但却暗示了在APIClient中存在好几行代码,它们仅仅用来修补可测性问题。

       现在换个方式作为对照,我们仅仅使用构造函数将测试替身直接注入APIClient。

@Testpublic void searchByOneKeyword() {final String[] keywords = {"one", "two", "three"}final Collection<Asset> results = createListOfRandomAssets();Assets assets = new Assets(new FakeAPIClient(keywords, results));assertEquals(results, assets.search(keywords));}
     代码减少了20%,并且APIClient也不再需要用于修补的setter方法。除了代码更加精简之外,明确的通过构造函数传递依赖,这种方式更加自然和直接地将我们的对象与其协作者联结在一起。


0 0