测试 如何进行测试驱动开发

qinsicheng · 2023年09月10日 · 最后由 ken 回复于 2023年09月12日 · 1006 次阅读

前言

​最近在读《匠艺整洁之道 - 程序员的职业修养》这本书,作者鲍勃大叔开篇就用了大量的示例来讨论与演示为什么需要和如何操作测试驱动开发。我是之前写过测试,但从不知道测试如何驱动开发,大部分情况下也只是先写生产代码,写好后再测试,看看是否能调通,再修改代码中隐藏的问题。

而测试驱动开发会扭转这种思维方式,是先写测试,再写对应的生产代码。这一开始让人会觉得很奇怪,感觉并且很麻烦,但当你尝试一下,就会发现这事儿挺有趣,且神奇。

温馨提示: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

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

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

TDD 感觉更像一种设计手段,强迫你写出容易测试(易用)的接口。

文中对“为什么要测试驱动开发”的回答,其实只是在回答“为什么要写测试”。

另外测试不是重构的前提,没有测试也可以重构。

最后多数人嘴里所谓的“重构”其实不是重构,重构其实都是些很小步很细碎的代码移动、提炼等等操作。重构严格按步骤来的话,本身完全不改变程序逻辑,原来逻辑是错的重构后还是错的,原来是对的还是对的,所以其实也就无所谓测试。

qiumaoyuan 回复

感谢提醒

我炸一看还以为是开发驱动😂

也可以先写代码,然后让 chatgpt 补测试。

我最爽的写代码经历就是 TDD。

需要 登录 后方可回复, 如果你还没有账号请 注册新账号