测试驱动开发的艺术读书笔记

参加了熊老师的 TDD 课程后,我开始意识到了自己以前对 TDD 的理解还太肤浅,于是开始阅读这本有点年头的书。 本书第一章大致介绍了 TDD 的概念,第二章就开始进入实战了,我很喜欢这种风格,书中使用的是 Java 语言,我就来一个 Ruby 版的吧。

分解需求

我们要开发的是一个邮件模版系统,我在实际工作中使用过 sendgrid 这样的系统,所以并不陌生,可以把模版子系统分解成如下测试:

  1. 没有任何变量的模版,渲染前后内容不变
  2. 含有一个变量的模版渲染后,变量应当替换为相应的值
  3. 含有多个变量的模版渲染后,变量应当替换为相应值

我想稍微了解 TDD 的同学看到这里已经心中有数了。让我们开始红绿重构吧。

第一个测试

让我们开始第一个测试吧(似乎漏了没有任何变量的情况?)

class TemplateTest < Minitest::Test
  def test_one_variable
    template = Template.new("Hello, ${name}")
    template.set("name", "Reader")
    assert_equal "Hello, Reader", template.evaluate()
  end
end

从这个测试中可以驱动出 Template 类, set 以及 evaulate 方法。

而我们的实现是一个硬编码的实现:

class Template

  def initialize(template_text)

  end

  def set(variable, value)

  end

  def evaluate()
    "Hello, Reader"
  end
end

再加一个测试

为了去掉硬编码,让我们再写一个测试:

def test_different_value
  template = Template.new("Hello, ${name}")
  template.set("name", "Someone else")
  assert_equal "Hello, Someone else", template.evaluate()
end

为了通过这个测试,我们不得不开始实现 set 方法。

class Template
  attr_accessor :variable_value

  def initialize(template_text)

  end

  def set(variable, value)
    @variable_value = value
  end

  def evaluate()
    "Hello, #{variable_value}"
  end

end

虽然驱动出了 set 方法的实现,不过 evaluate 方法依然有硬编码的部分。

看来还需要另一个测试,书中并没有选择新加一个测试,而是修改了原有的测试为:

def test_different_value
  template = Template.new("Hi, ${name}")
  template.set("name", "Someone else")
  assert_equal "Hi, Someone else", template.evaluate()
end

通过把 Hello 改为 Hi, 我们不得不在实现代码中考虑模版的问题了。

class Template
  attr_accessor :template_text, :variable_value

  def initialize(template_text)
    @template_text = template_text
  end

  def set(variable, value)
    @variable_value = value
  end

  def evaluate()
    template_text.gsub("${name}", variable_value)
  end
end

好了,这下 template_text 不是硬编码了,可见我们又前进了一步。

不过目前 ${name} 这个变量还是硬编码的,而去除硬编码的方式之前已经用过了,就是增加测试:

def test_multiple_variables
  template = Template.new("${one}, ${two}, ${three}")
  template.set("one", "1");
  template.set("two", "2");
  template.set("three", "3");
  assert_equal "1, 2, 3", template.evaluate()
end

有了这个多个变量的测试,我们就要开始实现具体的替换变量的代码了:

class Template
  attr_accessor :template_text, :variable_pair

  def initialize(template_text)
    @variable_pair = {}
    @template_text = template_text
  end

  def set(variable, value)
    variable_pair[variable] = value
  end

  def evaluate()
    variable_pair.each do |k, v|
      template_text.gsub!("${#{k}}", v)
    end
    template_text
  end
end

测试通过了,再来试试为不存在的变量赋值会被忽略这个情况。

def test_unknow_variable_should_be_ignored
  template = Template.new("Hello, ${name}")
  template.set("name", "Reader")
  template.set("doesnotexist", "Hi")
  assert_equal "Hello, Reader", template.evaluate()
end

这个测试直接通过了。下面我们可以考虑重构的问题了。

重构测试代码

目前实现代码还很简单,我们要重构的是测试代码。先来看看目前全部的测试代码:

class TemplateTest < Minitest::Test

  def test_one_variable
    template = Template.new("Hello, ${name}")
    template.set("name", "Reader")
    assert_equal "Hello, Reader", template.evaluate()
  end

  def test_different_value
    template = Template.new("Hi, ${name}")
    template.set("name", "Someone else")
    assert_equal "Hi, Someone else", template.evaluate()
  end

  def test_multiple_variables
    template = Template.new("${one}, ${two}, ${three}")
    template.set("one", "1");
    template.set("two", "2");
    template.set("three", "3");
    assert_equal "1, 2, 3", template.evaluate()
  end

  def test_unknow_variable_should_be_ignored
    template = Template.new("Hello, ${name}")
    template.set("name", "Reader")
    template.set("doesnotexist", "Hi")
    assert_equal "Hello, Reader", template.evaluate()
  end
end

一共4个测试,不难发现,第4个测试包含了第1,2个测试的内容,第2个测试的不同模版文字也没有意义了。 另外 template 对象也可以抽取出来,以及每个测试都有的 assert_equal xxx, template.evaluate() 也可以抽取。

重构后的测试代码如下:

class TemplateTest < Minitest::Test
  def setup
    @template = Template.new("${one}, ${two}, ${three}")
    @template.set("one", "1");
    @template.set("two", "2");
    @template.set("three", "3");
  end

  def test_multiple_variables
    assert_template_evaluates_to("1, 2, 3")
  end

  def test_unknow_variable_should_be_ignored
    @template.set("doesnotexist", "Hi")
    assert_template_evaluates_to("1, 2, 3")
  end

  private

  def assert_template_evaluates_to(expected)
    assert_equal expected, @template.evaluate()
  end
end

这下测试代码简洁了很多,下面要测试的是模版中出现未赋值变量时应该抛出异常。

def test_missing_value_exception
  assert_raises MissingValueException do
    Template.new("${foo}").evaluate
  end
end

这个测试驱动出了一个自定义的异常:

class MissingValueException < StandardError

end

另外,需要验证 template 最后的结果是否还包含未赋值的变量,这里的实现如下:

def evaluate()
  variable_pair.each do |k, v|
    template_text.gsub!("${#{k}}", v)
  end

  if /.*\$\{.*\}/.match(template_text)
    raise MissingValueException
  end

  template_text
end

好了,测试通过了,不过这次 evaluate 方法有点长了,可以考虑重构了。

如果把检查异常的代码抽出来的话:

def evaluate()
  variable_pair.each do |k, v|
    template_text.gsub!("${#{k}}", v)
  end

  check_for_missing_values()

  template_text
end

private
def check_for_missing_values
  if /.*\$\{.*\}/.match(template_text)
    raise MissingValueException
  end
end

运行测试,保证重构没有破坏功能,再看看 evaluate 方法,它做了两件事

  1. 用变量值替换变量
  2. 检查缺失的变量值

目前的 evaluate 方法中,这两件事不是平等的,它的抽象一致性有问题,需要再抽取一个 replace_variables 方法,来对应 check_for_missing_values 方法:

def evaluate()
  replace_variables()
  check_for_missing_values()
  template_text
end

private
def replace_variables()
  variable_pair.each do |k, v|
    template_text.gsub!("${#{k}}", v)
  end
end

def check_for_missing_values
  if /.*\$\{.*\}/.match(template_text)
    raise MissingValueException
  end
end

有了测试作保障,重构轻松多了。下面需要增强一下报错的信息,让它可以说明是哪个变量没有被赋值。

还是先写测试(书中是修改了之前的测试):

def test_missing_value_exception
  exception = assert_raises MissingValueException do
    Template.new("${foo}").evaluate
  end

  assert_equal "No value for ${foo}", exception.message
end

然后修改实现代码让测试通过:

def check_for_missing_values
  if missing_value_variable = /.*\$\{.*\}/.match(template_text)
    raise MissingValueException, "No value for #{missing_value_variable}"
  end
end

整个例子看起来很顺利,并且很。。。 hello world, 肯定有人觉得太简单了,别急,接下来我们会碰到一个比较棘手的问题。

试想一下,如果变量的 包含了 $ { } 等字符,那怎么办???

按目前的实现方式,肯定是会报错的。接下来我们会来解决这个问题。

制作模版解析器

之所以出现问题的原因是之前的做法是对每一个变量都要遍历一次模板字符串来实现变量替换,这就是问题的根源。

下面我们要换一种思路,把模板字符串切片,之后再对每个片进行变量替换,这样可以避免二次赋值的问题。

比如: ~”${a}:${b}:${c}”~ 可以切分为数组 ["${a}", ":", "${b}", ":", "${c}"]

哈哈,其实上面的例子已经可以当作一个测试用例了,不过步子别迈那么大,还是从空模版开始

解析器的第一个测试

class TemplateParseTest < Minitest::Test
  def test_empty_template_renders_as_empty_string
    parse = TemplateParse.new
    segments = parse.parse("")
    assert_equal 1, segments.size()
    assert_equal "", segments[0]
  end
end

这个测试可以驱动出 TemplateParse 类和 parse 方法:

class TemplateParse
  def parse(template)
    [""]
  end
end

同样这次先使用硬编码实现。

接着添加一个纯文本的 template 的测试,应该返回一个只有一个元素的数组,内容就是要解析的纯文本:

def test_template_with_only_plain_text
  parse = TemplateParse.new
  segments = parse.parse("plain text only")
  assert_equal 1, segments.size()
  assert_equal "plain text only", segments[0]
end

这次的实现还是有硬编码的成分,不过至少用到 template 参数了:

class TemplateParse
  def parse(template)
    [template]
  end
end

在进行下一步之前,先重构一下测试代码吧:

  1. 抽取 TemplateParse 实例
  2. 抽取 assert_segments 方法

这次测试的重构与之前 Template 类的重构如出一辙,重构后的测试代码如下:

class TemplateParseTest < Minitest::Test
  def setup
    @parse = TemplateParse.new
  end

  def test_empty_template_renders_as_empty_string
    assert_segments([""], "")
  end

  def test_template_with_only_plain_text
    assert_segments(["plain text only"], "plain text only")
  end

  private

  def assert_segments(segments, template)
    assert_equal segments, @parse.parse(template)
  end
end

开始解析变量

下面就是重头戏了,让我们添加一个真正需要解析的模版测试(就是一开始举的例子):

def test_parse_multiple_variables
  assert_segments(["${a}", ":", "${b}", ":", "${c}"], "${a}:${b}:${c}")
end

要解析出模版中的变量并切分,可以用正则找出变量文字的 index, 以此作为分割的点。

def parse(template)
  variable_position_pairs = template.enum_for(:scan, /\$\{[^}]}*/).map { |w| [Regexp.last_match.begin(0), w] }.map {|pair| [ pair[0], pair[0] + pair[1].length - 1]}

  return [template] if variable_position_pairs.empty?

  all_position_pairs = variable_position_pairs.clone
  variable_position_pairs.each_cons(2) do |a, b|
    all_position_pairs << [a[1] + 1, b[0] - 1]
  end

  result = all_position_pairs.sort.map do |pair|
    template[pair[0]..pair[1]]
  end

  result
end

这段代码逻辑挺复杂的,最终的实现效果看单元测试就知道了,就是把一段模版按照变量进行切分。这样以后替换变量就容易了,可以遍历这个数组进行替换,不会有重复替换的问题了。

好了,让我们回到 Template 类,让 evaluate 方法使用这个新的实现。

def evaluate()
  parser = TemplateParse.new
  segments = parser.parse(template_text)
  results = segments.map {|seg| append(seg)}
  results.join('')
end

运行测试会报错,说没有 append 方法,这个方法应该是用来实现变量替换的。

def append(segment)
  if segment.start_with?("${") && segment.end_with?("}")
    var = segment[2..-2]
    raise MissingValueException, "No value for #{segment}" unless variable_pair.keys.include?(var)
    variable_pair[var]
  else
    segment
  end
end

好了,运行测试,都通过的话我们可以开始重构了。

和上一次的重构一样,目前的 evaluate 方法中的抽象层次不一致。对应 segments 我们可以抽取一个 concatenate 方法,使得抽象层次一致。

def evaluate()
  parser = TemplateParse.new
  segments = parser.parse(template_text)
  concatenate(segments)
end

private
def concatenate(segments)
  results = segments.map {|seg| append(seg)}
  results.join('')
end

另外 append 方法中的 if else 也应该抽取出来

def is_variable(segment)
  segment.start_with?("${") && segment.end_with?("}")
end

还有具体的替换变量的几行也可以抽取出来:

def evaluate_variable(segment)
  var = segment[2..-2]
  raise MissingValueException, "No value for #{segment}" unless variable_pair.keys.include?(var)
  variable_pair[var]
end

最终的结果如下:

def evaluate()
  parser = TemplateParse.new
  segments = parser.parse(template_text)
  concatenate(segments)
end

private

def append(segment)
  if is_variable(segment)
    evaluate_variable(segment)
  else
    segment
  end
end

def concatenate(segments)
  results = segments.map {|seg| append(seg)}
  results.join('')
end

def evaluate_variable(segment)
  var = segment[2..-2]
  raise MissingValueException, "No value for #{segment}" unless variable_pair.keys.include?(var)
  variable_pair[var]
end

def is_variable(segment)
  segment.start_with?("${") && segment.end_with?("}")
end

因为有了单元测试的保护,这些重构都相当顺利。

重构出 Segment 接口

我们发现现在还有一个 if 条件判断:

if is_variable(segment)
  evaluate_variable(segment)
else
  segment
end

根据 tell don't ask 原则,这种查询后作判断的写法,一般都可以使用多态机制来重构。

考虑到现在的模版片断都是 String 类型,也有着 “基本类型偏执” 的坏味道。所以可以先提取出 Segment 接口,再添加 PlainTextVariable 两个实现。

由于 Ruby 是动态语言,可以选择使用鸭子类型,不过思路都是一样的。

添加 Segment 接口和两个实现会影响 TemplateParse 的工作方式,因此需要先修改 TemplateParseTest , 为了不让步子迈的太大,我们先通过添加一个 parse_segment 方法的测试来驱动新的实现方式,并不影响之前的直接操作字符串的实现。

def test_parsing_template_into_segment_objects
  parse = TemplateParse.new
  result = parse.parse_segments("a ${b} c ${d}")
  assert_equal [
    PlainText.new("a"),
    Variable.new("b"),
    PlainText.new("c"),
    Variable.new("d")
  ], result
end

这个测试可以驱动出 parse_segments 方法的硬编码实现:

def parse_segments(template)
  [
    PlainText.new("a"),
    Variable.new("b"),
    PlainText.new("c"),
    Variable.new("d")
  ]
end

以及 PlainTextVariable 两个类:

class PlainText
  attr_reader :text

  def initialize(text)
    @text = text
  end

  def == (other)
    text == other.text
  end
end

class Variable
  attr_reader :name

  def initialize(name)
    @name = name
  end

  def == (other)
    name == other.name
  end
end

当然这个硬编码实现要立刻去掉,利用之前已有的 parse 方法,可以很容易实现:

def parse_segments(template)
  string_segments = parse(template)
  segments = string_segments.map do |s|
    if Template.is_variable(s)
      Variable.new(s)
    else
      PlainText.new(s)
    end
  end
end

不过,这里发生了意料之外的情况,测试报错了,在 parse_segments 的返回值中没有文字 a

经过排查,发现是之前的 parse 方法中的算法有问题,如果在第一个变量之前有纯文本,那解析会有问题(其实末尾也有同样问题)。

我抑制住当场修 bug 的冲动,先把 parse_segments 的实现注释掉,恢复成硬编码的型式,确保测试都通过后,添加了一个测试:

def test_parse_variable_with_plain_text_round
  assert_segments(["123", "${a}", ":", "${b}", ":", "${c}", "456"], "123${a}:${b}:${c}456")
end

修复后的 parse 方法:

def parse(template)
  variable_position_pairs = template.enum_for(:scan, /\${.*?}/).map { |w| [Regexp.last_match.begin(0), w] }.map {|pair| [ pair[0], pair[0] + pair[1].length - 1]}
  return [template] if variable_position_pairs.empty?

  all_position_pairs = variable_position_pairs.clone
  variable_position_pairs.each_cons(2) do |a, b|
    all_position_pairs << [a[1] + 1, b[0] - 1]
  end

  if variable_position_pairs.first[0] != 0
    all_position_pairs << [0, variable_position_pairs.first[0] - 1]
  end

  if variable_position_pairs.last[1] != template.length - 1
    all_position_pairs << [variable_position_pairs.last[1] + 1, template.length - 1]
  end

  result = all_position_pairs.sort.map do |pair|
    template[pair[0]..pair[1]]
  end

  result
end

修好了 bug, 我们继续来实现 parse_segments :

def parse_segments(template)
  string_segments = parse(template)
  segments = string_segments.map do |s|
    if Template.is_variable(s)
      name = s[2..-2]
      Variable.new(name)
    else
      PlainText.new(s)
    end
  end
end

这里,我们用到了 Template 类中的私有方法 is_variable, 所以我先把这个方法改成 public 的类方法,这样就能在外部调用了。

当然 Template 类内部也修改了调用方式,这一切都在单元测试的保护中。

现在 parse_segments 方法的测试顺利通过了。

我们使用多态(鸭子类型)的目的就是要让 segment 自己可以进行内容的替换,下面就开始写 segment 的 evaluate 方法的测试吧:

class PlainTextTest < Minitest::Test
  def test_evaluate
    text = "abc def"
    plain_text = PlainText.new(text)

    assert_equal text, plain_text.evaluate
  end
end

class VariableTest < Minitest::Test
  def test_evaluate
    name = "myvar"
    value = "myvalue"
    variable = Variable.new(name)

    assert_equal value, variable.evaluate({name: value})
  end
end

下面添加 evaluate 方法的实现:

class PlainText
  def evaluate(map = nil)
    text
  end
end

class Variable
  def evaluate(map)
    map[name]
  end
end

真是太简单了,之后终于可以开始替换 Template 类的 evaluate 方法了。

具体的修改就是替换 parseparse_segments ,并删除私有方法 appendevaluate_variable ,它的职责已经被 Segment 接口实现了。并且在 concatenate 方法中直接遍历调用 segments 的 evaluate 方法。

def evaluate
  parser = TemplateParse.new
  segments = parser.parse_segments(template_text)
  concatenate(segments)
end

private

def concatenate(segments)
  segments.map {|segment| segment.evaluate(variable_pair)}.join('')
end

运行测试,一切都 OK, 除了 test_missing_value_exception 这个测试。

看来重构的代码失去了检查是否有未赋值变量的能力,还好有单元测试,我们可以很容易发现错误并修复:

def evaluate(map)
  if map.keys.include?(name)
    map[name]
  else
    raise MissingValueException.new("No value for ${#{name}}")
  end
end

可见,有了单元测试,重构会变得非常安全。

总结

这个模版的例子,一开始觉得很简单老套,但是后面的变化很大,可以说比大多数我写过的业务代码都复杂。

如果没采用 TDD 的做法,我敢说一定会需要相当多的测试,还不一定能顺利实现功能和重构。

不过我也有一些疑问:

  1. 书中的测试比较频繁地被修改(不属于重构地修改)是否合理?
  2. PlainText 和 Variable 的 euqal(==) 方法只有测试时需要被用到,这样的产品代码是否应该被书写?
  3. 最后的 MissingValueException 测试,之前 Template 测试中已经有了,是否应该再在 VariableTest 中写一份?是否会显得重复?

再总结一下整个系统的演进过程:

在这个过程中,涉及到了类的新增,方法调用的修改替换,bug 的修复,这一切的改动都依靠着单元测试的保护才能顺利进行。

从这个例子中,我感受到了很多,最后只说一句: TDD 牛逼!!!