TDD学习笔记【五】一隔绝相依性的方式与特性

前言

在上一篇文章中,提到了如何通过 IoC 的设计,以及 Stub Object 的方式,来独立测试目标对象。

这一篇文章,则要说明有哪些设计对象的方式,可以让测试或需求变更时,更容易转换。

并说明这些方式有哪些特性,供读者朋友们在设计时,可以选择适合自己情境的方式来使用。

需求说明

当调用目标对象的方法时,期望目标对象的内容可以不必关注相依于哪些实体对象,而只需要依赖于某个接口,通过这样的方式来达到设计的弹性与可独立测试性。

那么,有哪一些方式可以达到这样的目的呢?

构造函数(constructor)

描述:

上一篇文章范例所使用的方式,将对象的相依接口,拉到公开的构造函数,供外部对象使用时,可自行组合目标对象的依赖对象实体。

public class Validation
{
    private IAccountDao _accountDao;
    private IHash _hash;

    public Validation(IAccountDao dao,IHash hash)
    {
        this._accountDao = dao;
        this._hash = hash;
    }

    bool CheckAuthentication(string id,string password)
    {
        var passwordByDao = this._accountDao.GetPassword(id);
        var hashResult = this._hash.GetHashResult(password);

        return passwordByDao == hashResult;
    }
}

好处:

有许多 DI framework 支持 Autowiring。

Autowiring is an automatic detection of dependency injection points.

这里的 dependency injection points 在这例子,指的就是构造函数。以 Unity 为例,在 UnityContainer 取得目标对象时,会自动寻找目标对象参数最多的构造函数。并针对每一个参数的类型,继续在 UnityContainer 中寻找对应的实体对象,直到目标对象组合完毕,回传一个完整的目标对象。

由构造函数传入依赖接口的实体对象,是一个很通用的方式。因此在结合许多常见的 DI framework,不需要再额外处理。

顾虑点:

当对象越来越复杂时,构造函数也会趋于复杂。倘若没有 DI framework 的辅助,则使用对象上,面对许多 overload 的构造函数,或是一个构造函数的参数有好几个,会造成使用目标对象上的困难与疑惑。若没有好好进行 refactoring,也可能因此而埋藏许多 bad smell。

另外,倘若是许多构造函数,也可能造成要调用 A 方法时,应选用 A 对应的构造函数,但在使用对象上,可能会用错构造函数而不自知,若方法中没有正确的防呆,则可能出现错误。(请搭配单元测试的测试案例来辅助)

最后,与原本直接依赖的程序代码相比较,目标对象的相依对象因此暴露出来,交由外部决定,而丧失了一点封装的意味。而使用端也不一定知道,要取用此对象时,应该要注入哪些相依对象。(请使用 Repository Pattern 或 DI framework 来辅助)

公开属性(public setter property)

其实公开属性与公开构造函数非常类似,通过 public 的 property(property类型仍为 interface),让外部在使用目标对象时,可先 setting 目标对象的相依对象,接着才调用其方法。

而公开属性通常只会将 setter 公开给外部设定,getter 则设定为 private。原因很简单,外部只需设定,而不需取用。就像公开构造函数,在使用对象之前先传入初始化对象必备的信息,但目标对象可能将这些信息,存放在 private 的 filed 或 property 中,而不需再提供给外部使用。

程序代码如下:

bool CheckAuthentication(IAccountDao accountDao,IHash hash,255); line-height:1.5!important">string password)
{
    var passwordByDao = accountDao.GetPassword(id);
    var hashResult = hash.GetHashResult(password);

    return passwordByDao == hashResult;
}

好处:

不必再担心要先初始化哪些 property,或调用哪一个构造函数。当要调用某一个方法,其相依的对象,就是得通过参数来给定。基本上也不太需要担心使用上造成困扰或迷惑。

顾虑点:

最大的问题,在于方法签名上的不稳定性。当需求异动,该方法需要额外相依于其他对象时,方法签名可能会被迫改变。而方法签章是面向对象设计上,最需要稳定的条件之一。以面向对象、接口导向设计来说,当多态对象方法签名不一致时,向来是个大问题。

另外,方法的参数过多,在使用上也会造成困扰。而且会影响到 legacy code 的调用端,需要全面跟着异动,才能编译成功。

而且通过参数的方式,DI framework 支持度较低。

但这不代表,就不能在方法参数中,传入相依对象。在 .net framework 还是有许多这样的设计,例如:List<T>.Sort 方法 (IComparer<T>)这样的设计方式,通常要确保该方法相依相当明确、稳固,避免上述问题。

by the way,这个方式是可以与其他方式共存的,所以在设计对象时,可衡量搭配使用。

可覆写的保护方法(protected virtual function)

描述:

前面的三种方式,基本上都对外暴露了原本可能不需要对外暴露的细节。倘若,现在的需求是眼前的程序要进行测试,但又不希望影响或修改使用端的程序,那么该怎么作呢?除了可以透过公开属性设定,当为空时给予默认值的方式,来维持原本对象的内部程序逻辑以外,还有一个相当简单的方式,甚至有些情况不需要透过接口设计,就可以进行测试。先来看看原本直接依赖对象,无法测试的程序,程序代码如下:

var accountDao = new AccountDao();
        var passwordByDao = accountDao.GetPassword(id);

        var hash = new Hash();
        var hashResult = hash.GetHashResult(password);

        return passwordByDao == hashResult;
    }
}

接下来,我们只用简单的面向对象概念:继承、重写,就可以对 Validation 对象的 CheckAuthentication 方法进行测试。不相信吗?继续往下看下去。

首先,一定要记得,把 new 对象的动作抽离高层抽象的 context(可以透过 extract method 的方式抽离)程序代码如下

var accountDao = GetAccountDao(); var hash = GetHash(); return passwordByDao == hashResult; } private Hash GetHash() { return hash; } private AccountDao GetAccountDao() { return accountDao; } }

没什么改变,对吧?

接下来,将两个 new 对象的方法,声明为protected virtual,代表子类别可以继承与重写该方法。程序代码如下:

protected virtual Hash GetHash() { new Hash(); return hash; } virtual AccountDao GetAccountDao() { new AccountDao(); return accountDao; }

另外,将要使用到 Hash 与 AccountDao 的方法,也要声明为virtual程序代码如下:

class AccountDao { virtual string GetPassword(string id) { throw new NotImplementedException(); } } class Hash { string GetHashResult(new NotImplementedException(); } }

到这里,都不影响外部使用目标对象的行为,我们只是在重构对象的内部方法罢了。事实上,我们可测试性的动作也准备完毕了。(当然,建议还是要依赖于接口,实现接口要顾虑的点,比继承类要轻松的多)

接下来把目光切到测试程序,该如何对 CheckAuthentication 方法进行测试。

首先,将上一篇文章的 StubHash 改为继承自 Hash,StubAccountDao 改为继承自 AccountDao,并将原本 public 的方法,加上override关键词,重写其父类方法内容。程序代码如下:

class StubAccountDao : AccountDao { override return "Hello World"; } } class StubHash : Hash { "; } }

不难,对吧。接下来,建立一个 MyValidation 的 class,继承自 Validation。并重写 GetAccountDao() 与 GetHash(),使其回传 Stub Object。程序代码如下:

class MyValidation : Validation { override AccountDao GetAccountDao() { return new StubAccountDao(); } override Hash GetHash() { new StubHash(); } }

也不难,对吧。接下来,来设计单元测试,程序代码如下:

[TestMethod()]
void CheckAuthenticationTest()
{
    Validation target = new MyValidation();

    string id = "id随便";
    string password = "密码也随便";

    bool expected = true;

    bool actual;
    actual = target.CheckAuthentication(id,password);

    Assert.AreEqual(expected,actual);
}

原本初始化的测试目标为 Validation 对象,现在则为 MyValidation 对象。里面唯一不同的部分,只有重写的方法内容,其余 MyValidation 就等同于 Validation。Is-A的关系)调试测试一下,就可以确认,程序代码就跟之前使用 IoC 的方式执行没有太大的差异。

好处:

这个方式最大的好处,是完全不影响外部使用对象的方式。仅透过 protected 与 virtual 来对继承链开放扩充的功能,并且透过这样的方式,就使得原本直接相依而导致无法测试的问题,获得解套。

顾虑点:

这是为了测试,且面对 legacy code 所使用的方式,而不是良好的面向对象设计的方式。IoC 的用意在于面向借口与扩充点的弹性,所以当可测试之后,倘若重构影响范围不大,建议读者朋友还是要将对象改依赖于接口,通过IoC 的方式来设计对象。

by the way,同样为了解决直接相依对象,甚至相依于 static 方法、.net framework 本身的对象(如 DateTime.Now)而导致无法测试的问题,还有另外一个方式,称为 fake object。这在后面的文章,会再进行较为详尽的介绍。

结论

以上几种用来测试的方式,希望对各位读者在不同情境下的设计,可以有所帮助。

而许多延伸的议题,在这系列文章并不会多谈,但在实务应用面上,却是相当重要的配套措施。例如一再提到的 DI framework,Repository Pattern,以及通过测试程序来说明对象的使用方式,请读者在现实设计系统时,务必了解这些东西如何让系统设计更加完整。

下一篇文章,将介绍怎么样可以避免每次手工敲打这么啰唆的 stub 对象,怎么针对 static 或 .net framework 本身的对象进行隔离,怎么针对对象与相依接口互动的情况进行测试。

版权声明:本文内容由互联网用户自发贡献,该文观点与技术仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 dio@foxmail.com 举报,一经查实,本站将立刻删除。

相关推荐


什么是设计模式一套被反复使用、多数人知晓的、经过分类编目的、代码 设计经验 的总结;使用设计模式是为了 可重用 代码、让代码 更容易 被他人理解、保证代码 可靠性;设计模式使代码编制  真正工程化;设计模式使软件工程的 基石脉络, 如同大厦的结构一样;并不直接用来完成代码的编写,而是 描述 在各种不同情况下,要怎么解决问题的一种方案;能使不稳定依赖于相对稳定、具体依赖于相对抽象,避免引
单一职责原则定义(Single Responsibility Principle,SRP)一个对象应该只包含 单一的职责,并且该职责被完整地封装在一个类中。Every  Object should have  a single responsibility, and that responsibility should be entirely encapsulated by t
动态代理和CGLib代理分不清吗,看看这篇文章,写的非常好,强烈推荐。原文截图*************************************************************************************************************************原文文本************
适配器模式将一个类的接口转换成客户期望的另一个接口,使得原本接口不兼容的类可以相互合作。
策略模式定义了一系列算法族,并封装在类中,它们之间可以互相替换,此模式让算法的变化独立于使用算法的客户。
设计模式讲的是如何编写可扩展、可维护、可读的高质量代码,它是针对软件开发中经常遇到的一些设计问题,总结出来的一套通用的解决方案。
模板方法模式在一个方法中定义一个算法的骨架,而将一些步骤延迟到子类中,使得子类可以在不改变算法结构的情况下,重新定义算法中的某些步骤。
迭代器模式提供了一种方法,用于遍历集合对象中的元素,而又不暴露其内部的细节。
外观模式又叫门面模式,它提供了一个统一的(高层)接口,用来访问子系统中的一群接口,使得子系统更容易使用。
单例模式(Singleton Design Pattern)保证一个类只能有一个实例,并提供一个全局访问点。
组合模式可以将对象组合成树形结构来表示“整体-部分”的层次结构,使得客户可以用一致的方式处理个别对象和对象组合。
装饰者模式能够更灵活的,动态的给对象添加其它功能,而不需要修改任何现有的底层代码。
观察者模式(Observer Design Pattern)定义了对象之间的一对多依赖,当对象状态改变的时候,所有依赖者都会自动收到通知。
代理模式为对象提供一个代理,来控制对该对象的访问。代理模式在不改变原始类代码的情况下,通过引入代理类来给原始类附加功能。
工厂模式(Factory Design Pattern)可细分为三种,分别是简单工厂,工厂方法和抽象工厂,它们都是为了更好的创建对象。
状态模式允许对象在内部状态改变时,改变它的行为,对象看起来好像改变了它的类。
命令模式将请求封装为对象,能够支持请求的排队执行、记录日志、撤销等功能。
备忘录模式(Memento Pattern)保存一个对象的某个状态,以便在适当的时候恢复对象。备忘录模式属于行为型模式。 基本介绍 **意图:**在不破坏封装性的前提下,捕获一个对象的内部状态,并在该
顾名思义,责任链模式(Chain of Responsibility Pattern)为请求创建了一个接收者对象的链。这种模式给予请求的类型,对请求的发送者和接收者进行解耦。这种类型的设计模式属于行为
享元模式(Flyweight Pattern)(轻量级)(共享元素)主要用于减少创建对象的数量,以减少内存占用和提高性能。这种类型的设计模式属于结构型模式,它提供了减少对象数量从而改善应用所需的对象结