0.摘要
本章继续深入前一章节的例子,通过更改需求—修改代码—重构这一流程让你逐步意识到单元测试的意义,以及它对重构的重要性。
1.新的错误
就算是竭尽了全力编写全面的单元测试,还是会遇到错误。我所说的“错误”是什么意思?错误是尚未写到的测试实例。
这段代码非常简单。通过传入一个空字符串调用
f
r
o
m
_
r
o
m
a
n
(
)
from\_roman()
from_roman() ,并确保其引发一个
I
n
v
a
l
i
d
R
o
m
a
n
N
u
m
e
r
a
l
E
r
r
o
r
InvalidRomanNumeralError
InvalidRomanNumeralError 例外。难的是发现错误;找到了该错误之后对它进行测试是件轻松的工作。
用此方式编写代码将使得错误修正变得更困难。简单的错误(像这个)需要简单的测试实例;复杂的错误将会需要复杂的测试实例。在以测试为中心的环境中,由于必须在代码中精确地描述错误(编写测试实例),然后修正错误本身,看起来好像修正错误需要更多的时间。而如果测试实例无法正确地通过,则又需要找出到底是修正方案有错误,还数测试实例本身就有错误。然而从长远看,这种在测试代码和经测试代码之间的来回折腾是值得的,因为这样才更有可能在第一时间修正错误。同时,由于可以对新代码轻松地重新运行 所有 测试实例,在修正新代码时破坏旧代码的机会更低。今天的单元测试就是明天的回归测试。
2.控制需求变化
为了获取准确的需求,尽管已经竭力将客户“钉”在原地,并经历了反复剪切、粘贴的痛苦,但需求仍然会变化。大多数客户在看到产品之前不知道自己想要什么,而且就算知道,他们也不擅长清晰地表述自己的想法。而即便擅长表述,他们在下一个版本中也会提出更多要求。因此,必须随时准备好更新测试实例以应对需求变化。
举个例子来说,假定我们要扩展罗马数字转换函数的能力范围。正常情况下,罗马数字中的任何一个字符在同一行中不得重复出现三次以上。但罗马人却愿意该规则有个例外:通过一行中的 4 个 M 字符来代表 4000 。进行该修改后,将会把可转换数字的范围从 1…3999 拓展为 1…4999。但首先必须对测试实例进行一些修改。
现有的已知数值不会变(它们依然是合理的测试数值),但必须在 4000 范围之内(外)增加一些。在此,我已经添加了 4000 (最短)、 4500 (第二短)、 4888 (最长) 和 4999 (最大)。
“过大值输入” 的定义已经发生了变化。该测试用于通过传入 4000 调用 to_roman() 并期望引发一个错误;目前 4000-4999 是有效的值,必须将该值调整为 5000 。
“太多重复数字”的定义也发生了变化。该测试通过传入 ‘MMMM’ 调用 from_roman() 并预期发生一个错误;目前 MMMM 被认定为有效的罗马数字,必须将该条件修改为 ‘MMMMM’ 。
对范围内的每个数字进行完整循环测试,从 1 到 3999。由于范围已经进行了拓展,该 for 循环同样需要修改为以 4999 为上限。
现在,我们有了一些由新需求导致失败的测试实例,可以考虑修正代码让它与新测试实例一致起来(刚开始编写单元测试的时候,被测试代码绝不会在测试实例“之前”出现确实让人感觉有点怪)。尽管编码工作被置后安排,但还是不少要做的事情,一旦与测试实例相符,编码工作就可以结束了。一旦习惯单元测试后,您可能会对自己曾在编程时不进行测试感到很奇怪。
3.重构
关于全面单元测试,最美妙的事情不是在所有的测试实例通过后的那份心情,也不是别人抱怨你破坏了代码,而你通过实践证明自己没有时的快感。单元测试最美妙之处在于它给了你大刀阔斧进行重构的自由。
重构是修改可运作代码,使其表现更佳的过程。通常,“更佳”指的是“更快”,但它也可能指的是“占用更少内存“、”占用更少磁盘空间“或者”更加简洁”。对于你的环境、你的项目来说,无论重构意味着什么,它对程序的长期健康都至关重要。
本例中,“更佳”的意思既包括“更快”也包括“更易于维护”。具体而言,因为用于验证罗马数字的正则表达式生涩冗长,该
f
r
o
m
_
r
o
m
a
n
(
)
from\_roman()
from_roman() 函数比我所希望的更慢,也更加复杂。现在,你可能会想,“当然,正则表达式就又臭又长的,难道我有其它办法验证任意字符串是否为罗马数字吗?”
答案是:只针对 5000 个数进行转换;为什么不知建立一个查询表呢?意识到 根本不需要使用正则表达式 之后,这个主意甚至变得更加理想了。在建立将整数转换为罗马数字的查询表的同时,还可以建立将罗马数字转换为整数的逆向查询表。在需要检查任意字符串是否是有效罗马数字的时候,你将收集到所有有效的罗马数字。“验证”工作简化为一个简单的字典查询。
最棒的是,你已经有了一整套单元测试。可以修改模块中一半以上的代码,而单元测试将会保持不变。这意味着可以向你和其他人证明:新代码运作和最初的一样好。
可以注意到这是一次函数调用,但没有
i
f
if
if 语句包裹住它。这不是
i
f
_
_
n
a
m
e
_
_
=
=
′
_
_
m
a
i
n
_
_
′
if\ \_\_name\_\_\ == '\_\_main\_\_'
if __name__ ==′__main__′ 语块;模块被导入时它将会被调用(重要的是必须明白:模块将只被导入一次,随后被缓存了。如果导入一个已导入模块,将不会导致任何事情发生。因此这段代码将只在第一次导入时运行)。
这是一段聪明的程序代码……也许过于聪明了。上面定义了
t
o
_
r
o
m
a
n
(
)
to\_roman()
to_roman() 函数;它在查询表中查找值并返回结果。而
b
u
i
l
d
_
l
o
o
k
u
p
_
t
a
b
l
e
s
(
)
build\_lookup\_tables()
build_lookup_tables() 函数重定义了
t
o
_
r
o
m
a
n
(
)
to\_roman()
to_roman() 函数用于实际操作(像添加查询表之前的例子一样)。在
b
u
i
l
d
_
l
o
o
k
u
p
_
t
a
b
l
e
s
(
)
build\_lookup\_tables()
build_lookup_tables() 函数内部,对
t
o
_
r
o
m
a
n
(
)
to\_roman()
to_roman() 的调用将会针对该重定义的版本。一旦
b
u
i
l
d
_
l
o
o
k
u
p
_
t
a
b
l
e
s
(
)
build\_lookup\_tables()
build_lookup_tables() 函数退出,重定义的版本将会消失 — 它的定义只在
b
u
i
l
d
_
l
o
o
k
u
p
_
t
a
b
l
e
s
(
)
build\_lookup\_tables()
build_lookup_tables() 函数的作用域内生效。
像前面那样进行同样的边界检查之后,
t
o
_
r
o
m
a
n
(
)
to\_roman()
to_roman() 函数只需在查询表中查找并返回适当的值。
同样,
f
r
o
m
_
r
o
m
a
n
(
)
from\_roman()
from_roman() 函数也缩水为一些边界检查和一行代码。不再有正则表达式。不再有循环。
它不仅能够回答你的问题,还运行得非常快!好象速度提升了 10 倍。当然,这种比较并不公平,因为此版本在导入时耗时更长(在建造查询表时)。但由于只进行一次导入,启动的成本可以由对
t
o
_
r
o
m
a
n
(
)
to\_roman()
to_roman() 和
f
r
o
m
_
r
o
m
a
n
(
)
from\_roman()
from_roman() 函数的所有调用摊薄。由于该测试进行几千次函数调用(来回单独测试上万次),节省出来的效率成本得以迅速提升!
在学完这一章后我也稍微认识到了单元测试的重要性……确实如果没有足够的测试用例的话,对代码重构是一件很头疼的事情,我会非常纠结新代码和旧代码的功能是否一致……这甚至会影响重构代码的效率。然而在拥有完备的单元测试的情况下,你可以放心的去重构代码,因为可以迅速的检验新旧代码功能的一致性。
4.总结
单元测试是一个威力强大的概念,如果正确实施,不但可以降低维护成本,还可以提高长期项目的灵活性。但同时还必须明白:单元测试既不是灵丹妙药,也不是解决问题的魔术,更不是银弹。编写良好的测试实例非常艰难,确保它们时刻保持最新必须成为一项纪律(特别在客户要求关键错误修正时)。单元测试不是功能测试、集成测试或用户承受能力测试等其它测试的替代品。但它是可行的、行之有效的,见识过其功用后,你将对之前曾没有用它而感到奇怪。