前言

​ 最近在读《匠艺整洁之道-程序员的职业修养》这本书,作者鲍勃大叔开篇就用了大量的示例来讨论与演示为什么需要和如何操作测试驱动开发。我是之前写过测试,但从不知道测试如何驱动开发,大部分情况下也只是先写生产代码,写好后再测试,看看是否能调通,再修改代码中隐藏的问题。
​ 而测试驱动开发会扭转这种思维方式,是先写测试,再写对应的生产代码。这一开始让人会觉得很奇怪,感觉并且很麻烦,但当你尝试一下,就会发现这事儿挺有趣,且神奇。
​ 温馨提示:Clean Craftsmanship: Disciplines, Standards, and Ethics (Companion Videos),这个是该书示例操作的视频说明,非常非常非常有用!!! 第一篇是讲述如何通过测试驱动开发来写一个栈结构。我严重怀疑大叔是不是一边写代码一边喝啤酒。整个过程伴随大叔魔性的笑声和宛如魔法般的操作,就好像他在不停调戏编译器。你会通过这个视频感受到如何从零开始,进行测试驱动开发。
​ 下面将讲讲一些基础知识。

为什么需要测试驱动开发?

​ 就我目前的现状,我能感受到重构代码时的痛苦,在目前维护的项目中会遇到几百行的函数,整个函数要做太多的事儿,而且相互耦合在一起,如果这里的需求不变,我不会去碰它,但需求是一直变化的,我只能不断去读这些代码,然后小心翼翼的在里面修改。当上线时心惊胆战,生怕出问题。我希望有一套良好的测试能在我修改代码后告诉我哪里受到了影响,并在测试通过后,能让我安心上线。

TDD纪律

  1. 创建测试集,方便重构,并且其可信程度达到系统可部署的水平,也就是说测试集通过,系统就可以部署。
    1. 我不知道有多少人像我一样,由于老系统没有测试,导致每次上线都很害怕,担心修改引入新的问题。
  2. 创建足够解耦,可测试,可重构的代码
  3. 创建极短的反馈循环周期
  4. 创建相互解耦的测试代码和生产代码

上面的纪律需要下面展示的法则作为基础,如果没有一些技巧和知识就很难遵守这些法则。

TDD三法则

第一法则:在编写因为缺少生产代码而必然会失败的测试之前,绝不编写生产代码
第二法则:只写刚好导致失败或者通不过编译的测试,编写生产代码来解决失败的问题
第三法则: 只写刚好能解决当前测试失败问题的生产代码。测试通过后,立即写其他测试代码
整个过程看起来好像是:

  • 写一行测试代码,无法通过编译
  • 写一行生产代码,编译成功
  • 写另一行测试代码,无法通过编译
  • 再写生产代码,编译成功
  • 再写测试代码,编译能成功,但断言失败
  • 写一两行生产代码,满足断言

如此循环,贯穿始终

遵守上述法则后,有以下好处

  • 提高效率,减少调试的时间
  • 将产出一套低层次文档
  • 好玩
  • 将产出一套测试集,让你有信息部署系统
  • 将创建较少耦合的代码

    简单示例

    这里建议先看我上面提供的链接,作者是用Java写的,我这里用Ruby(核心思想是一致的)

require 'minitest/autorun'
class StackTest < Minitest::Test
  def test_nothing
    
  end
end

​ 这里我们一开始写一个什么都不做的测试,然后保证这个测试通过,这至少能说明你的环境没问题


规则1:先编写测试,逼着自己写将要写的代码


​ 我们知道我们需要一个栈

# stack_test.rb
require 'minitest/autorun'
class StackTest < Minitest::Test
  # ...
  
  def test_canCreateStack
    stack = Stack.new
  end
end

​ 这里的测试一定会失败,因为现在根本没有Stack这个类结构,所以我们写两行生产代码来通过测试

# stack.rb
class Stack
  
end

​ 这次我们在stack_test.rb引入一下,就可以通过测试了。


规则2:让测试失败,让测试通过,清理代码


​ 这时候我们发现我们还没有断言行为呢,比如当刚创建一个栈时,这个栈应该是空的。

# stack_test.rb

require 'minitest/autorun'
require_relative 'stack'
class StackTest < Minitest::Test
  def test_nothing
  end

  def test_canCreateStack
    stack = Stack.new
    assert(stack.isEmpty?)
  end
end
# => 报错:NoMethodError: undefined method `isEmpty?'

​ 我们迅速补上生产代码,来解决这个问题

# stack.rb

class Stack
  def isEmpty?
    false 
  end
end

​ 这样改后,方法可以找到了,但断言失败了,这里我们是故意的,为什么这么做呢?第一法则:测试必须失败,为啥呢?因为当测试应该失败时,我们就能看到它失败,我们测试了自己的测试。当我们将上面的方法返回true时,就能测试另一半了。

# stack.rb

class Stack
  def isEmpty?
    true 
  end
end

​ 这时候执行测试,测试通过,万事大吉,你一定会骂这不是作弊吗,但等等,至少我们只用几秒就能测出该通过时通过,该失败时失败。

​ 下一个要测的是,栈需要能push吧

# stack_test.rb

require 'minitest/autorun'
require_relative 'stack'
class StackTest < Minitest::Test
  # ...

  def test_canPush
    stack = Stack.new
    stack.push(0)
  end
end

​ 这里会报错,因为找不到方法,我们就跟着改改生产代码

# stack.rb

class Stack
  def isEmpty?
    true
  end

  def push(ele)
  end
end

​ 执行测试,测试通过,但这里我们没有断言啊,那既然push了,栈就不应为空吧

require 'minitest/autorun'
require_relative 'stack'
class StackTest < Minitest::Test
  # ...

  def test_canPush
    stack = Stack.new
    stack.push(0)
    refute(stack.isEmpty?)
  end
end

​ 执行测试,断言失败,因为我们返回的一直是true,那改改代码吧

class Stack
  attr_accessor :empty

  def initialize
    @empty = true
  end

  def isEmpty?
    empty
  end

  def push(ele)
    @empty = false
  end
end

​ 我们抽离出一个实例变量来保存是否为空,并在push后直接暴力设置为false。这时候测试又能通过了
我们发现没写一个测试方法,都要创建一个栈,太麻烦了,于是我们重构一下,使用setup方法

require 'minitest/autorun'
require_relative 'stack'

class StackTest < Minitest::Test
  def setup
    @stack = Stack.new
  end

  def test_nothing
  end

  def test_canCreateStack
    assert(@stack.isEmpty?)
  end

  def test_canPush
    @stack.push(0)
    refute(@stack.isEmpty?)
  end
end

​ 测试依然能通过,不过canPush这个测试名不太好,我们改改(test_操作_结果)

def test_afterOnePush_isEmpty
    @stack.push(0)
    refute(@stack.isEmpty?)
end

​ 当然测试还是能通过

​ 这时候我们测试,栈push一次,也能pop一次吧,并且这时候栈应该为空

def test_afterOnePushAndOnePop_isEmpty
    @stack.push(0)
    @stack.pop
    assert(@stack.isEmpty?)
end

​ 测试失败,因为没有这个方法

class Stack
  # ...
  
  def pop
    @empty = true
  end
end

​ 现在测试又通过了,现在我们测试两次push之后,栈的尺寸应该是2

def test_afterTwoPushs_sizeIsTwo
    @stack.push(0)
    @stack.push(0)
    assert_equal(2,@stack.size)
end

​ 测试失败,因为没有这个方法,我们修改生产代码

class Stack
  attr_accessor :empty,:size

  def initialize
    @empty = true
    @size = 0
  end

  def isEmpty?
    empty
  end

  def push(ele)
    @size += 1
    @empty = false
  end

  def pop
    @empty = true
  end
end

​ 我们定义size实例变量来保存状态,并在每次push时,size+1,这样断言通过。为了测试完整,我们再加一个测试

def test_afterOnePush_isNotEmpty
    @stack.push(0)
    refute(@stack.isEmpty?)
    assert_equal(1,@stack.size)
end

​ 测试通过。回到第一法则,如果对空栈执行pop操作,应该会有个异常吧

def test_poppingEmptyStack_raisesUnderflow
    assert_raises do
      @stack.pop
    end
end

​ 测试失败,我们还没定义这个异常类

class Underflow < StandardError
end

​ 断言失败,我们再修改生产代码

require_relative 'underflow'
class Stack
  # ...

  def pop
    raise Underflow if isEmpty?
    @empty = true
  end
end

​ 测试通过,再测试:当栈push一个数据时,也应该pop出相同的数据吧

def test_afterPushingX_willPopX
    @stack.push(1)
    assert_equal(1,@stack.pop)
end

​ 测试失败,调整生产代码,我们加一个实例变量保存信息

require_relative 'underflow'
class Stack
  attr_accessor :empty,:size,:element

  def initialize
    @empty = true
    @size = 0
  end

  def isEmpty?
    empty
  end

  def push(ele)
    @size += 1
    @empty = false
    @element = ele
  end

  def pop
    raise Underflow if isEmpty?
    @empty = true
    @element
  end
end

​ 测试通过,已经写了这么多代码,你看的都崩溃了,觉得直接写一个栈不就完了嘛


规则3:别挖金子


​ 在最开始尝试TDD时,你会急于解决较难或有趣的问题,你可能先写FILO行为,这个就是挖金子,我们有意避免测试与栈行为有关的东西,专注与周边行为。例如栈是否为空或栈大小。
为什么避免挖金子,因为如果过早挖金子,可能忽略周边所有细节。现在我们根据第一法子,编写FILO测试

def test_afterPushingXAndY_willPopYThenX
    @stack.push(1)
    @stack.push(2)
    assert_equal(2,@stack.pop)
    assert_equal(1,@stack.pop)
  end

​ 测试失败,我们发现抛出了Underflow,我们修改一下isEmpty?方法

require_relative 'underflow'
class Stack
  # ...

  def isEmpty?
    size == 0
  end
end

​ 测试失败,test_afterOnePushAndOnePop_isEmpty这个测试中报错,好吧,我们在pop时没有size-1

require_relative 'underflow'
class Stack
  # ...

  def pop
    raise Underflow if isEmpty?
    @size -= 1
    @empty = true
    @element
  end
end

​ 就剩下最后一个断言失败了,FILO行为,我们开始修改

require_relative 'underflow'
class Stack
  attr_accessor :empty,:size,:elements

  def initialize
    @empty = true
    @size = 0
    @elements = []
  end

  def isEmpty?
    size == 0
  end

  def push(ele)
    @size += 1
    @empty = false
    @elements << ele
  end

  def pop
    raise Underflow if isEmpty?
    ele = @elements[size-1]
    @size -= 1
    @empty = true
    ele
  end
end

​ 测试全部通过,我们已经完成了一个栈的基础行为啦,你会发现一个栈在我们不断测试中渐渐成型,虽然现在并不完善,但我们已经能感受到测试驱动开发的整个过程了。

​ 也许你可以花点儿时间,利用测试驱动开发自己写一个队列结构。