测试驱动开发的艺术读书笔记
参加了熊老师的 TDD 课程后,我开始意识到了自己以前对 TDD 的理解还太肤浅,于是开始阅读这本有点年头的书。 本书第一章大致介绍了 TDD 的概念,第二章就开始进入实战了,我很喜欢这种风格,书中使用的是 Java 语言,我就来一个 Ruby 版的吧。
分解需求
我们要开发的是一个邮件模版系统,我在实际工作中使用过 sendgrid 这样的系统,所以并不陌生,可以把模版子系统分解成如下测试:
- 没有任何变量的模版,渲染前后内容不变
- 含有一个变量的模版渲染后,变量应当替换为相应的值
- 含有多个变量的模版渲染后,变量应当替换为相应值
我想稍微了解 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
方法,它做了两件事
- 用变量值替换变量
- 检查缺失的变量值
目前的 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
在进行下一步之前,先重构一下测试代码吧:
- 抽取
TemplateParse
实例 - 抽取
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
接口,再添加 PlainText
和 Variable
两个实现。
由于 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
以及 PlainText
和 Variable
两个类:
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
方法了。
具体的修改就是替换 parse
为 parse_segments
,并删除私有方法 append
和 evaluate_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 的做法,我敢说一定会需要相当多的测试,还不一定能顺利实现功能和重构。
不过我也有一些疑问:
- 书中的测试比较频繁地被修改(不属于重构地修改)是否合理?
- PlainText 和 Variable 的 euqal(==) 方法只有测试时需要被用到,这样的产品代码是否应该被书写?
- 最后的 MissingValueException 测试,之前 Template 测试中已经有了,是否应该再在 VariableTest 中写一份?是否会显得重复?
再总结一下整个系统的演进过程:
- 第一版只有一个类(Template),使用基本的正则来实现了变量的替换
- 发现了一个很大的问题,没法处理类似变量格式的值
- 引入了 TemplateParse 类来处理模版,并用切片的方式避免上面的问题
- 把替换变量的职责下放到 Segment 接口中
在这个过程中,涉及到了类的新增,方法调用的修改替换,bug 的修复,这一切的改动都依靠着单元测试的保护才能顺利进行。
从这个例子中,我感受到了很多,最后只说一句: TDD 牛逼!!!