行为驱动开发导引

本文翻译自Dan North的文章"Introducing BDD"。

我遇到了一个问题。当我在不同环境的多个项目中使用和教授类似测试驱动开发(test-driven development, TDD)这样的敏捷实践时,我总是能遇到来自程序员们相同的困惑和误解。他们想知道从哪里开始、测什么不测什么、一次测试多少、谁来调用他们的测试以及如何理解为什么一个测试失败了。

越是深入TDD,我越能感觉到我对TDD认知过程是时断时续、逐步掌握的,还远未进入到死胡同。我记得多数时间我想到的都是"这只是别人告诉我这样做的",而不是"哇,我明白为何要这样做了"。我断定一定可以通过某种方法将TDD直截了当地呈现给那些优秀的程序员们,并且可以避免所有陷阱。

我给出的答案是行为驱动开发(Behaviour-drive Development, BDD)。它从已有的敏捷实践演化而成,其设计目的是让敏捷实践对于采用敏捷软件交付的新团队来说变得更加容易理解和高效。随着时间推移,BDD已经发展为一种包含敏捷分析以及自动验收测试的敏捷实践。

测试方法名应该成句

我第一次发出"Aha!"是当看到我的同事Chris Stevenson开发的一款看似简单的名为agiledox的工具程序时。这个程序用于处理JUnit的测试类,并以普通句子的形式打印出方法名。其中的一个测试用例看起来像这样:

public class CustomerLookupTest extends TestCase {
    testFindsCustomerById() {
        …
    }
    testFailsForDuplicateCustomers() {
        …
    }
    …
}

结果是这样的:

CustomerLookup
- finds customer by id
- fails for duplicate customers
- …

"test"这个词被从类名和方法名中剥离出来,采用驼峰式命名方式(camel-case)的方法名被转换为普通的文本。这就是这个工具所做的一切,但是它产生的效果却是惊人的。

开发人员发现这至少可以为他们产生一些文档,所以他们开始编写使用真实句子作为名字的测试方法。更重要的是,当他们使用业务领域的语言作为方法名后,生成的文档对于商业用户、分析师以及测试人员变得同样有意义了。

一个让你专注于测试方法的简单句子模板

接下来我无意中发现了以单词"should"作为开头的测试方法命名手法。这个句子模板-"这个类应该(should)做某事

"-意思是你只能为当前类定义测试,这会让你保持专注。如果你发现自己编写了一个名字不符合该模板的测试,这表明这个行为很可能属于其他地方。

例如,我正在编写一个用于校验屏幕输入的类。大多字段都是常规的客户信息-名,姓氏等等,不过其中有一个字段用于输入出生日期,还有一个字段用来输入年龄信息。我开始编写一个ClientDetailsValidatorTest类,其中包含诸如testShouldFailForMissingSurname和testShouldFailForMissingTitle的测试方法.

接下来,我开始着手计算年龄,我的思维进入了一个充斥着繁琐业务规则的世界:如果客户同时提供的年龄和出生日期信息两者无法匹配该怎么处理?如果提供的出生日期是今天呢,又该如何处理?如果我只得到了出生日期,我应该如何计算年龄呢?为了描述这个行为,我正在编写的一些测试方法名字日益复杂,所以我考虑将其交给其他类去处理。这促使我引入了一个名为AgeCalculator的新类以及对应的AgeCalculatorTest。所有有关年龄计算的行为都放到calculator这个类中,这样validator类只需要一个有关年龄计算的测试例,并保证其与calculator类可以正确地交互。

如果一个类做了不止一件事情,这对我而言通常是一个提示:我应该引入其他类来分担一些工作了。我会将该新服务定义成一个可以描述它自身职责
的接口,并且将该服务通过类的构造函数传入:

public class ClientDetailsValidator {
 
    private final AgeCalculator ageCalc;
 
    public ClientDetailsValidator(AgeCalculator ageCalc) {
        this.ageCalc = ageCalc;
    }
}

这种将众多对象连接在一起的手法,即通常所说的依赖注入(dependency injection),在与mock机制一同使用时特别有用。

当测试失败时,一个表达良好的测试名字十分有用

不久,我就发现如果我修改代码后导致测试失败,我可以查看测试方法的名字并识别出这段代码预期的行为。通常发生的情况有下面三种:

* 我引入一个bug。都怪我。解决方法:修正这个bug。
* 预期行为仍然有意义,但已移至别处了。解决方法:将这个测试例移走,也许还要进行一些修改。
* 这个行为已经不再正确 – 系统的前提发生了改变。解决方法:删除这个测试。

在敏捷项目中随着你的理解的深入,最后一种情况很可能发生。不幸的是,TDD新手对删除测试例有着与生俱来的恐惧,就好像这样做会降低他们的代码质量似的。

在一个更微妙的方面上,与那些更加正式的单词will或shall相比,单词should的含义更加显而易见。Should隐式地允许你挑战测试例的前提:"它应该吗?果真是这样吗"。这样我们可以更加容易判断出测试失败到底是由于你引入的一个bug还是只是因为你之前对系统行为的假设已经不再正确。

与"测试(test)"相比,"行为(Behaviour)"是个更加有用的词

现在,我有一个工具 – agiledox – 用来删除单词"test",并且我拥有一个编写测试方法名的模板。我突然意识到人们关于TDD的误解几乎都归结到单词"test"上。

这并不是说测试不是TDD所固有的 -测试方法的结果集是一个保证你的代码可以正确工作的有效途径。但是,如果方法没有全面地描述你的系统的行为,那么它们会使你产生一种虚假的安全感。

在进行TDD时,我开始使用单词"behaviour"代替"test"。我发现这样做不仅合适,而且之前在TDD辅导时遇到的各类问题也都迎任而解。现在我已经有了这些问题的答案。什么来调用你的测试,这个问题变得很容易回答 – 我们在一个句子中调用你的测试,这个句子描述了你感兴趣的行为。测试多少用例才算充分 – 这取决于你在一个句子中可以描述多少行为。当测试失败时,我们可以简单地按照上面描述的过程解决- 要么你引入了一个bug,要么这个行为被移走了,或者是这个测试不再有意义了。

我发现这种从考虑测试到考虑行为的思维转变影响是如此巨大,以致于我开始把TDD称作为BDD,或行为驱动开发了。

与测试相比,JBehave更强调行为

在2003年末,我觉得是时候采取实际行动了。我开始编写一个名为JBehave的JUnit的替代品,它删除了代码中所有涉及测试的词汇,并替换为与验证行为相关的词汇。我这样做的目的就是为了看看如果我严格坚持我的行为驱动方法,这样一个框架将会如何演变。同时,我认为这也是一个有价值的教学工具,可以用来介绍TDD和BDD,同时可以避免大家分心于那些Test相关的词汇。

为了定义一个假想CustomerLookup类的行为,我编写了一个行为类,例如,CustomerLookupBehaviour。这个类包含以单词"should"开始的方法。行为运行器(behaviour runner)将实例化这个行为类,并依次调用每个行为方法,就像JUnit处理测试例的方式一样。它还会报告执行进度并在结束时输出一份总结。

我的第一个里程碑是使得JBehave做到自我验证。我只不过增加了一些行为,使得JBehave可以验证自己。我能够将所有JUnit测试例移植为JBehave行为并且可以像JUnit那样立即获取验证结果的反馈。

确定最重要的行为

接下来,我发现了商业价值的概念。当然了,我一直知道我编写软件的原因,但是我从未真正考虑过我现在所编写代码的价值。我的另外一个同事,业务分析师Chris Matts,促使我开始考虑在行为驱动开发背景下的商业价值。

假定我在头脑中已经有了使JBehave自托管的目标,我发现一个真正有用的保持专注的方法就是问:系统尚未
实现的最重要的特性是什么

这个问题需要你能识别出你尚未实现的特性的价值,并按优先级顺序对它们进行排序。它也可以帮助你制定这个行为方法的名字:系统尚未实现X(X是一个有意义的行为),X是重要的,这意味着系统应该实现X;所以你的下一个行为方法很简单:

public void shouldDoX() {
    // …
}

现在我有了另外一个TDD问题即"从何开始"的答案了。

需求也是行为

此时此刻,我拥有了一个框架,它可以帮助我理解,并且更重要的是解释TDD是如何工作的,并且还可以帮助我解释一种避免我遇到的所有陷阱的方法。

临近2004年年底,当我向Matts描述我新发现的、基于行为的词汇时,他说"但是这很像分析"。当我们讨论到这些时,我们停顿了很长时间,然后我们决定将这种行为驱动的思维方式应用于定义需求。如果我们可以为分析师、测试人员、开发人员以及业务开发出一致的词汇,那么我们就可以很好的消除技术人员和业务人员沟通过程中产生的一些岐义和错误传达。

BDD为分析提供了一种"通用语言(ubiquitous language)"

就在此期间,Eric Evans出版了他的畅销书《领域驱动设计》。在书中,他使用一种基于业务领域的通用语言描述了系统建模的概念,这使得商业词汇渗透到了代码库中。

Chris和我意识到我们正试图为分析过程本身
定义一种通用语言!我们拥有一个很好的起点。公司内部已经有了一个常用的故事模板,看起来类似这样:

作为(As a)
[X]
我要(I want)
[Y]
结果是(so that)
[Z]

这里Y是某个特性,Z是这个特性的价值或带来的益处,X是这个特性的受益人或角色。它的优点在于当你第一次定义需求故事时,它将迫使你识别交付这个故事的价值。当一个故事没有真正的商业价值,它常常可以归结为类似:"…我想要[某个特性],所以[我就去做,好吗?]"。这样可以更加容易地消减一些难懂的需求。

从这点触发,Matts和我开始着手了解每个敏捷测试人员已经知道了些什么:一个故事的行为仅仅是其验收标准 – 如果系统满足所有验收标准,它的行为就是正确的;相反,它的行为就是不正确的。所以我们创建了一个模板来捕捉一个故事的验收标准。

这个模板应该足够宽松,这样分析师们不会感觉到矫揉造作或受到约束。不过它也应该足够结构化,这样我们可以将故事分解成组成片断并自动生成它们。我们从场景(scenarios)的角度来描述验收标准,采用如下形式:

假定(Given)
一些初始上下文,
当(When)
一个事件发生,
那么(then)
要保证一些结果。

为了说明这一点,我们使用ATM机这个经典的例子。其中的一个故事卡可能看起来像这样:

+标题: 客户取现金+
作为(As)一个客户
我想(I want)从一台ATM机中取现金
结果(so that)是我不需要在银行中排队等候

那么我们怎么知道何时我们已经交付了这个故事呢?这里有几种场景要考虑:账户可能有盈余,账户可能被透支,但在透支额度以内,账户可能被透支且超出透支额度。当然,还有其他一些场景,注入如果账户有盈余,但是这次取款将使得账户透支,或如果自动取款机现金量不足。

使用given-when-then模板,头两个场景可能看起来是这样的:

+场景 1: 账户有盈余 +
假定账户有盈余
并且(And)卡片是有效的
并且取款机有现金
当(When)客户请求现金时
那么(Then)要保证这个账户被记入了贷方
并且(And)保证现金被取出
并且保证卡片被返还

注意,and用于以自然的方式连接多个givens(假定)或多个outcomes(结果)。

+场景 2: 账户透支超出额度限制+
假定账户被透支
并且卡片是有效的
当客户请求现金
那么要保证显示一条拒绝消息
并且保证现金没有被取出
并且保证卡片被返回

两个场景都是基于同样的events(事件),甚至有一些共同的givens和outcomes。我们要通过重用givens,events和outcomes充分利用这一点。

验收标准应该是可执行的

场景的片断-givens,events和outcomes-的粒度足够细,可以直接用代码表示。JBehave定义了一个对象模型,该模型允许我们直接将场景片断映射为Java类。

你编写一个类用于代表每个given:

public class AccountIsInCredit implements Given {
    public void setup(World world) {
        …
    }
}
public class CardIsValid implements Given {
    public void setup(World world) {
        …
    }
}

并且另外一个用于代表event:

public class CustomerRequestsCash implements Event {
    public void occurIn(World world) {
        …
    }
}

outcomes也是这样。然后JBehave将所有这些联系起来并且执行它们。它创造了一个"世界",只是用于存储你的对象,它将这个世界依次传递给每个givens,这样这些givens就可以用已知状态生存于这个世界中了。JBehave接下来告诉events出现在这个世界,它们实现了场景的实际行为。最后,它将控制权传递给我们为这个故事定义的任一一个outcome。

用一个类来表示每个片断使得我们可以在其他场景或故事中重用这些片断。起初,我们通过使用mock机制设置账户有盈余或者卡片有效来实现片断。这些形成了实现行为的起始点。当你实现应用时,你修改givens和outcome,使用你实现的实际类,这样直到场景完成为止,他们已经成为正确的端到端的功能测试。

BDD的现在和未来

经过一次简短的停顿后,JBahave回归到积极的开发。其核心已经相当完整和健壮了。下一步是将其与流行的Java IDE如IntelliJ IDEA和Eclipse集成在一起。

Dave Astels一直在积极推动BDD。他的博客以及各类发表的文章引发了一系列活动,最引人注目的是rspec项目,它是一个用Ruby语言实现的BDD框架。我已经开始开发rbehave,它将是一个用Ruby实现的JBehave。

我的许多同事都一直在现实世界中的各种项目中使用了BDD技术,并且发现这个技术非常成功。JBehave的故事runner – 校验验收标准的部分 – 正在积极的开发中。

我们的目标是拥有一个往返的编辑器,这样业务分析师和测试人员可以在一个普通的文本编辑器中捕获故事,同时这个编辑器还可以为行为类生成桩代码,所有这些都使用业务领域的语言描述。BDD的演化是与大家的帮助分不开的,我在这里十分感谢他们。

Common Lisp初学点滴

Common Lisp是一门Interactive语言,比较容易上手。无论你是用CLISPSBCL还是Clozure CL,你都可以很快地写出一个"Hello, World"程序出来。不过千万不要因此低估了Common Lisp,前人的经验表明:Common Lisp是门庞大且复杂的语言,其学习曲线可并不低。要想真正掌握它,需要你有持续的热情、足够的耐心和不断的练习。我接触Common Lisp时间也不长,是个地地道道的初级选手。这段时间看了些书,做了一些练习,这里把我初学Common Lisp过程中的点点滴滴记录下来,以备忘。

俗话说:工欲善其事,必先利其器。Common Lisp开发者们也有着自己一套高效的开发工具。目前无论是在Windows还是在Linux或是其他平台上,最受Lisper们推崇的工具组合是Emacs+ Slime(The Superior Lisp Interaction Mode for Emacs)。鼎鼎大名的Emacs这里就不说了,Slime对于很多非Lisp开发者来说是一个陌生的名字,我们可以把它看成是一种专门为Lisper们提供的一个嵌入到Emacs中的IDE,通过它我们可以在Emacs编辑器中直接进行Lisp代码的求值,编译,宏扩展,符号定义的查找,名字的自动补全以及在线文档查询等操作。我平时开发更多使用的是另外一种编辑神器-VIM,幸运的是已经有人将Slime移植到了Vim下,Slime摇身一变,变成了Slimv(The Superior Lisp Interaction Mode for Vim)。由于接触时间较短,我目前尚不确定在功能上Slimv是否完全等同于Slime。不过就目前来看,Slimv的确让Vim下Common Lisp代码的编写变的高效了许多。

Slimv的安装极其简单:将Slimv包下载到你的$HOME/.vim下(这里以Linux下的安装为例),直接解压即可。Slimv首先为Vim提供了一种名为Paredit Mode(.vim/doc/paredit.txt )的编辑模式,这种模式专门针对Lisp代码源文件,诸如以.lisp为后缀名的文件。该编辑模式保证内容中所有括号、方括号以及双引号均平衡出现,即成对匹配。当你敲入"(",该模式会自动补充对应的")";删除半个括号时,另半个括号也被自动删除。初次使用Paredit mode很不习惯,特别是不知如何在括号的外层再包裹一层括号,也就是将(list 1 2)变为((list 1 2))。每次在(list 1 2)开始处输入"(",都会得到"()(list 1 2)"。后来才在Stackoverflow上觅到答案:原来先输入"\"再输入"("时,Slimv不会自动补充")",通过这种方式可以在括号的外围再加上一层括号了,在Lisp实际编程过程当中,嵌套括号的情况还是很多的。

打开一个名为xx.lisp的源文件,Slimv就会自动发挥作用。在Vim的命令模式下,敲入",c",Slimv会自动启动Swank Server,这个Server运行着一个Common Lisp的REPL,接收并处理嵌入在Vim中的Slimv client端发出的求值、编译、调试等请求,保存你在Vim中与REPL的session内容。Slimv同时会在Vim里创建一个REPL窗口,不过这仅是用来等待你的输入,真正的求值等操作是在Swank Server完成的。

Slimv会自动Detect你已安装的Common Lisp实现,在我的已经安装过Clisp和SBCL的系统中,Slimv优先选择了SBCL。 关于Slimv,这里不再多说什么了,因为其作者已经编写了一份很详尽的Tutorial在这里,有兴趣的朋友可以参考之。

我在读的Common Lisp书籍主要有两本:一本是"黑客与画家"的作者Paul Graham编写的"ANSI Common Lisp",另外一本则是Peter Seibel的"Practical Common Lisp"(据说该书的中文译本已由binghe完成)。这一周多来,我快速地浏览了Peter Seibel的"Practical Common Lisp",除了惊奇于一些之前未曾接触过的特殊语法结构(如Closure)之外,也感叹于Common Lisp的复杂,数不尽的function, macro和special operator让我有些迷失和混淆。另外Peter Seibel自称书中有关macro的例子都很初级,但就是这样初级的macro也是甚难以理解的。关于macro的深入领会,我看只能指望Paul Graham的大作:"ANSI Common Lisp"和"on lisp"了。

另外一本名为"Common Lisp Quick Reference"的小书也值得一看,不过更适合Common Lisp老手查阅手册时使用。

浏览完"Practical Common Lisp“后,继续精读"ANSI Common Lisp",并且对其中的习题也不放过。这些练习估计很初级,不过对于我这个初级选手来说正合适。刚刚看完第二章(Welcome to Lisp),这里将我的习题答案放到这里,供大家批评指正:

练习1.
(a) 14
(b) (1 5)
(c) 7
(d) (NIL 3)

练习2.
[1]> (cons 'a '(b c))
(A B C)
[2]> (cons 'a (cons 'b (cons 'c nil)))
(A B C)
[3]> (cons 'a (list 'b 'c))
(A B C)

练习3.
[1]> (defun my-fourth (x)
          (car (cdr (cdr (cdr x)))))
MY-FOURTH
[2]> (my-fourth '(1 2 3 4 5))
4

练习4.   
[1]> (defun my-max (x y)
         (if (> x y) x y))
MY-MAX
[2]> (my-max 5 6)
6
[3]> (my-max 7 6)
7

以上方案只适用于整数等适用>进行比较的类型,下面是一个更加通用的版本:

[1]> (defun my-max1 (x y comp_func)
         (if (funcall comp_func x y) x y))
MY-MAX1
[2]> (defparameter *cf* (lambda (x y) (if (> x y) t nil)))
*CF*
[3]> (my-max1 5 6 *cf*)
6
[4]> (my-max1 7 6 *cf*)
7
[5]> (defparameter *ccf* (lambda (x y) (if (char> x y) t nil)))
*CCF*
[6]>  (my-max1 #\c #\b *ccf*)
#\c
[7]> (my-max1 #\c #\d *ccf*)
#\d

练习5.
(a) enigma函数的功能是找出list中是否有值为nil的元素,如果有,返回T;否则返回nil
(b) mystery函数的功能是返回x在y列表中的位置(下标)

练习6.
(a) x = car
(car (car (cdr '( a (b c) d ) ) ) )

(b) x = or
(or 13 (/ 1 0))
注:短路求值,后一项在13为t的情况下不被求值,避免了divide by 0错误

(c) x = apply

注意funcall与apply的区别
(funcall function arg1 arg2 …)
==  (apply function arg1 arg2 … nil)
==  (apply function (list arg1 arg2 …))

练习7.
(defun have-list-param-p (x)
  (let ((result nil))
    (dolist (obj x)
      (if (listp obj)
        (setf result t)))
    result))

[1]> (load "list_param.lisp")
;; Loading file list_param.lisp …
;; Loaded file list_param.lisp
T
[38]> (have-list-param-p '(1 2 3))
NIL
[39]> (have-list-param-p '(1 (2 3) 4))
T

练习8.
(a)
iterative solution:
(defun print_dots (number-of-dots)
  (do ((i 1 (+ i 1)))
    ((> i number-of-dots))
    (format t ".")))

recursive solution:
(defun print_dots (number-of-dots)
  (let  ((i number-of-dots))
     (if (> i 1)
        (print_dots (- number-of-dots 1)))
     (format t ".")))

练习9.
(a) 问题所在:remove返回一个新的lst,原来的lst如果包含nil,则+会提示nil is not a number
修改后:
(defun summit (lst)
  (setf lst (remove nil lst)) 
  (apply #'+ lst))

(b) 问题所在:导致无穷递归,提示Program stack overflow. RESET
修改后:
(defun summit (lst)
  (if lst (+ (or (car lst) 0) (summit (cdr lst))) 0))
     
Common Lisp与Haskell不同,Common Lisp并非纯函数式编程语言,其中包含了诸多命令式(imperative)的元素,这样对于习惯了命令式编程的初学者来说,在学习过程中就不会感觉到过于剧烈的思维跳跃了。

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言精进之路1 Go语言精进之路2 Go语言编程指南
商务合作请联系bigwhite.cn AT aliyun.com

欢迎使用邮件订阅我的博客

输入邮箱订阅本站,只要有新文章发布,就会第一时间发送邮件通知你哦!

这里是 Tony Bai的个人Blog,欢迎访问、订阅和留言! 订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠 ,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您希望通过微信捐赠,请用微信客户端扫描下方赞赏码:

如果您希望通过比特币或以太币捐赠,可以扫描下方二维码:

比特币:

以太币:

如果您喜欢通过微信浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:
本站Powered by Digital Ocean VPS。
选择Digital Ocean VPS主机,即可获得10美元现金充值,可 免费使用两个月哟! 著名主机提供商Linode 10$优惠码:linode10,在 这里注册即可免费获 得。阿里云推荐码: 1WFZ0V立享9折!


View Tony Bai's profile on LinkedIn
DigitalOcean Referral Badge

文章

评论

  • 正在加载...

分类

标签

归档



View My Stats