2003年,Dan North首先提出了BDD的概念,并在随后开发出了JBehave框架。在Dan North博客上介绍BDD的文章中,说到了BDD的想法是从何而来。简略了解一下BDD的历史和背景,有助于我们更好地理解。
Dan在使用TDD敏捷实践时,时常会有很多同样的困惑萦绕脑海,这也是很多程序员敏捷实践都想知道的:
当Dan用上了一位同事编写的小框架agiledox时,灵感闪现!这个框架其实很简单,它基于JUnit测试框架,根据测试类名和方法名,将每个测试方法都打印为类似文档的输出。程序员们意识到这个小玩具可以帮它们做一些文档性的工作,于是就开始用商业领域语法命名他们的类和方法,让agiledox产生的输出能直接被商业客户、分析师、测试人员都看懂!
// CustomerLookup
// - finds customer by id
// - fails for duplicate customers
// - ...
public class CustomerLookupTest extends TestCase {
testFindsCustomerById() {
...
}
testFailsForDuplicateCustomers() {
...
}
...
}
此时,恰逢Eric Evans发表了畅销书DDD(领域驱动设计),其中描述了为系统建模时,使用一种基于商业领域模型的Ubiquitous Language,让业务词汇渗透到代码中。于是,Dan决定定义一种分析师、测试人员、开发者、业务人员、用户都能懂的”Ubiquitous Language”。
Feature: <description>
As a <role>
I want <feature>
So that <business value>
Scenario: <description>
Given <some initial context>,
When <an event occurs>,
Then <ensure some outcomes>.
就这样,BDD的雏形就出现了!但这种类似BRD的文档是如何与我们程序员的代码结合到一起的呢?下一节我们就详细分析一下。
Feature、Scenario、Steps是BDD的三个核心概念,体现了BDD的三个重要价值:
Feature就像是文档一样,描述了功能特性、角色、以及 最重要的商业价值。
场景就是上面提到的规范Specification。Cucumber提供了Scenario、Scenario Outline两种形式。使用时要注意,在Cucumber官博上的一篇文章“Are you doing BDD? Or are you just using Cucumber?”给出了一个反模式。
Scenario Outline: Detect agent type based on contract number (single contract found)
Given I am on the "Find me" page
And I have entered a contract number
When I click the "Continue" button
And a contract number match is found
And the agent type is <DistributorType>
Then the contract number field will become uneditable
And the "Back" button will be displayed
And the following <text> and <input field type> will be displayed
Examples:
| DistributorType | input field type | text |
| Broker | Date of birth | Please enter your last name |
| TiedAgent | Last name | Please enter your date of birth |
看出来了差别吧:Scenario Outline的核心依然应该是商业规则,而不能因为它对输入和输出的细化就将重点转移到UI界面。
Scenario: Customer has a broker policy so DOB is requested
Given I have a "Broker" policy
When I submit my policy number
Then I should be asked for my date of birth
Scenario: Customer has a tied agent policy so last name is requested
Given I have a "TiedAgent" policy
When I submit my policy number
Then I should be asked for my last name
Steps就是实际编码了,我们要在Java中实现出Feature文件中各种场景对应的代码,让它变成“活文档”!
之所以选择这么一个例子来实战,是因为网上的大部分例子都很简单而且雷同。通过这个例子,也是想试验一下BDD对于“业务性”不强的而且还是分布式的系统(即基础设施或中间件)是否也能发挥作用。这次实战也是一次比较奇妙的经历,不少核心类、接口和关于系统设计的想法都在这个过程中自然涌现~
IDE当然还是选择Intellij,并且开启Cucumber插件,因为本实例是基于Cucumber实现的(其实其他的框架如JBehave都非常类似)。然后新建Maven工程,引入以下依赖包:
<dependencies>
<dependency>
<groupId>info.cukes</groupId>
<artifactId>cucumber-java</artifactId>
<version>1.2.4</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>info.cukes</groupId>
<artifactId>cucumber-junit</artifactId>
<version>1.2.4</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.12</version>
<scope>test</scope>
</dependency>
</dependencies>
Feature相对比较好写,简单描述一下功能特性就行了。比如下面的集群自动创建功能:为了自动创建集群(功能),作为用户(角色),我想结点能自动互相发现形成集群以节省手工的工作量和时间(商业价值)。
Feature: Auto Cluster Creation
In order to create a cluster automatically
As a user
I want the nodes can discover each other on their own
我们还需要一个启动类:
@RunWith(Cucumber.class)
@CucumberOptions(plugin={"pretty"}, features="src/test/resources", tags = {})
public class NodeDiscoveryStory {
}
为了简化,我只选了一个最简单的两结点集群建立的场景。首先结点1形成集群A,当结点2加入集群A后,集群中应有两个结点1和2。
Scenario: create a cluster
Given node-1 in cluster-A starts
When a new node-2 in cluster-A starts
Then cluster-A should have node: 1,2
场景的选择和编写至关重要,本例的实践过程中就碰到了一些问题,下面做一点个人的经验总结:
此外,还有关于Given和When是否要细分出一些And条件,比如本例中的Given和When就都可以分别拆成createNode和createOrJoinCluster两步,但这样的话会导致成员变量增多而显得比较乱,因为Cucumber中的Given和And、When和And之间是不能携带过去对象的。所以从下一部分的编码实现中能看出,最终我还是没有拆的那么细。
编码实现是最痛苦也最有收获的!一开始时一无所有的茫然,不断重构最终终于找到比较合理的设计。注意:代码不要跟着场景的描述走,比如变量cluster起名为clusterA,那就限定死了!我们的Steps应该是通用的,这里的Given、When都是可能用于其他场景的。
首先在@Given中启动一个Cluster加入一个Node,之后在@When中模拟在另一台机器上启动一个Node加入到集群的过程。因为实际上这个过程是在远程完成的,所以不能直接使用成员变量cluster。最后验证cluster中的结点列表,看是否已经包含两个结点。
public class MyStepdefs {
private Cluster cluster;
@Given("^node-(\\w+) in cluster-(\\w+) starts$")
public void runCluster(String nodeId, String clusterName) {
Node node = new Node(nodeId);
cluster = new Cluster(clusterName, new CoordinatorMock());
node.join(cluster);
}
@When("^a new node-(\\w+) in cluster-(\\w+) starts$")
public void startNewNodeToJoinCluster(String nodeId, String clusterName) {
Node newNode = new Node(nodeId);
Cluster clusterSlave = new Cluster(clusterName, new CoordinatorMock());
newNode.join(clusterSlave);
}
@Then("^cluster-(\\w+) should have node: (.+)$")
public void joinCluster(String clusterName, List<String> nodeIds) {
Assert.assertEquals(clusterName, cluster.getName());
List<String> actualNodeIds = new ArrayList<String>();
for (Node node : cluster.getNodes()) {
actualNodeIds.add(node.getId());
}
Collections.sort(actualNodeIds);
Assert.assertEquals(nodeIds, actualNodeIds);
}
}
对比下面典型的单元测试代码能够看出,BDD的Steps代码因为对应着Scenario,所以步骤分的比较清楚。而在普通Test Case中,Case中就会堆砌着类似@Given、@When、@Then的代码,并且每个Case都会有类似的代码。所以一般我们会提取出一些公关的代码,以使Case更为清晰,但BDD则直接更进一步。
@Test
public void testCachePut2_List() throws Exception {
CacheResult<Object> ret = redis.cachePut(CACHE_NAME,
Arrays.asList(
new Person(1, 49, "alan"),
new Person(2, 34, "hank"),
new Person(3, 38, "carter")
)
);
Assert.assertTrue(ret.isOK());
List persons = redis.cacheGetAll(CACHE_NAME, Arrays.asList(1, 3)).getValue();
Collections.sort(persons);
Assert.assertNotNull(persons);
Assert.assertEquals(2, persons.size());
...
}
下面就说一下通过这次BDD历险得到的核心类,以及是如何思考出来的。这个重构、思考、最终浮现出来的过程其实是最重要的!
最先映入脑海的就是Cluster和Node,其实Node也可以暂时用一个ID代替,之后有需要时再抽象成类,这里有些“着急”了直接新建了个Node类。
public class Cluster {
private final String name;
private List<Node> nodes = new ArrayList<Node>();
public Cluster(String name) {
this.name = name;
}
public void addNode(Node node) {
}
public String getName() {
return name;
}
public List<Node> getNodes() {
return nodes;
}
}
public class Node {
private String nodeId;
public Node(String nodeId) {
this.nodeId = nodeId;
}
public void join(Cluster cluster) {
cluster.addNode(this);
}
public String getId() {
return nodeId;
}
}
写好了@Given、@When、@Then之后,就可以跑起来Cucumber试试了,肯定是报错的。现在自然就有疑问了,@Then中的断言如何能够成功呢?所以Cluster背后需要一个能够帮助分布式通信的组件,于是就加上Coordinator接口。同时,我们创建一个Mock实现,利用static静态变量模拟网络通信的过程。
public interface Coordinator {
void register(Cluster cluster);
boolean addNode(Node node);
}
public class CoordinatorMock implements Coordinator {
/** Simulate network communication */
private static List<Cluster> clusters = new ArrayList<Cluster>();
@Override
public void register(Cluster cluster) {
clusters.add(cluster);
}
@Override
public boolean addNode(Node node) {
for (Cluster cluster : clusters) {
cluster.handleAddNode(node);
}
return true;
}
}
最后让Cluster注册到Coordinator上,调用addNode()接口模拟分布式通信,并添加handleAddNode()处理请求就可以了!这样我们就完成了BDD的一个简单实例!
public class Cluster {
private final String name;
private final Coordinator coordinator;
private List<Node> nodes = new ArrayList<Node>();
public Cluster(String name, Coordinator coordinator) {
this.name = name;
this.coordinator = coordinator;
coordinator.register(this);
}
public void addNode(Node node) {
coordinator.addNode(node);
}
public void handleAddNode(Node node) {
nodes.add(node);
}
public String getName() {
return name;
}
public List<Node> getNodes() {
return nodes;
}
}
每种新事物的产生都不可避免地会伴随着各种各样的解读,毕竟每个人都有自己的看法和理解。有的理解深刻直达本质,有的独辟蹊径另立门派,也有的是偏见和误解。BDD也一样,可能会人被当做跟TDD一样的东西,也可能会被看做测试的一种。
通过本文的介绍,大家应该能看到BDD的闪光点。它提升了TDD的粒度和抽象层次,并以统一而规范的语言作为文档,消除了软件开发中各种人员的沟通障碍。同时以实用的框架将文档与代码粘合到一起,使文档可执行化、代码文档化。