引言
“我希望这里能这样……”,“我希望这里能再增加点东西……”——在软件开发的世界,我们永远无法解决的一大难题,是客户纷繁复杂并且不断变化的需求。如何把需求映射为最终的软件交付,是每一个软件开发方法都无法回避的核心问题。
领域驱动设计(Domain-Driven Design)通过专注领域核心、建立通用语言等手段,以及聚合、仓储等战术模式,很好地达到了去伪存真、化繁为简的目的,从而在团队协作、划分边界、建立模型等方面呈现出足够的优势。但是DDD的实践应用,非常依赖与客户沟通的技巧以及对领域知识的掌握。而这两点,都是需要一些实践经验积淀的,所以初入DDD并不容易。
实例化需求(Specification by Example,以下简称S&E),是我在学习BDD的过程中接触到的开发方法。之所以称其为开发方法,是因为它无法解决如何分析建模的问题,而主要回答了如何梳理用户需求Specification,并最终将其实现为软件交付Delivery的整个过程。其擅长的,是捕获需求、确定验收标准。
那么,为什么我要把DDD与S&E相提并论呢?这是因为,DDD能帮助我们划分讨论的上下文、提取通用语言UL、建立软件模型,S&E则可以帮助我们梳理讨论的场景、验证模型能否达到交付要求、生成开发的相关文档。所以我个人认为,这两种方法能形成优势互补,帮助我们更容易地开发出“正确的软件”。
关于书籍
DDD之父Eric Evans的《Domain-Driven Design: Tackling Complexity in the Heart of Software》、Vaughn Vernon的《Implementing Domain-Driven Design》、Scott Millett的《Patterns, Principles, and Practices of Domain-Driven Design》,还有Jimmy Nilsson的《Applying Domain-Driven Design and Patterns: With Examples in C# and .NET》为我们提供了完整的理论和具体的实践。
现在通过我的书摘,再看看
Gojko Adzic的《Specification by Example: How Successful Teams Deliver the Right Software 》是怎么通过正反事例的对比,来逐步阐述Specification by Example这一方法的吧。
注:《Specification by Example》一书已有中译本《实例化需求:团队如何交付正确的软件》,由人民邮电出版社出版。不过我手里只有这浆糊一样排版的英文原版。所以,下文完全出自于我的个人理解,各种类比和小结将不断穿插其中。
看到这排版,我真心醉了
S&E过程概览
软件开发的压力主要来源于:时间——开发的期限越来越短,成本——维护的要求越来越高,变化——需求改变的频率越来越高。S&E采用一系列彼此衔接的处理模式及其产出的工件(artifact),帮助我们顺利实现需求。其过程主要包括以下环节,并作为本篇各小节的目录:
· 根据业务目标Business Goal,划定问题域Scope
· 通过与客户的沟通协作,制定需求Specification
· 用具体的场景事例Example,阐明需求Specification的具体内容
· 提炼需求Specification,确定关键事例Key Example
· 在不修改需求的前提下,用自动化测试Automation来验证需求
· 在重构需求的同时,在系统与Executable Specification之间频繁地进行同步验证
· 利用工具,提取组织良好的、易于寻找的、前后一致的活文档Living Documentation
S&E关键过程示意图
建立“以文档为中心”的理念
目前实例化需求的过程现在有两种流行的模型:以验收测试为中心的ATDD,侧重于自动化测试,优点在于使开发目标更明确,并防止功能退化;以系统行为规范为主导的BDD,侧重于制定系统行为的场景,在客户与开发团队之间建立共识。这两种模型,各有长处、各有用途,所以无所谓孰优孰劣。我们关注的,是它们生成的活文档,这是实例化需求产出的最好工件。书的第三章,率先回答了什么是活文档的问题,并倡导建立“以文档为中心”的理念。
为什么需要活文档?
维护开发文档总是一件费力不讨好的事情,却又总是不得已而为之,因为将来的重构与维护都需要以这样的一份文档为基础。这份文档需要能快速地勾勒出系统的轮廓,清晰地表达出系统主要的概念,准确地描述系统架构和模型的结构。最关键的,这份文档必须是最新的。所以,这份文档不仅要完整,还必须是“鲜活的”。
测试为什么可以作为文档?
自动化测试本身是按一定的逻辑编排的,所以具有一定的组织结构性。测试方法的名称,也可以看作是测试的一种“自描述”文本。所以这种结构性与自描述性,与开发文档的需求不谋而合,所以测试可以被当作文档的一种形式——“代码即文档”。
但是要注意,不能因此偏重于测试本身,而忽略了测试与需求之间的联系,使得测试变得臃肿和不易修改。ATDD的方法,往往过于注重编写和执行测试,因此容易写出不易维护的测试,导致有需求变化时,产生牵一发而动全身式的连锁反应,大量的维护与重构工作使得先前的测试工作变得得不偿失,因此要极力避免。
如何从测试得到活文档?
如果把带有Example的Executable Specification比喻为页面,那么整个活文档就是由此构成的一整本书。利用Relish等BDD工具,我们可以把通过自动化测试验证后的Specification提取为HTML或者PDF等格式的文档系统,这甚至可以稍加修改就作为用户手册使用。
综上所述,因为重构与维护的难度,我们需要一份组织良好的文档。BDD的自动化测试正符合文档的要求,而且恰好这种文档可以利用一些BDD工具从可以执行的Specification中提取出来,所以实例化需求方法可以视作一种建立在“以文档为中心”理念上的开发方法。
根据业务目标划定问题域
敏捷开发是以用户故事为核心的,所以故事讲得好不好至关重要。那么这个讲故事的责任究竟应由谁来承担?传统的软件开发,认为划分问题域、讲清故事是客户的事。对此,《BDD in Action》和本书的两位作者都反复强调,『不能交由客户去编写用户故事、用例清单等细节,否则就等同于让客户去提供一个具体的、高层次的解决方案了。』所以,划分问题域、讲好用户故事,是开发团队的责任。在划定问题域这个环节,重点应该是引导用户弄清究竟需要什么,进而通过发掘现有业务的潜在,提出新的思路和新的方案。
使用Impact Mapping
划定问题域的具体方法,是理解“Why”与“Who”。这和我在前篇对结合BDD进行DDD开发的一点思考和整理中介绍Impact Mapping这个工具时一样,重点是理解“为什么要这样做?”、“谁人将从中受益?”等问题,弄清客户开发系统所能期望的价值究竟是从何而来。此时,由于系统轮廓不清不楚,可能会感觉无从下手。为此,建议先分清业务目标与要交付的功能,而不是尝试去把每一个用户故事描述清楚。对此,我个人认为Impact Mapping提供的Goal-Actor-Impact-Deliverable模型,将是一个非常合适的挖掘工具。我们可以通过连续的Why提问,来弄清真实的、具体的、有期限的、能度量的业务目标。
从客户期待的输出结果推导业务目标
当难以确定业务目标时,先不要急于讨论需要哪些功能,而是可以从描述客户期待的输出入手,分析为什么需要这样的输出,从而归纳出业务目标所在。比如对于一个ERP系统,坚持“Report-first”,用各种报表展示客户期待的系统输出结果,由此发掘业务目标,可以帮助我们把注意力集中在具体的报表项内容上,而暂时把流程、处理等功能性需求放在一边。
使用“As a - In order to - I want”描述目标
As a stakeholder
In order to achive something valuable
I want some system function
这样三段式的描述,可以与Impact Mapping的内容有机地联系起来,相当于根据Actor-Impact-Deliverable直接转译而来。
询问的技巧
为什么这东西有用? 通过提问,引导客户用具体的事例,来回答为什么某个功能有用?是如何给他的业务带来帮助的?“为什么需要这些东西”带有诘问的口气,因此并不推荐。
有什么可替代的方案? 通过寻找可以替代方案,可以帮助客户从另一个角度去思考和认识自己的业务目标,同时也给团队的实现提供新的思路、决定当前提议的是否已经是最佳方案。
通过沟通协作来制定需求
系统需求,需要由客户与开发团队达成一致,确保系统的各个方面的功能都被包括其中,并有明确具体的验收指征作为约束。这和DDD中分享消化业务知识,得到领域的通用语言是一致的。在DDD中,也提倡专注于最有意思的对话上,并从用例开始,从一个系统行为作为起点,组织开发人员、业务人员和业务专家,围绕一个特定的场景进行讨论,由此发现这一场景内的领域概念和业务知识。这一点也正是我非常珍视的,实例化需求和BDD这一类方法,楔入DDD的关键点。
这种协作,不仅发生在开发人员与客户之间,同样也在开发人员与测试人员之间。如果开发人员与测试人员没有围绕Specification达成一致,那么双方就会各行其是。开发人员看到的是一堆的需求,而测试人员看到的是一堆的测试用例。若由开发人员撰写Specification,它会因为过于贴近模型设计而充斥大量的模式、架构元素,从而变得难以理解。若改由测试人员独立撰写时,可能又会因为太过琐碎零散而变得难以维护,最终迷失在各种测试细节的汪洋大海之中。测试人员编写的测试,没办法帮助开发人员去组织整个系统的各个部分,也无法通过自动化测试驱动整个开发过程。测试人员编写的测试,也没法被当作Specification再被开发人员利用,因为这些测试都是站在测试人员的立场,用测试人员的方言、专业术语编写和描述的,所以没办法用于双方的沟通。对于测试人员,则会在每次系统需求改动时,面对一大堆的测试重构。因为这些测试都不支持自动化测试,或者不容易被其他人理解。所以,协作是广泛的、多重的、具体的。
视协作的规模不同,可以分为:
· 大型的全体工坊: 适合项目刚开始的阶段,增进彼此的了解,并划定足够大的范围。但是要协调如此多人员的日程安排在某一天达到一致,是一件非常困难的事。
· “三剑客”式的小型工坊: 开发人员、测试人员、业务人员组成的小型团队,主要负责勾勒具体场景,产出Given-When-Then三段式的Feature文件。
· 结对编程: 分析人员与开发人员的结对,这是一种高效的方式。为了避免开发人员站在自己的视角采用TDD的方法编写用户故事而有失偏颇,所以转由分析人员编写测试,好让分析人员掌控Specification的全貌。但这又会产生另一个问题,分析人员编写的故事可能会影响到许多已有的测试,而他自己根本无法预见。同时,分析人员习惯于一个故事对应一个流程,从而产生大量重复。所以最后可行的方案,是由分析人员制定测试计划,并与开发人员一起编写feature文件,防止遗漏可能的需求。
· 非正式会议: 由分析人员、编程人员、测试人员和业务相关人员采取非正式的聚会形式,目的在于统一理解、消化知识,发现在各自独立工作时未能发现的内容与细节。
用事例阐明需求的具体内容
只有当场景描述具有很强的带入感时,才能激发客户参与讨论的热情,才更容易达成共识,并发掘潜在的概念和需求。传统的基于平面文档的平铺直叙的方式,在向用户展示系统场景、捕捉系统需求时,可能会因为词不达意,而导致不同的人产生不同的理解,所以描述性的文档始终无法与清晰的代码媲美、也远没有代码直接。然而清晰的代码并非一朝一夕,让用户直接面对系统代码也完全没有意义,所以我们退而求其次,改用一个特定场景下的具体事例来表述系统的行为,达到与客户有效交流的目的。所以,举例说明的方式,对于共同认识和理解某个场景是非常有益的。
在选择和描述每一个例子时,作者提出要坚持“例子四原则”:
· 例子总是明确的。
要使用"yes or no"这样的问卷调查,应更注意彼此的沟通。
不要使用“小于10”这样的『比较性』描述,而使用“9”、“11”这样明确具体的值,来对应不同条件下的场景。因为比较性的描述,总是意味着一个值域,而不是单个的值。相比引起变化的单个临界值,这样的值域对于我们理解事例并没有更多的帮助。
· 例子总是完整的。
要用具体的数值去表述不同条件下的不同场景,特别是要注意正反两方面的数据输入组合。对负数、0这样的边界值给予足够重视。当涉及的条件是对象时,则要留意无效的引用、null等。
使用替代的方法进行验证。这个小节在原书的描述里非常晦涩。我大意理解为,对于一些新旧数据并存的系统,我们很容易只惦记着新环境下的各种例子,而遗忘了旧数据也应该当被考虑在内。
· 例子总是现实的。
建议直接使用真实的数据,而不用费心为测试专门编造数据。这样可以充分利用旧数据,减少未来可能的数据兼容风险,保证新旧数据在系统中的一致性。
直接由用户提供基础的例子,而不要自己臆造。
· 例子总是易于理解的。
不要拿一堆的参数组合表格给客户,我们重点关注的应当是所有的临界点。对每一个临界条件,都应当进行认真讨论。如果双方对该条件是否确系临界条件有争议,那说明双方对例子的理解本身就是有误解的。这似乎又回到所有问题都要达成共识的这个原点上来了。
注意寻找潜在的概念。在面对与一个功能联系的一大摞事例时,解决事例过于细碎、不易理解的方法,在于对事例适当进行抽象和归纳,然后再转头分析此前那些琐碎的下层概念,如同是切碎了再进行二次理解,这样可能会发现一些潜在的概念。
在安全、性能等非功能性的需求方面, 当其重要性已经达到影响业务价值或者业务目标实现程度的时候,那就清晰地表达出来。这与《UML精粹》中的观点是一致的,一切需求的重要性视其对实现业务价值、业务目标的影响程度而定。在性能、响应时间这些无法准确表述的需求方面,作者引入了QUPER模型。对这个模型,我个人理解是预估这些指标对应的障碍,以及由此产生的开销,然后再展开讨论。每个问题会被分成三个方面:
可用性:是否有功用性——“能不能用?”
分化性:是否有市场占有能力——“相比其他产品是不是更具优势?”
饱和性:过度设计没有意义——“弄得再好一些有没有实际意义?”
作者使用了“手机开机速度”作为例子:不能开机,手机就没用;开机速度太慢,就没市场竞争力;开机非常快了,再快就没有意义了。
提炼需求
好记性不如烂笔头。交流的结果一定要以某种形式记载下来。原始的例子就象未经雕琢的钻石,只有提炼后才是关键的、易理解的、方便转换为可执行Specification的、能予以自动化测试的Key Example。
Specification应该是明确的、可测试的
这一点,和Example的”明确的”是同样的含义。要尽可能消除描述上的模棱两可,并且要保证所有参与讨论人员认识上的一致。
Specification应当是真实的互动,而不是简单的脚本
脚本通常更侧重于描述一个事物是如何变化的,更多的倾向于流程方面的内容。这也是客户在描述的时候,容易掉入的一个陷阱——“先这样,然后那样,接着再怎么样,最后又是什么样”。这种脚本或者说是流程形式的表述,缺乏一个系统的视角,缺少对系统与用户交互情况的表达,而且流程本身很容易改变并且难以维护,所以并不适合直接作为Specification。正确的方式,应该按”在这样的情况下,系统会做出那样的反应“的形式进行表述。重点是”系统应该做什么“,而不是”系统应如何工作”。
Specification应该是业务功能相关的,而不仅仅是软件设计意义上的结果
Specification不要与代码、与UI等技术实现细节耦合太紧。技术层面的难题,以及流程等细节,留待再下层的自动化测试去解决。
Specification应该是自解释的、不言自明的
为了提高Specification的可阅读性,可以给它增添一段描述文本,然后交给其他人看,静静地观察对方的反应。如果对方无需额外提问就能理解并达成一致,那说明这个Specification符合预期。否则就把回答对方的解释,也写进开头的这段描述性文本里。
在筛选关键事例时,应优先把握所有成功的场景,而把可能失败的情景先放在一边,由简入繁地先把功能正常地展现出来。在具体筛选时,可以从以下几个方面着手:
描述了业务功能的某个方面
描述了重要的业务边界或者业务规则
描述了可能导致失败的某种情形
Specification应该是专注的
使用Given-When-Then三段式表述Specification,并且尽量避免考虑动作或者事件之间的依赖关系,最好就专注于一个动作、一个事件。对于新旧数据共存的系统,比如ES+CQRS里新旧版本的领域事件,为了保持专注,建议把这种兼容性问题压入自动化测试层去解决。对于系统当中的缺省值,虽然能让Specification更易读,但考虑到这种缺省值如果表述在Specification中,将会导致过强的依赖性,所以也建议移入自动化测试层。在这个问题上,可以参考“魔数”。当我们把魔数显式地定义出来时,才更有助于我们消除误解。将其移动到自动化测试层,或者放入一个全局的配置当中,都是相对更灵活的方法。
Specification应该是具备领域意义的
这一点又转回到DDD了,即Specification中引用的概念、关系,都应该与当前上下文中的通用语言保持一致。
用自动化测试验证需求
随着软件规模的逐渐增长,测试的数量、大小、应对变化的能力,都要求我们采取自动化的测试方式。在这个过程中,必须以预定的Specification不再修改作为前提,否则我们的自动化测试只能是以讹传讹了。换个角度看,虽然自动化测试增加了学习的成本,要引入额外的BDD工具,编写额外的Feature与Specification,得到的却是前后一致的需求表达和更加轻松的后期维护,代码的实现也会更自然。这一点,我认为和DDD里的从UL到代码的展开是一致的。因为有统一的UL和清晰的模型,所以直接映射到代码也就更直观和自然了。
在具体实施自动化测试时,应该由简入繁、从易到难,事先做好规划。因为构建整个自动化测试的上下文环境是相对比较耗时费力的,这个上下文还要集成到一定的系统环境中才能执行,将来需求发生变化时这个环境也能被重用,所以非常有必要在对待整个测试环境规划时更慎重一点。
在具体的实现环节,将自动化测试与编写业务代码同步,比如采用TDD的方法,能让所有人把精力集中在测试上,保证Specification顺利实现。这就如同公交车,如果中途没有乘客上下,自然会跑得很快。但事实并非如此,测试的职责就是保证Specification的实现、业务代码的正确,所以自动化测试的规划与实现,必须要由开发团队承担起来,不能交给其他人。
手动测试与自动化测试的区别在于,手动测试侧重于准备上下文,然后测试是否通过,关注的是成功与否;自动化测试则关心导致测试失败的原因。特别的,手动测试通常是脚本化的,一个步骤紧跟一个步骤,每一次测试都重复这个过程。如果测试失败,那么手动检查其中的每个步骤也在情理之中,反正都是手动的。自动化测试则必须消除这种测试步骤之间的依赖性,否则当测试失败时,无法确定究竟是哪个步骤出了问题,自动化测试将因此退化成手动测试。如果遇到这种情况,可以将其切分为若干个小的测试,比如每个步骤对应一个小的自动化测试,改由测试上下文通过准备测试条件将这些小测试联系起来。由此引申出一个问题:测试代码要不要良好的组织与设计?答案是肯定的。因为良好编码的测试代码,才能方便维护和阅读,并作为活文档的提炼来源。
Executable Specification通常是用文本或者HTML格式进行描述的(想想Cucumber的Step或者Spock里的测试方法的描述式命名)。这样当这个可以执行的需求说明发生改变时,通常不需要重新编译业务代码。而自动化测试是代码,并负责对Specification的验证,所以当需求说明改变时,要重新编译。此时,为了避免在Specification中混杂太多计算、判断的逻辑,要把这些验证逻辑放在自动化测试里,而不要表述在Specification里。因为对于Specification而言,在转换为Executable Specification时应当关注的是“测试什么”,而把“如何测试”的责任交给自动化测试。
尽管测试不能是脚本,容易变化的流程应尽量放在自动化测试层。但是无论怎样,业务流程总是客观存在的,通过When-Then的事件驱动方式,我们可以借由若干个Specification的组合,展示完整的业务流程。而具体的业务逻辑,则放在Specification里。所以,不要在测试代码里重复业务流程或业务逻辑。
UI的自动化测试
依赖UI与数据库的测试,是自动化测试面临的最大困难。书里提到了许多建议,但都非常需要实践进行验证,才能深刻领会。至少我只理解了皮毛,所以这一段的内容暂时没有办法总结。尽管如此,大量地引入Stub与Mock进行测试、隔离UI与业务模型、进行持久化无关的设计、建立统一的应用服务层、在Specification里竭力避免引入UI与存储相关的元素等等,都是可行的方案,类似MVC、MVP、MVVM等模式也将成为我们解耦的利器。
即使要对UI进行自动化测试,也建议使用针对UI编写的Specification进行验证,而且不要使用“录制-回放”工具。因为这类工具生成的脚本通常会增加一定的学习成本,而且会非常难以理解,不便于维护。
从需求说明到UI的自动化测试,可以从以下3个抽象力度逐渐弱化的不同层次进行实现。其中,需求说明应该在第一个业务规则层中描述,自动化层则应该通过组合第三个技术行为层来表达第二个业务工作流层。这样分层实现的从需求到测试的描述,更易于理解,也更高效。
业务规则层 :测试要展示或者操作的是什么? 对一条业务规则进行描述:对购买了5本书的客户提供包邮服务。
业务流程层 :如何通过UI使用某个功能,以更高的抽象级别进行描述? 对一个业务流程进行描述:放5本书进购物车,然后验证是否提示已包邮。
技术行为层 :在一个流程的某个环节,需要哪些技术性步骤?对实现一个业务流程的具体UI操作步骤进行描述:点击5本书的"放入购物车"按钮,然后点击“下单”,页面显示“已包邮”。
持久层的自动化测试
用数据库作为自动化测试的数据来源,是一个比较便利的方式,但是如何管理这些数据却成为一个难题。要避免直接使用旧存的数据作为测试的数据源,因为这种数据与现在的需求可能存在冲突、不易理解。对于构造过程相对复杂的对象图,可以尝试在数据库里预置相关数据,以提高自动化测试的速度。
在重构需求的同时,频繁地进行同步验证
这一部分的内容,与重构、持续集成紧密相关,因此提到的也多是化整为零、“不要想一口吃成一个胖子”、用Mock隔离故障点、以事件驱动测试、先保证同步再尝试异步测试等等建议,并且提倡引入并发测试、快慢分组等方式尽快提高测试的反馈速度。
“业务时钟”
对于那种周期性执行的测试,引入自然时钟显然是不合适的,所以新增“业务时钟”的概念去控制这个周期,使之可以根据测试要求随时执行这一类的测试。我的理解,它等同于一个虚拟的时钟,基本原理还是触发一个时间事件,然后利用这个事件去驱动测试。
提取活文档
由于提取活文档更多的是BDD工具的使用,所以只要有编写优雅的Specification和自动化测试作为基础,文档的生成是一件水到渠成的事。在这个部分,主要的建议包括:
以UI导航流程、功能结构、业务过程等组织文档内容。
即便是虚拟的角色,也要保证完整的角色信息。
合理使用Tag、WiKi等一些文档组织技术,提高可阅读性。
在文档中始终保证领域专用语言DSL的统一。
写在最后
实例化需求的实践,重点和难点都在其中的4个环节:制定需求、描述需求、提炼需求和自动化测试。而《实例化需求》这本书本身的内容,也更偏重于用正反两方面的具体事例来引导我们的思考,涉及具体操作步骤的内容相对较少。所以和DDD一样,掌握实例化需求的方法,也需要大量的实操和经验积累。整理出这篇书摘,附上自己阅读时的注解,希望可以抛砖引玉、温故而知新。