本文为《软件设计精要与模式》第四章
在企业运营理论体系中,有一种理论叫做运行价值链。它将企业的运营分为三个步骤:首先是发现价值,找到目标市场;然后是生产价值,将高质量的产品生产出 来;最后是保护价值或收获价值,保证产品的质量,做好品牌。我们应该如何理解运行价值链呢?以nike为例,在nike鞋的企业运行过程中,首先是设计 nike鞋,也就是运行价值链中的发现价值。在这个过程中,可能收获50美元的价值。然后是生产。nike公司为了降低成本,会将生产基地设在劳动力成本 较低的国家,例如中国进行生产。这其中的价值大约是10美元。最后,再将生产好的鞋子,贴上nike的商标后送回到美国本土进行销售,又可以收获40美元 的价值。一双鞋利润100美元,而生产价值所能收获的却仅有10美元。这一步获取利益最低,但我们中国本土的公司却做得最好;而对于如何去发现价值,然后又怎样去巩固自己的品牌和知名度,中国的公司就只能说是差强人意了。
仔细分析企业运行价值链的三个步骤,我认为它和软件开发的测试驱动开发(TDD,Test-Driven Development)价值链很相似。第一步是发现价值,应用到测试驱动开发中,就是测试先行,通过测试来驱动我们编写代码。第二步是生产价值,毋庸置 疑,这正是编写代码的一个阶段。第三步是收获价值,在测试驱动开发中,我们收获的不仅有开发后完整的产品,同时还收获了完整的测试套件。
与nike鞋的生产相似的是,我们并没有做好测试驱动开发的运行价值链。很多人认为,测试驱动开发是很好的技术,但似乎不符合中国国情。说到原因,最具有 说服力的一条就是项目时间紧,没有时间写测试代码。在项目中,到底该不该使用测试驱动开发,大多数人持怀疑或观望的态度。事实是我们在软件开发过程中,往 往过度地重视生产价值阶段,而对于第一步和第三步,要么是忽略了,要么就是没有提高到相应的高度。这样的现实未免让我们扼腕叹息,或许,我们可以利用企业 的运行价值链来重新评估测试驱动开发的价值。
发现价值与生产价值
习惯了传统开发模式的程序员,非常不适应在写代码之前先写测试的方法。那么,我们一般是怎样去发现价值的呢?首先,我们通过需求分析,获取用例并标注功能 点,然后进入设计阶段。在设计阶段期间,围绕需求分析的结果,我们更多的是从实现的角度,而非从客户应用的角度出发进行设计。测试驱动开发彻底颠覆了这种 模式,因为需要测试先行,就驱动了程序员必须从功能出发,从应用出发。在写测试代码的过程中,我们需要考虑实现哪些功能,相应类的名称,对象的创建方式, 以及可能会应用到的模式和策略,如此种种,在整个过程中,如抽丝剥茧一般逐渐地规定出来了。
执行测试驱动开发时,我们要审慎地选择测试的步子。昂首阔步固然显得气宇轩昂,行进快速,但往往会忽略沿途的风景。在测试驱动开发过程中,我建议要小心地规划测试样例,从测试样例的逐步完善中,渐进地驱动出更加完善的代码。
举例来说,我们需要开发一个智能的个人助理,它目前提供的功能包括:
- 能够让用户定制自己感兴趣的类别;
- 能够根据用户的定制进行搜索;
- 将搜索得到的结果按不同的类别进行存储。
如果采用测试驱动开发,我们应该怎样完成该系统的设计与实现呢?首先,我们应该分析整个系统的功能,从而判断出系统需要定义一个智能助理对象。测试代码如下:
public void TestSmartAssistor()
{
SmartAssistor assistor = new SmartAssistor();
Assert.IsNotNull(assistor);
}
当然,这段代码是无法通过编译的,因为我们还没有定义SmartAssistor类。然而,不要小瞧了这一步,它实际上能够促使你对项目进行初步的理解,至少,你需要想好这个将要创建的类型,它的名字是什么?这就是一种驱动力。
那么,SmartAssistor类型究竟能够做什么呢?通过分析,我们认为,个人智能助理应包括定制、搜索和存储三个功能。
仔细想想,实际上只有搜索和存储才是智能助理的职责所在,而定制不过是智能助理要运转的一个条件罢了。从客户应用的顺序来考虑,我们应该先实现定制的功能。要定制类别,就应该定义类别类型,至于定制类别的行为,则交由一个专门的控制器来承担职责。
public class Test
{
private Category m_cg1;
private Category m_cg2;
private CategoryContainer m_cgContainer;
private SmartController m_control;
[SetUp]
public void InitObject()
{
m_cg1 = new Category("SoftWare Engineering","TDD");
m_cg2 = new Category("SoftWare Engineering","Design Patterns");
m_cgContainer = new CategoryContainer();
m_cgContainer.Add(m_cg1);
m_cgContainer.Add(m_cg2);
m_control = new SmartController();
}
[Test]
public void TestCategory()
{
Assert.IsNotNull(m_cgContainer);
Assert.AreEqual(m_cg1,m_cgContainer[0]);
Assert.AreEqual(m_cg2,m_cgContainer[1]);
}
[Test]
public void TestController()
{
Assert.IsNotNull(m_control);
Assert.IsTrue(m_control.CustomizeCategories(m_cgContainer));
Assert.AreEqual(m_cg1, m_control.Categories[0]);
}
}
上面的测试代码完全是从用户的应用角度来考虑的。要定制类别,必须具备类别类型Category,它应该实现一个带参的构造函数,传递主类别和子类别。由 于定制的类别可能会很多,所以需要一个类别容器CategoryContainer。定制类别由控制器SmartController 完成,其中方法CustomizeCategories可以定制多个类别,并返回布尔型结果,以确定定制是否成功。其中,Categories属性用于存储用户定制的类别。
接下来是编码。根据测试代码实现这些类并不困难。在实现的过程中,我们需要利用单元测试工具如NUnit,运行上述的测试代码,保证功能的正确性。
接着应该考虑搜索和存储功能了。由于这两个功能属于SmartAssistor类型的职责,因此需要修改最初的测试代码。
public void TestSmartAssistor()
{
SmartAssistor assistor = new SmartAssistor();
Assert.IsNotNull(assistor);
assistor.Search(m_control.Categories);
assistor.Store();
}
此时,我发现在写Search和Store方法的断言时,存在一些问题。这两个方法返回的结果应该是什么?是布尔值吗?那么搜索得到的结果呢?存储后形成的文件呢?对于用户而言,是否只需要这两个行为呢?
虽然我们可以通过布尔值来判定搜索与存储操作是否成功,但为了使错误信息更加直观地呈现给调用者,同时也为了避免因为需要返回两个结果而使用ref或者 out关键字,因此,有关错误的处理,最好的解决方案应该是异常机制。此外,Search方法的返回结果应该是一个特定的类对象,而Store方法则应该 指定存储的路径和文件的格式。所以,上面的测试代码还需要进一步完善。
public void TestSmartAssistor()
{
SmartAssistor assistor = new SmartAssistor();
Assert.IsNotNull(assistor);
SearchResult result = new SearchResult();
try
{
result = assistor.Search(m_control.Categories);
assistor.Store(result, @"D:\Smart Assistor\", "result.xml");
}
catch (SearchException ex)
{
Assert.Fail(ex.Message);
}
catch (StoreException ex)
{
Assert.Fail(ex.Message);
}
catch (Exception ex)
{
Assert.Fail(ex.Message);
}
}
在编写测试代码的过程中,我发现此前的功能需求还忽略了一个重要功能。在执行Search方法后,对于搜索的结果而言,除了进行存储之外,是否还需要提供显示功能?答案显然是肯定的,我们需要在TestSmartAssistor方法中增加如下语句。
{
//…
try
{
//…
}
catch (SearchException ex)
{
Assert.Fail(ex.Message);
}
catch (StoreException ex)
{
Assert.Fail(ex.Message);
}
catch (Exception ex)
{
Assert.Fail(ex.Message);
}
finally
{
assistor.List(result);
}
}
根据现有的测试代码,我们已经可以实现个人智能助理的主要功能了。整个测试驱动开发过程,从需求驱动测试,再从测试驱动开发,既能够充分理解客户的需求,加强与客户的交流,同时也能保证代码的正确性。至此,个人智能助理系统已经初具雏形了。
所谓智能,还应具备自动搜索,自动匹配,自动分类等诸多功能,本章只是根据该项目提出测试驱动开发的一些观点,因此这些功能在本章中省略。
在测试驱动开发过程中运用面向对象编程思想
“发现价值”的过程远远没有结束。通过测试代码,我们从客户的角度来考虑,会发现一些问题。在已经实现的代码中,SmartAssistor类型实现了搜 索、存储和显示的功能;但这些职责是否真的应该由它承担呢?从表面上看确实如此,因为客户对智能助理的功能需求正是如此。然而我们需要谨记一个原则,就是 当一个对象承担了过多的责任时,我们就有必要考虑对其进行职责划分,除非该对象是一个Facade类。
我们需要利用“分而治之”的思想,减弱SmartAssitor类的直接权力,从而减少重复代码,提高对象的复用,此外也可以降低各种功能之间的耦合度。 根据“封装变化”的原理,我们还需要考虑是否对程序结构实现适度的抽象。测试用例就好似“暮鼓晨钟”一般,可以惊醒我们对现有结构进行深入的思考。考虑的 时机,可以是设计之初,也可以是在设计完成之后在开展重构的过程中进行。
测试驱动开发与重构密不可分。测试驱动开发是一种演进的设计方式,程序结构的设计根据测试用例的驱动,随时可能会修改,此时我们就需要学会利用重构的利器。而在重构的时候,仍然不能放弃测试驱动开发,并利用单元测试工具保证程序的可靠性以及重构的正确性。
接下来我们对现有的程序结构进行分析。首先从行为来考虑。搜索的功能会很复杂吗?可能会有精确搜索,模糊搜索;可能是在网上搜索,也可能是本机搜索。那 么,存储的功能呢?IO的操作是否频繁,存储的要求是否会根据安全级别而逐步升级?对于显示功能而言,显示的方式需要多样化吗?显然,以上的行为具有一定的复杂程度。
再从抽象性考虑。我们需要把这些行为抽象出来吗?也就是说,这些对象是否具有多态的行为?例如,搜索功能可能是对文件对象的搜索,可能是对文本内容的搜 索,也可能是数据库的搜索;存储的格式也会有多种多样,如文本文件、xml文件、数据库文件。显示搜索结果时,可能以网页的形式显示,也可能在客户端窗体 中显示。也许用户要求是带滚动条的文本框,也许只是简单的文本显示。对象的形式很多吧,需要抽象吗?显然是的!
当需要修改的内容太多时,我们可以分步骤完成,“心急吃不了热豆腐”,我们不能急于求成。此外,在修改程序结构的同时,不要忘记利用测试代码进行单元测试,保证我们执行的每一个步骤都是完全正确的。
根据此前的分析,我们可以先把SmartAssistor的职责剥离出来,用单独的对象来完成各自的功能。根据“封装变化”原理,我们对对象进行抽象,提炼出各自的接口。例如搜索功能,我们可以定义一个专门的搜索引擎对象SearchEngine。
public void TestSearching()
{
SearchEngine engine = new SearchEngine();
Assert.IsNotNull(engine);
SearchResult result1 = new SearchResult();
SearchResult result2 = new SearchResult();
Assert.IsNotNull(result1);
Assert.IsNotNull(result2);
result1 = engine.ExactSearch(m_control.Categories);
result2 = engine.BlurSearch(m_control.Categories);
Assert.AreEqual("contents", result1.Content);
Assert.AreEqual("more contents", result2.Content);
}
考察SearchEngine类型,它是一个无状态的对象,仅为项目提供搜索功能,因而在整个程序中我们仅需要保留一个对该对象的引用。为了减小程序所消耗的内存,我们可以采用Singleton模式来限制该对象的重复创建。修改测试代码如下:
public void TestSearching()
{
SearchEngine engine = SearchEngine.Instance;
Assert.IsNotNull(engine);
//……
}
相应的,SearchEngine类的实现也要进行修改。
C#语言:
{
private static SearchEngine m_engine;
private SearchEngine(){}
public static SearchEngine Instance
{
get
{
if (m_engine == null)
{
m_engine = new SearchEngine();
}
return m_engine;
}
}
public SearchResult ExactSearch(Category[] categories)
{
//实现略
}
public SearchResult BlurSearch(Category[] categories)
{
//实现略
}
}
由于搜索的范围有多种情况,如Internet、本地机器、数据库等,也就是说,搜索的算法可能会根据搜索范围的不同而发生改变。我们可以引入 Strategy模式,对ExactSearch和BlurSearch方法进行抽象。由于原有的SearchEngine类采用了Singleton模 式,并设置了Instance属性为静态方法,不允许被override。在完成抽象的时候,如果我们为搜索算法建立抽象类,则Singleton模式的 实现就只有留待子类来完成。当然,我们也可以定义接口来抽象ExactSearch和BlurSearch方法。
考虑到搜索算法可能拥有一些共同的实现逻辑,定义抽象类更有利于这些共同逻辑的重用,因此在本例中,我采用了定义抽象类的方法。当搜索范围为Internet时,测试代码就应该如下所示。
C#语言:
public void TestInternetSearching()
{
SearchEngine engine = InternetSearchEngine.Instance;
SearchEngine engine1 = InternetSearchEngine.Instance;
Assert.IsNotNull(engine);
Assert.IsNotNull(engine1);
Assert.AreEqual(engine, engine1);
SearchResult result1 = new SearchResult();
SearchResult result2 = new SearchResult();
Assert.IsNotNull(result1);
Assert.IsNotNull(result2);
result1 = engine.ExactSearch(m_control.Categories);
result2 = engine.BlurSearch(m_control.Categories);
Assert.AreEqual("contents", result1.Content);
Assert.AreEqual("more contents", result2.Content);
}
根据测试用例,我们可以轻松地定义SearchEngine与InternetSearchEngine类的实现。
C#语言:
{
public virtual SearchResult ExactSearch(Category[] categories)
{
throw new NotSupportedException("Operation is not implemented.");
}
public virtual SearchResult BlurSearch(Category[] categories)
{
throw new NotSupportedException("Operation is not implemented.");
}
}
public class InternetSearchEngine:SearchEngine
{
private static SearchEngine m_engine;
private InternetSearchEngine(){}
public static SearchEngine Instance
{
get
{
if (m_engine == null)
{
m_engine = new InternetSearchEngine();
}
return m_engine;
}
}
public override SearchResult ExactSearch(Category[] categories)
{
//实现略
}
public override SearchResult BlurSearch(Category[] categories)
{
//实现略
}
}
类LocalSearchEngine和DBSearchEngine的定义与InternetSearchEngine类相似,此处略。
既然有如此多的类型,且具有共同的抽象类,那么类型的创建就应该通过工厂进行管理。进一步修改测试代码如下:
public void TestInternetSearching()
{
SearchEngineFactory factory = new InternetSearchEngineFactory();
SearchEngine engine = factory.CreateSearchEngine();
Assert.IsNotNull(engine);
//……
}
SearchEngine的工厂类定义如下所示。
{
public abstract SearchEngine CreateSearchEngine();
}
public class InternetSearchEngineFactory: SearchEngineFactory
{
public override SearchEngine CreateSearchEngine()
{
return InternetSearchEngine.Instance;
}
}
我们可以按照相同的思路改进存储与显示的设计。需要注意的是,每进行一次修改,或者增加新的功能,我们都需要严格按照测试驱动开发的要求,首先写出测试用 例,然后利用单元测试工具NUnit检验测试用例的正确性。如果NUnit亮出红灯,说明测试的功能点存在错误,我们就需要去完善实现代码,直到 NUnit全部显示绿灯测试通过为止。
适时小结
表面上看,这样反复地编写测试用例,然后再根据测试用例进行设计、编码,委实有些消磨时间。然而,我们不要妄下结论,而应该认真思考所谓“发现价值”的意 义。通过测试先行的方式,以模拟客户应用的状态来考量客户的需求,并以此驱动程序员一步一步到达“生产价值”的终点。“发现”与“生产”并行不悖,同时 “质检员”一直跟随其间,保证了产品的质量。这就好比nike鞋的生产,必须以体贴用户的角度出发,设计出吸引人的款式,那么大规模的生产才会有盈利的可 能。
利用测试驱动开发的生产过程也许慢了一点,但请不要忽略了它其实已经省去了编码后单元测试的时间。相加相减之后,又会浪费多少时间呢?如果你还在一味地强 调“时间紧”,以此为借口来搪塞推托测试驱动开发的实施,就有些强词夺理了;或者说,你还未曾体验到测试驱动开发在收获价值方面给你带来的巨大回报。
收获价值
传统的开发模式,是在产品生产出来之后,紧接着进行大量的测试,其中也包括单元测试;最后收获了产品、一大堆源代码和文档。而测试驱动开发的方式,既省去了单元测试的过程,同时还收获了另外一件上帝恩赐的礼物——测试类或测试套件。
测试类绝对是一件奇妙的礼物。必须认识到它的价值不只在于“发现价值”的阶段,它同样是我们的“收获”。
1、比代码更好的文档,比文档更好的代码
有了它,我们就不用钻进浩如烟海的文档里,四顾茫然了。文档的文字描述既不准确,易产生歧义,又会产生文档更新不同步的问题。也许它能促进你对业务和架构 的理解,但对于程序本身,你无法从文档中得到基本的启示。那么看程序的源代码吗?你会在众多的类对象和方法中绕来绕去,最后一头雾水,精疲力尽之后,还是 一无所获。
阅读测试代码就完全不同了。我们不需要了解对象内部的具体实现,而仅需要关心对象暴露在外的接口。我们可以站在调用者的角度,了解对象的创建方式,对象之 间的关系,以及它们的方法究竟实现了何种功能,应该怎样调用。因为测试用例是从客户的应用角度来编写的,看完测试代码,你会很轻松地理清程序结构的脉络。
2、新兵训练营的绝佳教材
也许你的项目组加入了一名新员工。如果他熟悉测试驱动开发,那么,这些测试类是他熟悉项目的最好文档;如果他还没听说过测试驱动开发,不用着急,先把这些 测试类给他。只要他不是程序设计的新手,我想这个新兵会很快熟悉项目组开发的方式。再让他写几个测试样例,他会立即投入到测试驱动开发的怀抱中来的。
3、满载而归的信心
在项目开发中,成员最宝贵的除了认真、努力、团队精神之外,就是信心了。这里所谓的信心,并非是对自己能力乐观地估计与客观地评价后,所表现出来的精神面 貌,而是指程序员对代码正确性的信心。无论这些代码是自己写的,还是他人写的,只要严格按照测试驱动开发的要求进行,你都会对它们充满信心。虽然不能保证 没有bug,但必须承认,通过单元测试,我们已经将bug降低到最少了。
无上之力
中国企业在企业运行价值链上,走好了利润最低的第二步,却忽略了“发现价值”和“收获价值”对于一个企业的重要性。如果一家企业对于运行价值链能够给予足 够的重视,从高端产品中发现价值,找到目标市场,并从品牌创造中收获价值,那么对于这家企业而言,就将是注入活力的源泉之水,避免了干涸的危险。
软件开发同样如此。当然,测试驱动开发并非完全的解决之道,它也有很多缺陷,在许多项目应用上存在一定的局限。但我们在开发的过程中,却必须要重视设计的 “发现价值”阶段,然后在收获产品的同时,不要忽略了还应该收获其他同样值得珍视的“价值”。从这一点来看,也许测试驱动开发更符合这种价值链的模式。尤 其是对于我们程序员来说,千万不要舍本逐末,过于偏执地重视“生产价值”,以至于在软件开发方法上,总是落后于人,进而受制于人!
谨以我之愚见,思考测试驱动开发的方式,认为测试驱动开发内力精深,大约分为4种无上之力。
- 驱动力——驱动程序代码编写;
- 学习力——新兵训练营之绝佳教材;
- 自信力与他信力——能将程序的bug降到最少;
- 控制力——与设计紧密结合,牢牢控制开发过程。