前言

如果你关注软件开发最佳实践方面的话题,你肯定听说过测试驱动开发(TDD - Test Driven Development) 和行为驱动开发(BDD - Behavior Driven Development)。这篇文章会为你阐述这两种模式的含义并举例,同时对二者进行比较。

测试驱动开发 (TDD)

Test-Driven Development(TDD)即测试驱动开发,它是一种测试先于编写代码的思想用于指导软件开发。测试驱动开发是敏捷开发中的一项核心实践和技术,也是一种设计方法论。TDD的原理是在开发功能代码之前,先编写单元测试用例代码,测试代码确定需要编写什么产品代码。

如果对TDD作进一步的解释,这个过程可以进一步分解为5个步骤:

  1. 首先,开发者在码业务前写一些测试用例
  2. 运行这些测试用例。结果肯定是运行失败,因为测试用例中的业务逻辑还没实现嘛
  3. 开发者实现测试用例中的业务逻辑
  4. 再运行测试用例, 如果开发者代码能力不错,这些测试用例应该可以跑通了(pass)
  5. 对业务代码及时重构,包括增加注释,清理重复等。因为没人比开发者自己更了解哪些代码会对哪些部分造成影响从而导致测试失败(fail)

当需要开发新需求新功能时,重复上述步骤。流程如下图所示:

特点:

  • 有利于更加专注软件设计;
  • 清晰地了解软件的需求;
  • 很好的诠释了代码即文档。

TDD举例

我们通过举例来了解一下如何实践TDD。测试一个 routine 分几个步骤:准备数据,调用 routine,判断返回。还要测试不同的情况。如果每种情况都手工写一次代码的话,会很繁琐,使用 Table Driven 的方式能让测试代码看起来简洁易懂不少。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import "github.com/stretchr/testify/assert"
func TestMod(t *testing.T) {
    tests := []struct {
        a int
        b int
        r int
        hasErr bool
    }{
        {a: 42, b: 9, r: 6, hasErr: false},
        {a: -1, b: 9, r: 8, hasErr: false},
        {a: -1, b: -9, r: -1, hasErr: false},
        {a: 42, b: 0, r: 0, hasErr: true},
    }

    for row, test := range tests {
        r, err := Mod(test.a, test.b)
        if test.hasError {
            assert.Error(t, err, "row %d", row)
            continue
        }
        assert.NoError(t, err, "row %d", row)
        assert.Equal(t, test.r, r, "row %d", row)
    }
}

显然上述测试会失败,因为我们尚未实现函数功能。所以接下来我们需要实现满足上述测试用例的函数。代码如下:

1
2
3
4
5
6
func Mod(a, b int) (r int, err error) {
    if b == 0 {
        return 0, fmt.Errorf("mod by zero")
    }
    return a%b, nil
}

现在我们再次运行测试用例,所有的case都跑通了! 这就是TDD的使用方式。

TDT

表格驱动测试是一种编写易于扩展测试用例的测试方法。表格驱动测试在 Go 语言中很常见(并非唯一),以至于很多标准库都有使用。表格驱动测试使用匿名结构体。

我们针对函数 Sqrt 进行测试,其实现为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Sqrt calculate the square root of a non-negative float64
// number with max error of 10^-9. For simplicity, we don't
// discard the part with is smaller than 10^-9.
func Sqrt(x float64) float64 {
  if x < 0 {
    panic("cannot be negative")
  }

  if x == 0 {
    return 0
  }

  a := x / 2
  b := (a + 2) / 2
  erro := a - b
  for erro >= 0.000000001 || erro <= -0.000000001 {
    a = b
    b = (b + x/b) / 2
    erro = a - b
  }

  return b
}

这里我们使用了一个常规的方法实现 Sqrt,该实现的最大精确度是到小数点后9位(为了方便演示,这里没有对超出9位的部分进行删除)。

来看下如何在 table driven test 中使用 require。这里我们测试的传入常规参数的情况,代码实现如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
func TestSqrt(t *testing.T) {
  testcases := []struct {
    desc   string
    input  float64
    expect float64
  }{
    {
      desc:   "zero",
      input:  0,
      expect: 0,
    },
    {
      desc:   "one",
      input:  1,
      expect: 1,
    },
    {
      desc: "a very small rational number",
      input: 0.00000000000000000000000001,
      expect: 0.0,
    },
    {
      desc:   "rational number result: 2.56",
      input:  2.56,
      expect: 1.6,
    },
    {
      desc:   "irrational number result: 2",
      input:  2,
      expect: 1.414213562,
    },
  }

  for _, ts := range testcases {
    got := Sqrt(ts.input)
    erro := got - ts.expect
    require.True(t, erro < 0.000000001 && erro > -0.000000001, ts.desc)
  }
}

在上面这个例子,有三点值得注意:

  • 匿名struct 允许我们填充任意类型的字段,非常方便于构建测试数据集;
  • 每个匿名struct都包含一个 desc string 字段,用于描述该测试要处理的状况。在测试运行失败时,非常有助于定位失败位置;
  • 使用 require 而不是 assert,因为使用 require 时,测试失败以后,所有测试都会停止执行。

行为驱动开发 (BDD)

Behavior Driven Development,行为驱动开发是一种敏捷软件开发的技术,它鼓励软件项目中的开发者、QA和非技术人员或商业参与者之间的协作。它对TDD的理念进行了扩展,在TDD中侧重点偏向开发,通过测试用例来规范约束开发者编写出质量更高、bug更少的代码。而BDD更加侧重设计,其要求在设计测试用例的时候对系统进行定义,倡导使用通用的语言将系统的行为描述出来,将系统设计和测试用例结合起来,从而以此为驱动进行开发工作。

一般在TDD的需求挖掘,分发和使用流程情况下,应该是下面的样子。

那么,通过这张图,我想您一定能立马发现,所有的需求流动和维护都是单方向的,而我们知道,软件的需求其实就是软件的目标,就是我们应该交付的产品,是我们应该要做的正确的事情。

而对于用户的需求而言,有的时候其实是很复杂的,有的时候客户在提出某一想法的时候,其实压根自己也不知道最终需要一个什么产品,只是大概模糊的知道需要实现一个功能。

而且客户的想法和最终实现这个产品的开发人员做出来的东西最终肯能会不太一样,因为开发人员可能已经开始根据最初的需求文档已经把代码实现了,QA 也把测试用例写好了。

但是 QA 根据需求文档写出的测试用例和开发人员开发出来的产品的可能根本匹配不上,好多的工作就这样白白浪费了,于是团队成员抱怨了。

另外,谁有能保证业务人员把需求文档写出来后,没有歪曲和误解商务人员告诉给他的需求和想法,开发人员能通过文档把业务分析人员写的东西全部理解透吗?有的时候业务需求文档,真的不是特别的有趣,没有例子,比较抽象,有歧义。

那有没有一种媒介,可以让大家及时的,基于同一个平台的交流,而且用于交流的媒介,对于需求的描述也非常的生动,会根据以后软件的行为进行分类,并提供一些生动的例子呢? 下面我们看看 BDD 会如何做。

通过对比,大家是不是发现 BDD 的这种方式,把客户,业务分析人员,开发人员,测试人员,文档工程师,通过特性文件(Feature File)真正的联系在一起了,其沟通是顺畅的,QA,BA,开发,测试,客户,用户可以通过这一媒介,进行高效无障碍的沟通,而不是像传统的方式,通过BA进行二次转达,从而丢失了很多重要的需求。

由此可见,其BDD的好处如下:

  • 减少浪费
  • 节省成本
  • 容易并且安全的适应变化
  • 因为少了中间的转达环节,从而能够快速交付产品

假设我们需要开发一个计算器,里面有一个加法的运算,那应该如何描述,才能让所有参与项目的人都能看懂呢?下面就是其中的一种写法。

上面的文件,其实叫 Feature(特性文件)。其使用了 Gherkin 语法。Gherkin语法就是使用 Given,when,then等关键字词来描述一个用户故事(User Story)。形成一份不论是客户,业务分析人员,测试,还是开发,都能读懂的文件格式。

需要说明的是,请大家注意左边的红色的关键字,Feature,Scenario,Given,When,And,Then; 这些关键字其实就是 Gherkin 语法定义的标准关键字,其主要的关键字如下。

英文关键字 中文关键字
feature “功能”
background “背景”
scenario “场景”, “剧本”
scenario_outline “场景大纲”, “剧本大纲”
examples ”例子”
given ”假如”, “假设”, “假定”
when ”当”
then ”那么”
and ”而且”, “并且”, “同时”
but “但是”

BDD举例

我们实现一个判断两个字符串切片是否相等的函数StringSliceEqual,主要逻辑包括:

  • 两个字符串切片长度不相等时,返回false
  • 两个字符串切片一个是nil,另一个不是nil时,返回false
  • 遍历两个切片,比较对应索引的两个切片元素值,如果不相等,返回false
  • 否则,返回true

根据上面的逻辑,代码实现如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
func StringSliceEqual(a, b []string) bool {
    if len(a) != len(b) {
        return false
    }

    if (a == nil) != (b == nil) {
        return false
    }

    for i, v := range a {
        if v != b[i] {
            return false
        }
    }
    return true
}

测试代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import (
    "testing"
    . "github.com/smartystreets/goconvey/convey"
)

func TestStringSliceEqual(t *testing.T) {
    Convey("TestStringSliceEqual", t, func() {
        Convey("should return true when a != nil  && b != nil", func() {
            a := []string{"hello", "goconvey"}
            b := []string{"hello", "goconvey"}
            So(StringSliceEqual(a, b), ShouldBeTrue)
        })

        Convey("should return true when a == nil  && b == nil", func() {
            So(StringSliceEqual(nil, nil), ShouldBeTrue)
        })

        Convey("should return false when a == nil  && b != nil", func() {
            a := []string(nil)
            b := []string{}
            So(StringSliceEqual(a, b), ShouldBeFalse)
        })

        Convey("should return false when a != nil  && b != nil", func() {
            a := []string{"hello", "world"}
            b := []string{"hello", "goconvey"}
            So(StringSliceEqual(a, b), ShouldBeFalse)
        })
    })
}

看出TDD和BDD的区别了吗?其实就是措辞。BDD的描述采用了更加’繁琐’的描述风格,阅读BDD的测试用例就像是阅读一篇文档。

这正是为什么我说BDD旨在消除TDD过程中可能造成的问题的原因所在。BDD赋予的这种像阅读句子一样阅读测试的能力有助于带来对测试认知上的转变,有助于我们去考虑如何更好写测试。当你可以流畅的阅读自己写的测试,你自然可以写出更好更全面的测试用例。

尽管上面的举例非常简单,当我们可以看出:BDD更注重功能本身而非单纯的测试用例运行结果。这也是我们经常听到的一句关于BDD本质的另外一种表达方式:BDD帮助开发人员设计(design)软件,TDD帮助开发人员测试(test)软件。

TDD vs BDD

通过下面一幅图就可以发现对于测试也有不同的层次和流程:

从图中可以发现,最下面的是单元测试(白盒测试),主要用于测试开发人员编写的代码是否正确,这部分工作都是开发人员自己来做的。通常而言,一个单元测试是用于判断某个特定条件(或者场景)下某个特定函数的行为。再往上,就是BDD(灰盒测试、黑盒测试),主要用于测试代码是否符合客户的需求,这里的BDD更加侧重于代码的功能逻辑。

从左边的范畴也可以看出,测试的范围也是逐层扩大,从单元测试的类到BDD里面的服务、控制器等,再到最上层的模拟实际操作场景的Selenium(Selenium也是一个用于Web应用程序测试的工具。Selenium测试直接运行在浏览器中,就像真正的用户在操作一样。支持的浏览器包括IE(7、8、9)、Mozilla Firefox、Mozilla Suite等。)对于包括UI界面的测试。

在TDD和BDD之间做选择不是件容易的事。以下有几条建议:

  • 简单的一次性项目,沟通交流成本都较低的情况下,没有必要使用BDD;
  • 业务比较轻量,重在技术方面的项目,可以只使用TDD,或者简单的白板上的BDD,不需要在BDD工具记录需求用例文档;
  • 业务复杂、团队成员较多的项目,沟通成本高,BDD很有必要。

参考: https://blog.csdn.net/Napoleonxxx/article/details/88808475 https://cloud.tencent.com/developer/article/1021203 https://www.jianshu.com/p/e3b2b1194830 http://insights.thoughtworkers.org/when-we-talk-about-bdd/