Skip to content

高级测试驱动开发

排序示例

我们尝试使用 TDD 推演出对数组中的整数进行排序的算法。

先循例写一个什么都没有的测试和一个什么都没有的生产代码。

写第一个测试。

ts
expect([]).toEqual(simpleSort([]))
ts
function simpleSort() {
    return
}

很显然,测试失败。但很容易让它通过测试。

ts
function simpleSort() {
    return []
}

很好,测试通过了。第一个测试针对空数组的基本情况。

考虑复杂点的情况。第二个测试,试一下只有一个整数的数组。

ts
expect([1]).toEqual(simpleSort([1]))

显然会失败。将生产代码写得更加通用一些,使其通过测试。

ts
function simpleSort(arr: number[]) {
    return arr
}

先返回最基础的解答,再返回输入参数,前两个测试就都通过了!

下一个测试,它直接就通过了,因为它就是按顺序排列的两个元素。

ts
expect([1, 2]).toEqual(simpleSort([1, 2]))

下一个测试,调整输入数组的顺序。测试会失败,结果输出的数组并没有按顺序排列。

ts
expect([1, 2]).toEqual(simpleSort([2, 1]))

要让测试通过,继续修改生产代码。如果发现数组里有超过一个的元素,且前两个元素的顺序有误,就做个交换。

ts
function simpleSort(arr: number[]) {
    if (arr.length > 1) {
        if (arr[0] > arr[1]) {
            const first = arr[1]
            const second = arr[0]

            arr[0] = first
            arr[1] = second
        }
    }

    return arr
}

很好,现在有四个测试,全部都通过了。你可能想象到接下来发生什么,且别急,继续往下做!

下一个测试,开始考虑有三个元素的情况。

ts
expect([1, 2, 3]).toEqual(simpleSort([1, 2, 3]))

通过。下一个测试,交换三个元素中的第一和第二元素的位置。

ts
expect([1, 2, 3]).toEqual(simpleSort([2, 1, 3]))

通过。下一个测试,交换三个元素中的第一和第三元素的位置。

ts
expect([1, 2, 3]).toEqual(simpleSort([3, 2, 1]))

失败。聪明的我们,想到将原先的生产代码放入循环中即可解决问题。

ts
function simpleSort(arr: number[]) {
  if (arr.length > 1) {
    for (let i = 0; i < arr.length; i += 1) {
      const j = i + 1

      if (arr[i] > arr[j]) {
        const first = arr[j]
        const second = arr[i]

        arr[i] = first
        arr[j] = second
      }
    }
  }

  return arr
}

还是失败!失败的测试会说话。我们的生产代码返回 [2, 1, 3],尽管 3 被移到了末尾,但是前两个元素的顺序还是不对。前两个元素需要再交换一次位置。

再添加一个循环!把对比与交换位置算法放到另一个循环中,逐步递减对比与交换列表的长度。

ts
function simpleSort(arr: number[]) {
  if (arr.length > 1) {
    for (let limit = arr.length; limit > 0; limit -= 1) {
      for (let i = 0; i < limit; i += 1) {
        const j = i + 1

        if (arr[i] > arr[j]) {
          const first = arr[j]
          const second = arr[i]

          arr[i] = first
          arr[j] = second
        }
      }
    }
  }

  return arr
}

通过。接下来我们可以做更大的测试来验证代码。

ts
expect([1, 2, 3, 4]).toEqual(simpleSort([3, 4, 2, 1]))

expect([55, 67, 98, 109, 293]).toEqual(simpleSort([293, 109, 98, 67, 55]))

都通过。看来我们的排序算法已经完成。这是什么算法呢?当然是冒泡排序。

在一开始,我们并没有一个清晰的算法实现,而通过这个例子,我们可以完整地体验到:TDD 是一种渐进式的推演算法的过程。

每进一步,测试就越内束和具体,生产代码也越来越通用。如此这般,直至代码通用至你再也想不出会失败的测试为止。

卡壳

刚开始进行 TDD 的新手常常会陷入困境:让测试通过的唯一方法是写出本该让测试推动实现的全套算法。这种情况叫做⌈卡壳⌋。

解决卡壳的方法是,删掉你刚写的测试,另写一个更容易通过的测试。

安排、行动、断言

所有测试的基本模式是 3A 模式(或者叫 AAA 模式),意思是 Arrange/Act/Assert,即安排、行动、断言。

在写测试时:

  1. 安排要测试的数据。
  2. 行动。测试调用函数,或执行操作,或调用作为测试目标的过程。
  3. 断言。查看行动的输出,确保系统处于新的所需状态。

进入 BDD

对于-当-则(Given-When-Then, GWT)是行为驱动开发(Behavior-Drivien Developement, BDD)的肇始。一开始,BDD 被视为对测试编写的改进。但是,TDD 测试与 BDD 测试功用一致。

有限状态机

每个测试都是描述系统行为的有限状态机。当执行每一个指令时,计算机本身就是在从一个有限状态转换到另一个有限状态。

测试替身

测试替身(Test Doubles)分为五种对象类型:Dummy(仿品)、Stub(占位)、Spy(间谍)、Mock(拟造)和 Fake(伪造)。

这几种测试替身构成一种类型层级的关系。Dummy 是其中最简单的。Stub 是 Dummy 的一种,Spy 是 Stub 的一种,Mock 是 Spy 的一种。Fake 则独立在外。

TDD 不确定性原理

如果你要求测试足够确定,那么测试与实现就不可避免地相互耦合,而且测试会变得脆弱。这就引出 TDD 不确定性原理:确定性越高,测试越不灵活。测试越灵活,确定性越低。

伦敦派对决芝加哥派

一方面,我们不想要既僵化又脆弱的测试;另一方面,我们又想要得到尽可能高的确定性。作为工程师,我们需要在两者之间做出权衡。

伦敦派注重确定性甚于灵活性。这种态度由外向内实践,更加关注算法而非结果。

而芝加哥派注重灵活性甚于确定性。这种态度由内向外设计,更加关注结果而非交互过程和算法。

尽管有两种流派的存在,但两派并不对立。哪种流派你更喜欢,其实都可以。

架构

当选择采用何派的做法时,可以从架构角度来考虑。比如,将系统切分为组件。组件之间的区隔称为边界。源代码依赖应当朝向更高层面跨越边界。

小结

本章深入考察了 TDD,在原有的规则上继续总结:

  1. 在开始下一个更复杂情况的测试前,穷尽简单情况测试。
  2. 如果你必须写很多实现代码才能让测试通过,删掉测试,写一个更容易通过的简单测试。
  3. 从容不迫、循序渐进地完成所有测试。
  4. 不要在测试中添加测试不需要的东西。
  5. 别在测试中使用生产环境数据。

Released under the MIT License.