七、行为驱动开发

行为驱动开发(BDD)可以理解为 测试驱动开发 ( TDD )和验收测试的演进和合流。这两种技术在前面的章节中已经讨论过了,所以在继续之前,您可能需要回顾一下第 1 章开始测试、第 6 章练习测试驱动开发、

行为驱动开发引入了一些新概念,比如使用通用词汇来描述测试,以及在软件开发项目中包含业务参与者,比如产品所有者或业务分析师。

我们以前访问过测试驱动开发,我们专注于将低级需求转换成能够驱动我们开发过程的测试。行为驱动开发迫使我们专注于更高层次的需求,通过使用特定的词汇,我们可以用一种可以进一步分析或评估的方式来表达这些需求。有人认为 BDD 只是 TDD 做对的哲学。

我们将通过例子来探讨这些概念,这样你就可以得出自己的结论。

给定,何时,然后

给定/何时/然后词汇是跨越商业和技术鸿沟的通用词汇,正如在http://behaviour-driven.org所描述的,它们也可以被称为无处不在的行为驱动开发语言。框架基于以下三个核心原则,我们在此逐字复制:

  • 业务和技术应该以同样的方式引用同一个系统
  • 任何系统都应该对业务有一个确定的、可验证的价值
  • 前期分析、设计和规划都有递减的回报

行为驱动开发依赖于这种特定词汇的使用。此外,需求表达的格式是预先确定的,允许工具解释和执行它们:

  • 给定:这是描述在接受外部刺激之前的初始状态。
  • :这个是描述用户执行的关键动作。
  • 然后:这个是分析动作的结果。可以观察到,所执行的动作应该有某种结果。

健身

FitNesse 是一个软件开发协作工具,可以用来管理 BDD 场景。严格来说,FitNesse 是一套工具,描述如下:

  • 作为一个软件测试工具,FitNesse 是一个轻量级的开源框架,允许团队协作
  • 它也是一个维基,你可以轻松地创建、编辑页面和共享信息
  • web 服务器,因此不需要额外的配置或管理权限来设置或配置

http://www.fitnesse.org下载 FitNesse 发行版。这个发行版是一个 JAR 文件,它会在第一次运行时自行安装。在这些例子中,我们使用了 FitNesse 独立版本 20140901,但是更新的版本也应该可以工作。

从命令行运行 FitNesse

默认情况下,当 FitNesse 运行时,它监听端口 80,所以要以非特权方式运行,您应该在命令行上更改端口。在这个例子中,我们使用8900:

$ java -jar fitnesse.jar -p 8900

这是我们运行命令时获得的输出:

Bootstrapping FitNesse, the fully integrated standalone wiki and acceptance testing framework.
root page: fitnesse.wiki.fs.FileSystemPage at ./FitNesseRoot#latest
logger: none
authenticator: fitnesse.authentication.PromiscuousAuthenticator
page factory: fitnesse.html.template.PageFactory
page theme: fitnesse_straight
Starting FitNesse on port: 8900

一旦运行,您可以将您的浏览器指向本地 FitNesse 服务器主页(http://localhost:8900/FrontPage),您将看到以下内容:

Running FitNesse from the command line

创建温度转换器子网站

一旦 FitNesse 启动并运行,我们可以从创建一个子网站来组织我们的测试开始。你可能已经熟悉了维基的概念。如果不是,维基是一个允许用户编辑和创建页面的网站。这个编辑过程是在浏览器中完成的,并且使用了一种标记语言,大大简化了这个过程。

你可以在 http://en.wikipedia.org/wiki/Wiki T2 最著名的维基中找到更多关于维基的信息。

虽然这个子网站组织不是强制性的,但是强烈推荐,特别是如果您计划在多个项目上使用 FitNesse 进行验收测试。

最简化的过程之一是超链接创建,这仅通过使用camelocaseWikiWords 来完成;这是一个以大写字母开头的单词,其中至少有一个或多个大写字母。这个维基单词将被转换成一个网页的超链接,并以此命名。

要创建温度转换器子界面,我们只需按下 FitNesse 徽标右侧的编辑按钮,即可编辑主页,添加以下内容:

| '''My Tests''' |
| TemperatureConverterTests | ''Temperature Converter Tests'' |

这将使用“|”标记作为第一个字符并分隔列,从而向页面添加一个新表。

我们还添加了一个列,其中包含关于测试的描述性注释。这个注释用两个单引号(')括起来,变成斜体。本文将创建一个名为TemperatureConverterTests的维基链接。

保存,页面将被修改。

一旦页面显示出来,我们就可以验证TemperatureConverterTests后面是【?】(问号)因为页面还没有创建,而且会在我们点击的时候创建。现在点击它,这使我们直接进入新页面的编辑模式。我们可以添加一些注释来清楚地标识这个新创建的子网站首页:

!contents -R2 -g -p -f -h

This is the !-TemperatureConverterTests SubWiki-!.

在这里,使用!--!对文本TemperatureConverterTests SubWiki进行转义,以防止其被转换为另一个页面链接。

再次按保存

向子网站添加子页面

现在,我们通过使用页面标题旁边的【添加】链接来添加一个新的子页面。

创建子页面有不同的选项,我们可以选择:

  • 静态:这是一个普通的维基页面
  • 套件:这是一个包含组成套件的其他测试的页面
  • 测试:这是一个包含测试的页面

我们将选择添加一个套件页面,并将其称为TemperatureConverterTestSuite,如下图所示:

Adding child pages to the subwiki

按下保存后,该页面被创建并自动添加为TemperatureConverterTests子网站的链接。

让我们沿着这个新创建的链接到达测试套件页面。

一旦你在这里,使用【添加】 | 【测试页】添加另一个孩子。这增加了一个测试页面,我们将其命名为TemperatureConverterCelsiusToFahrenheitFixture,,因为这将包含我们的夹具。这里的命名只是组织我们维基的一个惯例。

点击保存完成操作。

增加验收测试夹具

直到现在,我们还只是在创建维基页面。这没什么好激动的!现在,我们将把验收测试夹具直接添加到页面中。确认您已经导航到新添加的页面TemperatureConverterCelsiusToFahrenheitFixture,点击编辑,并用以下内容替换<test page>:

!contents

!|TemperatureConverterCelsiusToFahrenheitFixture          |
|celsius|fahrenheit?                                      |
|0.0    |~= 32                                            |
|100.0  |212.0                                            |
|-1.0   |30.2                                             |
|-100.0 |-148.0                                           |
|32.0   |89.6                                             |
|-40.0  |-40.0                                            |
|-273.0 |~= -459.4                                        |
|-273   |~= -459.4                                        |
|-273   |~= -459                                          |
|-273   |~= -459.40000000000003                           |
|-273   |-459.40000000000003                              |
|-273   |-459.41 < _ < -459.40                            |
|-274.0 |Invalid temperature: -274.00C below absolute zero|

该表为我们的测试特性定义了几个项目:

  • TemperatureConverterCelsiusToFahrenheitFixture:这是表格标题和测试夹具名称。
  • celsius:这是我们作为测试输入提供的值的列名。
  • fahrenheit?:这是作为转换结果的预期值的列名。问号表示这是一个结果值。
  • ~=:这表示结果大约是这个值。
  • < _ <:表示期望值在此范围内。
  • Invalid temperature : -绝对零度以下 274.00°C 是转换失败预期的值。

点击保存保存该内容。

添加支持测试类

如果我们按下测试按钮,该按钮位于 FitNesse 标志下方(详见下面的截图),我们将收到一个错误。在某种程度上,这是意料之中的,因为我们还没有创建支持测试的夹具。测试夹具将是一个非常简单的类,调用TemperatureConverter类方法。

FitNesse 支持以下两种不同的测试系统:

  • fit :这是两种方法中较老的一种,使用 HTML,在调用夹具之前解析
  • 苗条:这是更新;所有的桌子处理都是在 FitNesse 内完成的,也就是在纤细的滑道内

关于这些测试系统的更多信息可以在 http://fitnesse.org/FitNesse.找到用户指南。编写接受测试。测试系统

在本例中,我们使用 slim,在同一页面中设置变量TEST_SYSTEM,如下所示:

!define TEST_SYSTEM {slim}

现在,我们将创建纤细的测试夹具。请记住,文本夹具是一个简单的类,它允许我们从 FitNesse 测试套件中运行已经编写好的温度转换代码。我们在现有项目TemperatureConvertercore模块内创建了一个名为com.blundell.tut.fitnesse.fixture的新包。我们将在这个包内创建夹具。

接下来,我们必须创建TemperatureConverterCelsiusToFahrenheitFixture类,我们在验收测试表中定义了该类:

public class TemperatureConverterCelsiusToFahrenheitFixture {
    private double celsius;
    public void setCelsius(double celsius) {
        this.celsius = celsius;
    }
    public String fahrenheit() throws Exception {
        try {
            double fahrenheit = TemperatureConverter
.celsiusToFahrenheit(celsius);
            return String.valueOf(fahrenheit);
        } catch (RuntimeException e) {
            return e.getLocalizedMessage();
        }
    }
}

提醒一下,完成后应该是这样的:

Adding the supporting test classes

这个夹具应该委托给我们的真实代码,而不是自己做任何事情。我们决定从fahrenheit(),返回String,这样我们就可以用同样的方法返回Exception消息。

此时,运行核心模块测试以确保没有破坏任何东西(并为以后编译新创建的类)。

在 FitNesse 测试页面上,我们还应该定义测试所在的包。这允许用 FitNesse 编写的测试找到在我们的 Android 项目中编写的测试夹具。在我们仍在编辑的同一页面中,添加:

|import|
|com.blundell.tut.fitnesse.fixture|

现在,我们将安卓项目类文件添加到 FitNesse 测试的路径中。这允许 FitNesse 使用我们新编写的测试夹具和我们的TemperatureConverter;测试中的代码:

!path /Users/blundell/AndroidApplicationTestingGuide/core/build/classes/test
!path /Users/blundell/AndroidApplicationTestingGuide/core/build/classes/main

这应该适合您的系统路径。这里的要点是/core/之后的路径。这是指编译后的*.class文件在测试应用中的位置。注意,我们需要分别添加测试源和项目源。

完成这些步骤后,我们可以点击测试按钮运行测试,下面的截图会反映结果:

Adding the supporting test classes

我们可以很容易地通过绿色识别每一个成功的测试,通过红色识别失败的测试。在这个例子中,我们没有任何失败,所以一切都是绿色的。注意,它还显示了我们声明的classpathTEST_SYSTEM变量。

FitNesse 还有另一个有用的功能,测试历史。所有的测试运行和特定数量的结果都会保存一段时间,以便您可以稍后查看结果并进行比较,从而分析您的更改的演变。

点击位于列表底部的测试 历史,在顶部菜单的工具下,进入该功能。

在下面的截图中,我们可以看到最近 3 次测试运行的结果,其中 2 次失败,1 次成功。同样通过点击 + (加号)或 - (减号),您可以展开或折叠视图以显示或隐藏关于测试运行的详细信息:

Adding the supporting test classes

吉文禅

GivWenZen 是一个建立在 FitNesse 和 Slim 之上的框架,允许用户利用行为驱动的开发技术来表达,使用给定-当-然后词汇来描述测试。这些测试描述也是使用 FitNesse wiki 工具创建的,将测试表示为包含在 wiki 页面的表格中的纯文本。

这个想法简单明了,并延续了我们一直在使用 FitNesse 做的事情,但这次我们将使用三个行为驱动开发的神奇词汇给定-何时-然后,来描述我们的场景,而不是编写给出一个值表的验收测试。

首先,让我们用 GivWenZen 安装 FitNesse。在http://goo.gl/o3Hlpo从其下载列表页面下载完整发行版。一旦解压,GivWenZen JAR 的启动方式与 FitNesse 完全相同(因为它只是顶部的一个修改):

$ java -jar /lib/fitnesse.jar -p 8900

进一步阅读,综合说明和例子可以在https://github.com/weswilliams/GivWenZen/wiki的维基上找到。我们在这些例子中使用了 GivWenZen 1.0.3,但是更新的版本应该也可以。

GivWenZen 完整的发行版包含了所有需要的依赖项,包括 FitNesse,所以如果您让 FitNesse 从前面的例子中运行,最好停止它,因为您必须为 GivWenZen 使用不同的 JAR。

启动时,将您的浏览器指向主页,您会发现一个熟悉的 FitNesse 首页,或者如果您已经像我们之前所做的那样配置了端口,请在http://localhost:8900/GivWenZenTests查看一些测试。您可以花一些时间来探索包含的示例。

创建测试场景

让我们为我们的温度转换器创建一个简单的场景,这样我们就可以更好地理解给定时间。

作为一个简单的英语句子,我们的场景是:

给定我正在使用温度转换器,我在摄氏场输入 100,然后我在华氏场获得 212。

通过将该值添加到维基页面,该值被直接转换为 GivWenZen 场景:

-|script|
|given|I'm using the !-TemperatureConverter-!|
|when |I enter 100 into the Celsius field|
|then |I obtain 212 in the Fahrenheit field|

翻译很简单。表格标题必须是script,在这种情况下,它前面有一个破折号(-)来隐藏它。然后每个给定-当-然后场景被放在一列,谓词在另一列。

在运行这个脚本之前,当整个页面被执行时,我们需要通过运行另一个脚本来初始化 GivWenZen。您可以通过将其添加到 wiki 页面的顶部来实现这一点:

|import|
|org.givwenzen|
-|script|
|start|giv wen zen for slim|

我们还需要初始化类路径,并为所有脚本添加相应的导入。通常,这是在其中一个 SetUp 页面中完成的,它在运行每个测试脚本之前执行(就像 JUnit 测试中的setUp()方法一样),但是为了简单起见,我们将初始化添加到同一个页面中:

!define TEST_SYSTEM {slim}

!path ./target/classes
!path ./target/examples
!path ./lib/clover-2.6.1.jar
!path ./lib/commons-logging.jar
!path ./lib/commons-vfs-1.0.jar
!path ./lib/dom4j-1.6.1.jar
!path ./lib/fitnesse.jar
!path ./lib/guava-18.0.jar
!path ./lib/javassist.jar
!path ./lib/log4j-1.2.9.jar
!path ./lib/slf4j-simple-1.5.6.jar
!path ./lib/slf4j-api-1.5.6.jar
!path ./givwenzen-20150106.jar
!path /Users/blundell/AndroidApplicationTestingGuide/core/build/classes/test
!path /Users/blundell/AndroidApplicationTestingGuide/core/build/classes/main

您将需要更改最后两个路径变量以匹配您的TemperatureConverter项目;你以后会明白为什么你需要这些。

如果此时通过点击测试按钮运行测试,您将收到以下信息:

__EXCEPTION__:org.givwenzen.DomainStepNotFoundException:

表中的第二列,对于我们的测试大纲,保存了域步骤,因此,异常DomainStepNotFound。您需要一个步骤类,它带有一个与这个模式匹配的注释方法:“我正在使用温度转换器”。

此错误的典型原因如下:

  • StepClass不见了:这是我们的错误
  • StepClass缺少@DomainSteps注释
  • StepMethod缺少@DomainStep注释
  • StepMethod注释的正则表达式与您编写的当前测试步骤不匹配

一个示例步骤类可以是:

 @DomainSteps
 public class StepClass {
 @DomainStep("I'm using the TemperatureConverter")
 public void domainStep() {
 // TODO implement step by invoking your own code
 }
}

step 类应该放在bdd.steps的包或子包中,或者您也可以定义自己的自定义包。这个包将存在于我们应用的/core/test/模块中。如果你注意到了,在 setUp 页面上面,我们在路径上添加了我们的应用,所以这个域名可以在我们构建项目之后找到。

为了在我们的项目中使用@DomainStep(s)注释,我们需要项目测试路径上的 GivWenZen JAR。这可以通过将givwenzen.jar文件复制到/core/libs,中来实现,或者通过将它作为远程依赖项添加到build.gradle中来更好地实现:

testCompile 'com.github.bernerbits:givwenzen:1.0.6.1'

类型

你会注意到这个testCompile依赖并不是官方的 GivWenZen 版本,而是有人分叉(复制)了代码,并上传了它。这对于我们来说暂时无关紧要,因为我们只使用了两个注释类(我知道这两个类在这个版本中是相同的),但是值得记住的是,如果 GivWenZen 库曾经作为一个远程依赖项发布过,那么就应该恢复到最初的 GivWenZen 库。

遵循小大纲示例,在我们的特定情况下StepClass的实现将是:

package bdd.steps.tc;

import com.blundell.tut.TemperatureConverter;

import org.givwenzen.annotations.DomainStep;
import org.givwenzen.annotations.DomainSteps;

@DomainSteps
public class TemperatureConverterSteps {

    private static final String CELSIUS = "Celsius";
    private static final String FAHRENHEIT = "Fahrenheit";
    private static final String UNIT_NAME 
= "(" + CELSIUS + "|" + FAHRENHEIT + ")";
    private static final String ANY_TEMPERATURE 
= "([-+]?\\d+(?:\\.\\d+)?)";

    private double inputTemperature = Double.NaN;

    @DomainStep("I(?: a|')m using the TemperatureConverter")
    public void createTemperatureConverter() {
        // do nothing
    }

    @DomainStep("I enter " + ANY_TEMPERATURE 
 + " into the " + UNIT_NAME + " field")
    public void setField(double inputTemperature, String unitName) {
        this.inputTemperature = inputTemperature;
    }

    @DomainStep("I obtain " + ANY_TEMPERATURE 
  + " in the " + UNIT_NAME + " field")
    public boolean verifyConversion(double expectedTemperature, String unitName) {
        double outputTemperature = convertInputInto(unitName);
        return Math.abs(outputTemperature - expectedTemperature) < 0.01D;
    }

    private double convertInputInto(String unitName) {
        double convertedInputTemperature;
        if (CELSIUS.equals(unitName)) {
            convertedInputTemperature = getCelsius();
        } else if (FAHRENHEIT.equals(unitName)) {
            convertedInputTemperature = getFahrenheit();
        } else {
            throw new RuntimeException("Unknown conversion unit" + unitName);
        }
        return convertedInputTemperature;
    }

    private double getCelsius() {
        return TemperatureConverter.fahrenheitToCelsius(inputTemperature);
    }

    private double getFahrenheit() {
        return TemperatureConverter.celsiusToFahrenheit(inputTemperature);
    }
}

在这个例子中,我们使用的是bdd.steps的子包,因为默认情况下,这是 GivWenZen 搜索 step 实现的包层次结构。否则,需要额外的配置。

实现步骤的类应该用@DomainSteps,注释,每个步骤的方法应该用@DomainStep注释。每个步骤方法注释都接收一个字符串正则表达式作为参数。GivWenZen 使用这个正则表达式来匹配这些步骤。

例如,在我们的场景中,我们定义了这个步骤:

I enter 100 into the Celsius field

我们的注释如下:

@DomainStep("I enter " + ANY_TEMPERATURE 
  + " into the " + UNIT_NAME + " field")

这将匹配,并且将获得由ANY_TEMPERATUREUNIT_NAME定义的正则表达式组值,并将其作为参数的valueunitName提供给该方法:

public void setField(double inputTemperature, String unitName) 

回想一下,在上一章中,我建议回顾一下正则表达式,因为它们可能很有用。这可能是它们非常有用的地方之一。它允许灵活使用英语。这里I(?: a|'m)用来允许我是我是。在ANY_TEMPERATURE,中,我们将每个可能的温度值与可选的符号和小数点进行匹配。因此UNIT_NAME与单位名称匹配;也就是摄氏或华氏温度。

这些正则表达式用于构造@DomainStep注释参数。这些正则表达式中由()括号分隔的组被转换为方法参数。setField()就是这样获得参数的。

然后我们有一个verifyConversion()方法,根据实际转换是否与预期的匹配,在两个小数位的差异内返回真或假。

最后,我们有一些方法实际上调用了TemperatureConverter类中的转换方法。

再次运行测试时,所有测试都通过了。我们可以通过分析输出消息来确认这一点:

Assertions: 1 right, 0 wrong, 0 ignored, 0 exceptions.

我们不仅应该为正常情况创建场景,还应该涵盖异常情况。简单地说,我们的场景如下:

假设我使用的是温度转换器,当我在摄氏度字段输入-274时,我会得到一个无效温度:-绝对零度以下 274.0 摄氏度异常。

它可以被翻译成如下的 GivWenZen 表:

-|script|
|given|I am using the !-TemperatureConverter-!             |
|when |I enter -274 into the Celsius field                 |
|then |I obtain 'Invalid temperature: -274.00C below absolute zero' exception|

通过添加一个单独的支持步骤方法,我们将能够运行它。step 方法可以这样实现:

    @DomainStep("I obtain '(Invalid temperature: " + ANY_TEMPERATURE + " C|F below absolute zero)' exception")
    public boolean verifyException(String message, String value, String unit) {
        try {
          if ( "C".equals(unit) ) {
            getFahrenheit();
          } else {
            getCelsius();
          }
        } catch (RuntimeException ex) {
          return ex.getMessage().contains(message);
        }
        return false;
      }

此方法从正则表达式中获取异常消息、温度值和单位。然后将其与实际的异常消息进行比较,以验证它是否匹配。

不要忘记,当您将 Java 代码添加到您的StepClass注释中时,您将需要再次编译该类,以便 FitNesse 可以使用新代码。一种方法是从集成开发环境中运行您的 Java 测试,强制重新编译。

此外,我们可以创建其他场景,在这种情况下,将由现有步骤的方法支持。这些场景可能是:

-|script|
|given |I'm using the !-TemperatureConverter-!   |
|when  |I enter -100 into the Celsius field      |
|then  |I obtain -148 in the Fahrenheit field    |

-|script|
|given |I'm using the !-TemperatureConverter-!   |
|when  |I enter -100 into the Fahrenheit field   |
|then  |I obtain -73.33 in the Celsius field     |

-|script|
|given|I'm using the !-TemperatureConverter-!          |
|when |I enter -460 into the Fahrenheit field          |
|then |I obtain 'Invalid temperature: -460.00F below absolute zero' exception|

因为 GivWenZen 是基于 FitNesse 的,所以我们可以自由地将这两种方法结合起来,并将我们上一次会议的测试包含在同一个套件中。这样,我们可以从套件页面运行整个套件,获得如下总体结果:

Creating the test scenario

总结

在这一章中,我们发现行为驱动开发是测试驱动开发的一个开发,我们在前面的章节中已经讨论过了。

我们讨论了行为驱动开发背后的驱动力。我们分析了作为基础的概念,探索了“给定时间然后”的词汇概念,并介绍了 FitNesse 和 Slim 作为部署测试的有用工具。

我们展示了 GivWenZen,一个基于 FitNesse 的工具,它让我们能够创建接近英语的散文式场景,并对它们进行测试。

我们将这些技术和工具引入了我们的示例安卓项目。然而,我们仍然局限于在 JVM 下可测试的测试主题,避免使用安卓特定的类和用户界面。我们将在第 9 章替代测试策略中探索一些替代方案来克服这一限制。

下一章讨论测试的另一个方面,集中在性能和概要分析上,这是在我们的应用按照我们的测试规范正确运行之后自然要遵循的步骤。