前言
相信许多读者都听过「可测试性」,甚至被它搞的要死要活的,还觉得根本是莫名其妙,徒劳无功。今天这篇文章,主要要讲的是对象的相依性,以及对象之间直接相依,会带来什么问题。为了避免发生因相依性而导致设计与测试上的问题,本文会清楚地说明该如何隔绝对象的相依性。最后会说明如何通过简单的 stub 对象来进行测试,而不必相依于production code 中执行时所实际相依的对象。补充的部分,更是我觉得测试所能带来的庞大优点,怎么验证对象设计的好坏,让测试告诉你。
什么是相依性
假设现在有一个 Validation 的服务,要针对用户输入的 id 与密码进行验证。Validation 的 CheckAuthentication 方法的商业逻辑如下:
- 根据 id,取得存在数据源中的密码(仅存放经过 hash 运算后的结果)。
- 根据传入的密码,进行 hash 运算。
- 比对数据源回传的密码,与输入密码经过哈希运算的结果,是否吻合。
简单的程序代码如下(AccountDao与Hash的内容不是重点,为节省篇幅就先省略):
1 using System; 2 3 public class Validation 4 { 5 public bool CheckAuthentication(string id, string password) 6 { 7 // 取得数据库中,id对应的密码 8 AccountDao dao = new AccountDao(); 9 var passwordByDao = dao.GetPassword(id); 11 // 针对传入的password,进行hash运算 12 Hash hash = new Hash(); 13 var hashResult = hash.GetHashResult(password); 15 // 对比hash后的密码,与数据库中的密码是否吻合 16 return passwordByDao == hashResult; 17 } 18 } 19 20 public class AccountDao 21 { 22 internal string GetPassword(string id) 23 { 24 //连接DB 25 throw new NotImplementedException(); 26 } 27 } 28 29 public class Hash 30 { 31 internal string GetHashResult(string passwordByDao) 32 { 33 //使用SHA512 34 throw new NotImplementedException(); 35 } 36 }
先将职责分离,所以取得数据是通过AccountDao对象,Hash运算则通过Hash对象。
一切都很合理吧。那么,这样会有什么问题?
相依性的问题
再来看一次,CheckAuthentication方法商业逻辑,其实只是为了取得密码、取得hash结果、比对是否相同,三个步骤而已。但在面向对象的设计,要满足单一职责原则,所以将不同的职责,交由不同的对象负责,再通过对象之间的互动来满足用户需求。
但是,对Validation的CheckAuthentication方法来说,其实根本就不管、不在乎AccountDao以及Hash对象,因为那不在它的商业逻辑中。
但却为了取得密码,而直接初始化AccountDao对象,为了取得hash结果,而直接初始化Hash对象。所以,Validation对象便与AccountDao对象以及Hash对象直接相依。其类别关系如下图所示:
直接相依会有什么问题呢?
单元测试的角度
就单元测试的角度来说,当想要测试Validation的CheckAuthentication方法是否符合预期时,会发现要单独测试Validation对象,是件不可能的事。
因为Validation对象直接相依于其他对象。如同前面文章提到,我们为CheckAuthentication建立单元测试,程序代码如下:
[TestMethod()] public void CheckAuthenticationTest() { Validation target = new Validation(); // TODO: 初始化为适当值 string id = string.Empty; // TODO: 初始化为适当值 string password = string.Empty; // TODO:初始化为适当值 bool expected = false; // TODO: 初始化为适当值 bool actual; actual = target.CheckAuthentication(id, password); Assert.AreEqual(expected, actual); Assert.Inconclusive("验证这个测试方法的正确性。"); }
不论怎么arrange,当呼叫Validation对象的CheckAuthentication方法时,就肯定会使用AccountDao的GetPassword方法,进而联机至DB,取得对应的密码数据。
还记得我们对单元测试的定义与原则吗?单元测试必须与外部环境、类别、资源、服务独立,而不能直接相依。这样才是单纯的测试目标对象本身的逻辑是否符合预期。
而且单元测试需要运行相当快速,倘若单元测试还需要数据库的资源,那么代表执行单元测试,还需要设定好数据库联机或外部服务设定,并且执行肯定要花些时间。这,其实就是属于整合测试,而非单元测试。
弹性设计的角度
除了测试程序的角度以外,直接相依其他对象在设计上,有什么问题?希望各位读者,读这系列文章时,可以把这句话记在心理:测试程序就是在模拟外部使用,可能是用户的使用,也可能是外部对象的使用情况。
所以,当我们用测试程序会碰到直接相依造成的问题,也意味着这样的 production code ,当在使用 Validation 对象时,就是直接相依于 AccountDao 与 Hash 对象。当需求变动时,例如数据源由数据库改为读 csv 档,那么要不然就是新写一个 AccountFileDao 对象,并修改 Validation 对象的内容。或是直接把 AccountDao 读取数据库的内容,改写成读 csv 档案的内容。
这两种修改,都违背了开放封闭原则(Open Close Principle, OCP),也就代表对象的耦合性过高,当需求异动时,无法轻易的扩充与转换。当直接改变对象中 context 内容,则代表对象不够稳固。而在软件开发过程中,需求变动是一件正常且频繁的情况。
就像以前是通过软盘来存放文件,接下来 CD, 随身碟, DVD, 蓝光 DVD, 甚至云端硬盘,倘若我们将备份服务的方法内容中,直接写死存取软盘,接着时代变迁,技术改变,我们得一直去修改原本的程序内容,还不能保证结果是否符合预期。甚至于原本的测试程序都需要跟着修改,因为内容与需求已经改变,而相对的影响到了原本对象商业逻辑的变化。
因此,在设计上不论是为了弹性或是可测试性,我们都应该避免让对象直接相依。(试想一下,实务系统上,对象相依可不只是两层关系而已。A 相依于 B,而 B 相依于 C 与 D,这就代表着 A 相依于 B, C, D 三个对象。相依关系将会爆炸性的复杂)
如何隔离对象之间的相依性
直接相依的问题原因在于,初始化相依对象的动作,是写在目标对象的内容中,无法由外部来决定这个相依对象的转换。所以隔离相依性的重点很简单,别直接在目标对象中初始化相依对象。怎么作呢?
首先,为了扩充性,所以定义出接口,让目标对象仅相依于接口,这也是面向接口编程方式。如同抽象地描述CheckAuthentication方法的商业逻辑,程序代码改写成下面方式:
1 public interface IAccountDao 2 { 3 string GetPassword(string id); 4 } 5 6 public interface IHash 7 { 8 string GetHashResult(string password); 9 } 10 11 public class AccountDao : IAccountDao 12 { 13 public string GetPassword(string id) 14 { 15 throw new NotImplementedException(); 16 } 17 } 18 19 public class Hash : IHash 20 { 21 public string GetHashResult(string password) 22 { 23 throw new NotImplementedException(); 24 } 25 } 26 27 public class Validation 28 { 29 private IAccountDao _accountDao; 30 private IHash _hash; 31 32 public Validation(IAccountDao dao, IHash hash) 33 { 34 this._accountDao = dao; 35 this._hash = hash; 36 } 37 38 public bool CheckAuthentication(string id, string password) 39 { 40 // 取得数据库中,id对应的密码 41 var passwordByDao = this._accountDao.GetPassword(id); 42 // 针对传入的password,进行hash运算 43 var hashResult = this._hash.GetHashResult(password); 44 // 对比hash后的密码,与数据库中的密码是否吻合 45 return passwordByDao == hashResult; 46 } 47 }
上面可以看到,原本直接相依的对象,现在都通过相依于接口。而 CheckAuthentication 逻辑更加清楚了,如同批注所述:
取得数据中 id 对应的密码 (数据怎么来的,不必关注)
针对 password 进行 hash (怎么 hash 的,不必关注)
针对 hash 结果与数据中存放的密码比对,回传比对结果
类别相依关系如下所示:
这就是面向接口的设计。而原本初始化相依对象的动作,通过目标对象的公开构造函数,可由外部传入接口所属的实例,也就是在目标对象外初始化完成后传入。
把初始化动作,由原本目标对象内,转移到目标对象之外,称作「控制反转」,也就是 IoC。
把依赖的对象,通过目标对象公开构造函数,交给外部来决定,称作「依赖注入」,也就是 DI。
而 IoC 跟 DI,其实就是同一件事:让外部决定目标对象的相依对象。
原文可參考 Martin Fowler 的文章:Inversion of Control Containers and the Dependency Injection pattern
As a result I think we need a more specific name for this pattern. Inversion of Control is too generic a term, and thus people find it confusing. As a result with a lot of discussion with various IoC advocates we settled on the name Dependency Injection.
如此一来,目标对象就可以专注于自身的商业逻辑,而不直接相依于任何实体对象,仅相依于接口。而这也是目标对象的扩充点,或是接缝,提供了未来实作新的对象,来进行扩充或转换相依对象模块,而不必修改到目标对象的 context 内容。
通过 IoC 的方式,来隔绝对象之间的相依性,也带来了上述提到的扩充点,这其实就是最基本的可测试性。下一段我们将来介绍,为什么这样的设计,可以提供可测试性。
如何进行测试
针对刚刚用 IoC 方式设计的目标对象,通过 VS2013 建立单元测试时,测试程序代码如下:
[TestMethod()] public void CheckAuthenticationTest() { IAccountDao accountDao = null;// TODO: 初始化为合适的值 Hash hash = null;// TODO: 初始化为合适的值 Validation target = new Validation(accountDao, hash); string id = string.Empty; // TODO: 初始化为合适的值 string password = string.Empty;//TODO: 初始化为合适的值 bool expected = false;// TODO: 初始化为合适的值 bool actual; actual = target.CheckAuthentication(id, password); Assert.AreEqual(expected, actual); Assert.Inconclusive("验证这个测试的正确性。"); }
看到了吗?Visual Studio会自动帮我们把构造函数需要的参数也都列出来。
为什么这样的设计方式,就可以帮助我们只独立的测试Validation的CheckAuthentication方法呢?
接下来要用到「手动设计」的stub。
大家回过头看一下,CheckAuthentication方法中,使用到了IAccountDao的GetPassword方法,取得id对应密码。也使用到了IHash的GetHashResult方法,取得hash运算结果。接着才是比对两者是否相同。
通过接口可进行扩充,多态和重载(如果是继承父类或抽象类,而非实作接口时)的特性,我们这边举IAccountDao为例,建立一个StubAccountDao的类型,来实现IAccountDao。并且,在GetPassword方法中,不管传入参数为何,都固定回传"Hello World",代表Dao回来的密码。程序代码如下所示:
public class StubAccountDao : IAccountDao { public string GetPassword(string id) { return "Hello World"; } }
接着用同样的方式,让 StubHash 的 GetHashResult,也回传 "Hello World",代表 hash 后的结果。程序代码如下:
public class StubHash : IHash { public string GetHashResult(string password) { return "Hello World"; } }
聪明的读者朋友们,应该知道接下来就是来写单元测试的 3A pattern,单元测试程序代码如下:
[TestMethod()] public void CheckAuthenticationTest() { //arrange // 初始化StubAccountDao,来当作IAccountDao的执行对象 IAccountDao dao = new StubAccountDao(); // 初始化StubHash,来当作IStubHash的执行对象 IHash hash = new StubHash(); Validation target = new Validation(dao, hash); string id = "随便写"; string password = "随便写"; bool expected = true; bool actual; //act actual = target.CheckAuthentication(id, password); //assert Assert.AreEqual(expected, actual); }
如此一来,就可以让我们的测试目标对象:Validation,不直接相依于 AccountDao 与 Hash 对象,通过 stub 对象来模拟,以验证 Validation 对象本身的 CheckAuthentication 方法逻辑,是否符合预期。
测试程序使用 Stub 对象,其类别图如下所示:
延伸思考
给各位读者出个作业,倘若今天 CheckAuthentication 方法中,相依的是一个随机数生成器的对象,验证逻辑则是检查「输入的密码」是否等于「数据存放的密码」+「随机数生成器」。这样的程序代码,要怎么撰写?撰写完,如何测试?倘若没有通过 IoC 与 Stub object 的方式,是否仍然可以测试呢?该怎么模拟或猜到这一次测试执行时,随机数为多少?
这是一个标准的 RSA token 用来作登入的例子,也是我最常拿来说明 IoC 与 Stub 的例子。读者朋友自己动手写一下这个简单的 function,并尝试去测试他,就能体会到这样设计的好处以及所谓的可测试性。
结论
大家如果把「可测试性」的目的,当作只是为了测试而导致要花费这么多功夫,那么很容易就会变成事倍功半。
往往 developer 会认为:「为什么我要为了测试,而多花这么多功夫,即使我不写测试,程序的执行结果仍然是对的啊,又没有错!」
但,其实这样设计的重点是在于设计的弹性、扩充性。
以文章例子来说,当数据源的改变,或是Hash算法模块的改变时,都不需要更改到 Validation 内的程序代码,因为这一份商业逻辑是不变的。也不需要更改到原本的 AccountDao,因为它的职责和内容也没有改变。
要改变的是:让「Validation 通过新的数据源取值,通过新的 Hash 算法取得 hash 运算结果」。所以,只需要改变注入的相依对象即可。
而这样的方式,就是单元测试中,用来独立测试目标对象的方式,所以又被称为对象的可测试性。
这也是为什么,可以拿可测试性来确认,对象的设计是否具备低耦合的特性,而低耦合是一个良好设计的指针之一。
但写程序的人一定都要知道一个逻辑:「程序若不具备可测试性,代表其对象设计不够良好。但程序具备可测试性,并不太代表对象设计就一定良好。」
补充
想请读者再静下心思考一下,倘若今天的设计,是由需求产生测试案例,由测试程序产生目标对象。我们只关注在目标对象,如何满足测试案例,也就是使用需求。目标对象以外的职责,都交给外部实作。以这 IoC 的例子,只需要把非目标对象职责,都抽象地通过接口来互动,根本不需思考接口背后如何实作。
那么,要撰写 Validation 对象的程序代码,跟原本没通过接口所撰写的程序代码,哪一个比较短,比较轻松?
以笔者自己的经验,当对这样的 TDD 方式很熟悉时,一有测试案例,撰写好测试程序后,完成目标对象行为的时间将相当简短。因为这次的目标与设计范围,限定在只需要完成这一个目标对象,这一个测试案例所需行为的职责,其他繁复的实作都交给接口背后的对象去处理。
这就是面向接口的设计,也就是抽象地设计对象,抽象地设计可以使得对象更加稳定、稳固,不因外在变化而受影响。
而因为 TDD,开发人员会发现,目标对象的设计,相依性将不会太多,也不会太少,只会刚刚好。
因为相依太多,测试程序会很难写,也代表目标对象复杂,职责切太细、剁太碎,导致要完成一个功能,可能要十几个对象的组合方能完成。是否十几个对象,可以再抽象与凝聚一些职责,改成相依三个对象,就能满足这项测试案例呢?这是通过测试程序来验证职责是否被切得太零碎。
相依太少,倒不是太大问题。但因为与其他对象直接相依,而导致目标对象行为职责过肥,要测试一个行为,就需准备相当多的测试案例,方能满足所有执行路径。这时候就是可以通过测试程序,来验证对象设计是否符合单一职责原则。
而可测试性,则是通过测试程序来验证对象的设计是否低耦合,是否具备良好的扩充与可转换变化的设计。
如果只是把测试程序、测试案例、可测试性,当作多一个心安的程序结果,那就真的太可惜了。因为那个小小的好处,只是整个宝藏的冰山一角。当体会到这整份宝藏,自然就会觉得撰写测试程序的 CP 值,高的吓人!
备注:这个系列是我毕业后时隔一年重新开始进入开发行业后对大拿们的博文摘要整理进行学习对自我的各个欠缺的方面进行充电记录博客的过程,非原创,特此感谢91 等前辈