TDD学习笔记【四】--- 如何隔离相依性 - 基本的可测试性

前言

相信许多读者都听过「可测试性」,甚至被它搞的要死要活的,还觉得根本是莫名其妙,徒劳无功。今天这篇文章,主要要讲的是对象的相依性,以及对象之间直接相依,会带来什么问题。为了避免发生因相依性而导致设计与测试上的问题,本文会清楚地说明该如何隔绝对象的相依性。最后会说明如何通过简单的 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),它为相互依赖的组件提供抽象,将依赖(低层模块)对象的获得交给第三方(系统)来控制即依赖对象不在被依赖模块的类中直接通过new来获取

依赖注入(DI),它提供一种机制,将需要依赖(低层模块)对象的引用传递给被依赖(高层模块)对象。

把初始化动作,由原本目标对象内,转移到目标对象之外,称作「控制反转」,也就是 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 等前辈

时间: 2024-12-18 12:31:10

TDD学习笔记【四】--- 如何隔离相依性 - 基本的可测试性的相关文章

Caliburn.Micro学习笔记(四)----IHandle<T>实现多语言功能

Caliburn.Micro学习笔记(四)----IHandle<T>实现多语言功能 说一下IHandle<T>实现多语言功能 因为Caliburn.Micro是基于MvvM的UI与codebehind分离, binding可以是双向的所以我们想动态的实现多语言切换很是方便今天我做一个小demo给大家提供一个思路 先看一下效果 点击英文  变成英文状态点chinese就会变成中文                          源码的下载地址在文章的最下边 多语言用的是资源文件建

代码管理工具 --- git的学习笔记四《重新整理git(1)》

1.创建版本库 mkdir  创建目录 cd  地址,到该地址下 pwd 显示当前目录 1.创建目录 $ mkdir startGit $ cd startGit $ pwd 显示当前目录 或者cd到桌面,然后再创建目录 2.初始化版本库 $ git init 初始化仓库 提示信息:Initialized empty Git repository in /Users/xingzai/Desktop/startGit/.git/ 建立一个空的git仓库在/Users/xingzai/Desktop

Linux学习笔记四:Linux的文件搜索命令

1.文件搜索命令  which 语法:which [命令名称] 范例:$which ls  列出ls命令所在目录 [[email protected] ~]$ which ls alias ls='ls --color=auto' /bin/ls 另外一个命令:whereis [名称名称],也可以列出命令所在目录. [[email protected] ~]$ whereis ls ls: /bin/ls /usr/share/man/man1/ls.1.gz /usr/share/man/ma

小猪的数据结构学习笔记(四)

小猪的数据结构学习笔记(四) 线性表之静态链表 --转载请注明出处:coder-pig 本章引言: 在二,三中中我们分别学习了顺序表中的线性表与单链表,线性表有点类似于 我们前面所学的数组,而单链表使用的最多的是指针,这里问个简单的问题, 如果是在以前没有指针的话,前辈先人们怎么实现单链表呢?大家思考下! 没有指针,那么用什么来代替呢?前辈先人们非常机智,想出了使用下标+游标的方式 来实现单链表的效果!也就是今天要讲的--静态链表! 当然你也可以直接跳过本章,因为有了单链表就没有必要用静态链表了

Swift学习笔记四:数组和字典

最近一个月都在专心做unity3d的斗地主游戏,从早到晚,最后总算是搞出来了,其中的心酸只有自己知道.最近才有功夫闲下来,还是学习学习之前的老本行--asp.net,现在用.net做项目流行MVC,而不是之前的三层,既然技术在更新,只能不断学习,以适应新的技术潮流! 创建MVC工程 1.打开Visual studio2012,新建MVC4工程 2.选择工程属性,创建MVC工程 3.生成工程的目录 App_Start:启动文件的配置信息,包括很重要的RouteConfig路由注册信息 Conten

NLTK学习笔记(四):自然语言处理的一些算法研究

自然语言处理中算法设计有两大部分:分而治之 和 转化 思想.一个是将大问题简化为小问题,另一个是将问题抽象化,向向已知转化.前者的例子:归并排序:后者的例子:判断相邻元素是否相同(与排序). 这次总结的自然语言中常用的一些基本算法,算是入个门了. 递归 使用递归速度上会受影响,但是便于理解算法深层嵌套对象.而一些函数式编程语言会将尾递归优化为迭代. 如果要计算n个词有多少种组合方式?按照阶乘定义:n! = n*(n-1)*...*1 def func(wordlist): length = le

Android学习笔记四:添加Source

问题描述 Source not foundThe JAR file D:\.....\sdk\platforms\android-20\android.jar has no source attachment. 问题原因及解决办法 1. 使用SDK Manager下载最新版本的Sources for Android SDK 一般文件下载目录默认在SDK下的sources文件中即 \adt-bundle-windows-x86_64-20130522\sdk\sources\android-20

【Unity 3D】学习笔记四十二:粒子特效

粒子特效 粒子特效的原理是将若干粒子无规则的组合在一起,来模拟火焰,爆炸,水滴,雾气等效果.要使用粒子特效首先要创建,在hierarchy视图中点击create--particle system即可 粒子发射器 粒子发射器是用于设定粒子的发射属性,比如说粒子的大小,数量和速度等.在创建完粒子对象后,在右侧inspector视图中便可以看到所有的粒子属性: emit:是否是使用粒子发射器. min size:粒子最小尺寸. max size:粒子最大尺寸. min energy:粒子的最小生命周期

WEB前端学习笔记 四

接上一篇,web学习笔记 四,在此感谢您对此篇笔记的认可,但转发时请注明文章出自网知博学. 2.0  html的语法格式 html的标签要写在尖括号中 :<> 在在英文输入法状态下,按住shift键然后再按它左侧的尖括号就可了, 先学习一个简单的h1标签,是个标题标签,在html中这样写: <h1>我在h1标签中,我就是标题</h1> 那么h1标签中所包裹的文字,就标记成标题了.通过浏览器的解析后在页面上显示出来的效果就是字体加粗,加黑,和word中的标题性质一样! 大