当前位置: 首页 ‣ 深入 Python 3 ‣
难度级别: ♦♦♦♦♢
❝ After one has played a vast quantity of notes and more notes, it is simplicity that emerges as the crowning reward of art. ❞
— Frédéric Chopin
就算是竭尽了全力编写全面的单元测试,还是会遇到错误。我所说的“错误”是什么意思?错误是尚未写到的测试实例。
>>> import roman7
>>> roman7.from_roman('') ①
0
InvalidRomanNumeralError
例外。在重现该错误后,应该在修复前写出一个导致该失败情形的测试实例,这样才能描述该错误。
class FromRomanBadInput(unittest.TestCase):
.
.
.
def testBlank(self):
'''from_roman should fail with blank string'''
self.assertRaises(roman6.InvalidRomanNumeralError, roman6.from_roman, '') ①
from_roman()
,并确保其引发一个 InvalidRomanNumeralError
例外。难的是发现错误;找到了该错误之后对它进行测试是件轻松的工作。由于代码有错误,且有用于测试该错误的测试实例,该测试实例将会导致失败:
you@localhost:~/diveintopython3/examples$ python3 romantest8.py -v from_roman should fail with blank string ... FAIL from_roman should fail with malformed antecedents ... ok from_roman should fail with repeated pairs of numerals ... ok from_roman should fail with too many repeated numerals ... ok from_roman should give known result with known input ... ok to_roman should give known result with known input ... ok from_roman(to_roman(n))==n for all n ... ok to_roman should fail with negative input ... ok to_roman should fail with non-integer input ... ok to_roman should fail with large input ... ok to_roman should fail with 0 input ... ok ====================================================================== FAIL: from_roman should fail with blank string ---------------------------------------------------------------------- Traceback (most recent call last): File "romantest8.py", line 117, in test_blank self.assertRaises(roman8.InvalidRomanNumeralError, roman8.from_roman, '') AssertionError: InvalidRomanNumeralError not raised by from_roman ---------------------------------------------------------------------- Ran 11 tests in 0.171s FAILED (failures=1)
现在 可以修复该错误了。
def from_roman(s):
'''convert Roman numeral to integer'''
if not s: ①
raise InvalidRomanNumeralError('Input can not be blank')
if not re.search(romanNumeralPattern, s):
raise InvalidRomanNumeralError('Invalid Roman numeral: {}'.format(s)) ②
result = 0
index = 0
for numeral, integer in romanNumeralMap:
while s[index:index+len(numeral)] == numeral:
result += integer
index += len(numeral)
return result
raise
语句。{0}
来指向 format()
方法的第一个参数,只需简单地使用 {}
而 Python 将会填入正确的位置索引。该规则适用于任何数量的参数;第一个 {}
代表 {0}
,第二个 {}
代表 {1}
,以此类推。you@localhost:~/diveintopython3/examples$ python3 romantest8.py -v from_roman should fail with blank string ... ok ① from_roman should fail with malformed antecedents ... ok from_roman should fail with repeated pairs of numerals ... ok from_roman should fail with too many repeated numerals ... ok from_roman should give known result with known input ... ok to_roman should give known result with known input ... ok from_roman(to_roman(n))==n for all n ... ok to_roman should fail with negative input ... ok to_roman should fail with non-integer input ... ok to_roman should fail with large input ... ok to_roman should fail with 0 input ... ok ---------------------------------------------------------------------- Ran 11 tests in 0.156s OK ②
用此方式编写代码将使得错误修正变得更困难。简单的错误(像这个)需要简单的测试实例;复杂的错误将会需要复杂的测试实例。在以测试为中心的环境中,由于必须在代码中精确地描述错误(编写测试实例),然后修正错误本身,看起来 好像 修正错误需要更多的时间。而如果测试实例无法正确地通过,则又需要找出到底是修正方案有错误,还数测试实例本身就有错误。然而从长远看,这种在测试代码和经测试代码之间的来回折腾是值得的,因为这样才更有可能在第一时间修正错误。同时,由于可以对新代码轻松地重新运行 所有 测试实例,在修正新代码时破坏旧代码的机会更低。今天的单元测试就是明天的回归测试。
⁂
为了获取准确的需求,尽管已经竭力将客户“钉”在原地,并经历了反复剪切、粘贴的痛苦,但需求仍然会变化。大多数客户在看到产品之前不知道自己想要什么,而且就算知道,他们也不擅长清晰地表述自己的想法。而即便擅长表述,他们在下一个版本中也会提出更多要求。因此,必须随时准备好更新测试实例以应对需求变化。
举个例子来说,假定我们要扩展罗马数字转换函数的能力范围。正常情况下,罗马数字中的任何一个字符在同一行中不得重复出现三次以上。但罗马人却愿意该规则有个例外:通过一行中的 4 个 M
字符来代表 4000
。进行该修改后,将会把可转换数字的范围从 1..3999
拓展为 1..4999
。但首先必须对测试实例进行一些修改。
class KnownValues(unittest.TestCase):
known_values = ( (1, 'I'),
.
.
.
(3999, 'MMMCMXCIX'),
(4000, 'MMMM'), ①
(4500, 'MMMMD'),
(4888, 'MMMMDCCCLXXXVIII'),
(4999, 'MMMMCMXCIX') )
class ToRomanBadInput(unittest.TestCase):
def test_too_large(self):
'''to_roman should fail with large input'''
self.assertRaises(roman8.OutOfRangeError, roman8.to_roman, 5000) ②
.
.
.
class FromRomanBadInput(unittest.TestCase):
def test_too_many_repeated_numerals(self):
'''from_roman should fail with too many repeated numerals'''
for s in ('MMMMM', 'DD', 'CCCC', 'LL', 'XXXX', 'VV', 'IIII'): ③
self.assertRaises(roman8.InvalidRomanNumeralError, roman8.from_roman, s)
.
.
.
class RoundtripCheck(unittest.TestCase):
def test_roundtrip(self):
'''from_roman(to_roman(n))==n for all n'''
for integer in range(1, 5000): ④
numeral = roman8.to_roman(integer)
result = roman8.from_roman(numeral)
self.assertEqual(integer, result)
4000
范围之内(外)增加一些。在此,我已经添加了 4000
(最短)、 4500
(第二短)、 4888
(最长) 和 4999
(最大)。4000
调用 to_roman()
并期望引发一个错误;目前 4000-4999
是有效的值,必须将该值调整为 5000
。'MMMM'
调用 from_roman()
并预期发生一个错误;目前 MMMM
被认定为有效的罗马数字,必须将该条件修改为 'MMMMM'
。1
到 3999
。由于范围已经进行了拓展,该 for
循环同样需要修改为以 4999
为上限。现在,测试实例已经按照新的需求进行了更新,但代码还没有,因按照预期,某些测试实例将返回失败结果。
you@localhost:~/diveintopython3/examples$ python3 romantest9.py -v from_roman should fail with blank string ... ok from_roman should fail with malformed antecedents ... ok from_roman should fail with non-string input ... ok from_roman should fail with repeated pairs of numerals ... ok from_roman should fail with too many repeated numerals ... ok from_roman should give known result with known input ... ERROR ① to_roman should give known result with known input ... ERROR ② from_roman(to_roman(n))==n for all n ... ERROR ③ to_roman should fail with negative input ... ok to_roman should fail with non-integer input ... ok to_roman should fail with large input ... ok to_roman should fail with 0 input ... ok ====================================================================== ERROR: from_roman should give known result with known input ---------------------------------------------------------------------- Traceback (most recent call last): File "romantest9.py", line 82, in test_from_roman_known_values result = roman9.from_roman(numeral) File "C:\home\diveintopython3\examples\roman9.py", line 60, in from_roman raise InvalidRomanNumeralError('Invalid Roman numeral: {0}'.format(s)) roman9.InvalidRomanNumeralError: Invalid Roman numeral: MMMM ====================================================================== ERROR: to_roman should give known result with known input ---------------------------------------------------------------------- Traceback (most recent call last): File "romantest9.py", line 76, in test_to_roman_known_values result = roman9.to_roman(integer) File "C:\home\diveintopython3\examples\roman9.py", line 42, in to_roman raise OutOfRangeError('number out of range (must be 0..3999)') roman9.OutOfRangeError: number out of range (must be 0..3999) ====================================================================== ERROR: from_roman(to_roman(n))==n for all n ---------------------------------------------------------------------- Traceback (most recent call last): File "romantest9.py", line 131, in testSanity numeral = roman9.to_roman(integer) File "C:\home\diveintopython3\examples\roman9.py", line 42, in to_roman raise OutOfRangeError('number out of range (must be 0..3999)') roman9.OutOfRangeError: number out of range (must be 0..3999) ---------------------------------------------------------------------- Ran 12 tests in 0.171s FAILED (errors=3)
'MMMM'
,from_roman()
已知值测试将会失败,因为 from_roman()
仍将其视为无效罗马数字。4000
,to_roman()
已知值测试将会失败,因为 to_roman()
仍将其视为超范围数字。4000
时也会失败,因为 to_roman()
仍认为其超范围。现在,我们有了一些由新需求导致失败的测试实例,可以考虑修正代码让它与新测试实例一致起来。(刚开始编写单元测试的时候,被测试代码绝不会在测试实例“之前”出现确实让人感觉有点怪。)尽管编码工作被置后安排,但还是不少要做的事情,一旦与测试实例相符,编码工作就可以结束了。一旦习惯单元测试后,您可能会对自己曾在编程时不进行测试感到很奇怪。)
roman_numeral_pattern = re.compile('''
^ # beginning of string
M{0,4} # thousands - 0 to 4 Ms ①
(CM|CD|D?C{0,3}) # hundreds - 900 (CM), 400 (CD), 0-300 (0 to 3 Cs),
# or 500-800 (D, followed by 0 to 3 Cs)
(XC|XL|L?X{0,3}) # tens - 90 (XC), 40 (XL), 0-30 (0 to 3 Xs),
# or 50-80 (L, followed by 0 to 3 Xs)
(IX|IV|V?I{0,3}) # ones - 9 (IX), 4 (IV), 0-3 (0 to 3 Is),
# or 5-8 (V, followed by 0 to 3 Is)
$ # end of string
''', re.VERBOSE)
def to_roman(n):
'''convert integer to Roman numeral'''
if not (0 < n < 5000): ②
raise OutOfRangeError('number out of range (must be 1..4999)')
if not isinstance(n, int):
raise NotIntegerError('non-integers can not be converted')
result = ''
for numeral, integer in roman_numeral_map:
while n >= integer:
result += numeral
n -= integer
return result
def from_roman(s):
.
.
.
from_roman()
函数进行任何修改。唯一需要修改的是 roman_numeral_pattern 。仔细观察下,将会发现我已经在正则表达式的第一部分中将 M
字符的数量从 3
优化为 4
。该修改将允许等价于 4999
而不是 3999
的罗马数字。实际的 from_roman()
函数完全是通用的;它只查找重复的罗马数字字符并将它们加起来,而不关心它们重复了多少次。之前无法处理 'MMMM'
的唯一原因是我们通过正则表达式匹配明确地阻止了它这么做。to_roman()
函数只需在范围检查中进行一个小改动。将之前检查 0 < n < 4000
的地方现在修改为检查 0 < n < 5000
。同时修改 引发
的错误信息,以体现新的可接受范围 (1..4999
取代 1..3999
) 。无需对函数剩下部分进行任何修改;它已经能够应对新的实例。(它将对找到的每个千位增加 'M'
;如果给定 4000
,它将给出 'MMMM'
。之前它不这么做的唯一原因是我们通过范围检查明确地阻止了它。)所需做的就是这两处小修改,但你可能会有点怀疑。嗨,别光听我说,你自己看看吧。
you@localhost:~/diveintopython3/examples$ python3 romantest9.py -v
from_roman should fail with blank string ... ok
from_roman should fail with malformed antecedents ... ok
from_roman should fail with non-string input ... ok
from_roman should fail with repeated pairs of numerals ... ok
from_roman should fail with too many repeated numerals ... ok
from_roman should give known result with known input ... ok
to_roman should give known result with known input ... ok
from_roman(to_roman(n))==n for all n ... ok
to_roman should fail with negative input ... ok
to_roman should fail with non-integer input ... ok
to_roman should fail with large input ... ok
to_roman should fail with 0 input ... ok
----------------------------------------------------------------------
Ran 12 tests in 0.203s
OK ①
全面单元测试的意思是:无需依赖某个程序员来说“相信我吧。”
⁂
关于全面单元测试,最美妙的事情不是在所有的测试实例通过后的那份心情,也不是别人抱怨你破坏了代码,而你通过实践 证明 自己没有时的快感。单元测试最美妙之处在于它给了你大刀阔斧进行重构的自由。
重构是修改可运作代码,使其表现更佳的过程。通常,“更佳”指的是“更快”,但它也可能指的是“占用更少内存“、”占用更少磁盘空间“或者”更加简洁”。对于你的环境、你的项目来说,无论重构意味着什么,它对程序的长期健康都至关重要。
本例中,“更佳”的意思既包括“更快”也包括“更易于维护”。具体而言,因为用于验证罗马数字的正则表达式生涩冗长,该 from_roman()
函数比我所希望的更慢,也更加复杂。现在,你可能会想,“当然,正则表达式就又臭又长的,难道我有其它办法验证任意字符串是否为罗马数字吗?”
答案是:只针对 5000 个数进行转换;为什么不知建立一个查询表呢?意识到 根本不需要使用正则表达式 之后,这个主意甚至变得更加理想了。在建立将整数转换为罗马数字的查询表的同时,还可以建立将罗马数字转换为整数的逆向查询表。在需要检查任意字符串是否是有效罗马数字的时候,你将收集到所有有效的罗马数字。“验证”工作简化为一个简单的字典查询。
最棒的是,你已经有了一整套单元测试。可以修改模块中一半以上的代码,而单元测试将会保持不变。这意味着可以向你和其他人证明:新代码运作和最初的一样好。
class OutOfRangeError(ValueError): pass
class NotIntegerError(ValueError): pass
class InvalidRomanNumeralError(ValueError): pass
roman_numeral_map = (('M', 1000),
('CM', 900),
('D', 500),
('CD', 400),
('C', 100),
('XC', 90),
('L', 50),
('XL', 40),
('X', 10),
('IX', 9),
('V', 5),
('IV', 4),
('I', 1))
to_roman_table = [ None ]
from_roman_table = {}
def to_roman(n):
'''convert integer to Roman numeral'''
if not (0 < n < 5000):
raise OutOfRangeError('number out of range (must be 1..4999)')
if int(n) != n:
raise NotIntegerError('non-integers can not be converted')
return to_roman_table[n]
def from_roman(s):
'''convert Roman numeral to integer'''
if not isinstance(s, str):
raise InvalidRomanNumeralError('Input must be a string')
if not s:
raise InvalidRomanNumeralError('Input can not be blank')
if s not in from_roman_table:
raise InvalidRomanNumeralError('Invalid Roman numeral: {0}'.format(s))
return from_roman_table[s]
def build_lookup_tables():
def to_roman(n):
result = ''
for numeral, integer in roman_numeral_map:
if n >= integer:
result = numeral
n -= integer
break
if n > 0:
result += to_roman_table[n]
return result
for integer in range(1, 5000):
roman_numeral = to_roman(integer)
to_roman_table.append(roman_numeral)
from_roman_table[roman_numeral] = integer
build_lookup_tables()
让我们打断一下,进行一些剖析工作。可以说,最重要的是最后一行:
build_lookup_tables()
可以注意到这是一次函数调用,但没有 if
语句包裹住它。这不是 if __name__ == '__main__'
语块;模块被导入时 它将会被调用。(重要的是必须明白:模块将只被导入一次,随后被缓存了。如果导入一个已导入模块,将不会导致任何事情发生。因此这段代码将只在第一此导入时运行。)
那么,该 build_lookup_tables()
函数究竟进行了哪些操作呢?很高兴你问这个问题。
to_roman_table = [ None ]
from_roman_table = {}
.
.
.
def build_lookup_tables():
def to_roman(n): ①
result = ''
for numeral, integer in roman_numeral_map:
if n >= integer:
result = numeral
n -= integer
break
if n > 0:
result += to_roman_table[n]
return result
for integer in range(1, 5000):
roman_numeral = to_roman(integer) ②
to_roman_table.append(roman_numeral) ③
from_roman_table[roman_numeral] = integer
to_roman()
函数;它在查询表中查找值并返回结果。而 build_lookup_tables()
函数重定义了 to_roman()
函数用于实际操作(像添加查询表之前的例子一样)。在 build_lookup_tables()
函数内部,对 to_roman()
的调用将会针对该重定义的版本。一旦 build_lookup_tables()
函数退出,重定义的版本将会消失 — 它的定义只在 build_lookup_tables()
函数的作用域内生效。to_roman()
函数,该函数实际计算罗马数字。to_roman()
函数),可将整数及其对应的罗马数字添加到两个查询表中。查询表建好后,剩下的代码既容易又快捷。
def to_roman(n):
'''convert integer to Roman numeral'''
if not (0 < n < 5000):
raise OutOfRangeError('number out of range (must be 1..4999)')
if int(n) != n:
raise NotIntegerError('non-integers can not be converted')
return to_roman_table[n] ①
def from_roman(s):
'''convert Roman numeral to integer'''
if not isinstance(s, str):
raise InvalidRomanNumeralError('Input must be a string')
if not s:
raise InvalidRomanNumeralError('Input can not be blank')
if s not in from_roman_table:
raise InvalidRomanNumeralError('Invalid Roman numeral: {0}'.format(s))
return from_roman_table[s] ②
to_roman()
函数只需在查询表中查找并返回适当的值。from_roman()
函数也缩水为一些边界检查和一行代码。不再有正则表达式。不再有循环。O(1) 转换为或转换到罗马数字。但这段代码可以运作吗?为什么可以,是的它可以。而且我可以证明。
you@localhost:~/diveintopython3/examples$ python3 romantest10.py -v
from_roman should fail with blank string ... ok
from_roman should fail with malformed antecedents ... ok
from_roman should fail with non-string input ... ok
from_roman should fail with repeated pairs of numerals ... ok
from_roman should fail with too many repeated numerals ... ok
from_roman should give known result with known input ... ok
to_roman should give known result with known input ... ok
from_roman(to_roman(n))==n for all n ... ok
to_roman should fail with negative input ... ok
to_roman should fail with non-integer input ... ok
to_roman should fail with large input ... ok
to_roman should fail with 0 input ... ok
----------------------------------------------------------------------
Ran 12 tests in 0.031s ①
OK
to_roman()
和 from_roman()
函数的所有调用摊薄。由于该测试进行几千次函数调用(来回单独测试上万次),节省出来的效率成本得以迅速提升!这个故事的寓意是什么?
⁂
单元测试是一个威力强大的概念,如果正确实施,不但可以降低维护成本,还可以提高长期项目的灵活性。但同时还必须明白:单元测试既不是灵丹妙药,也不是解决问题的魔术,更不是银弹。编写良好的测试实例非常艰难,确保它们时刻保持最新必须成为一项纪律(特别在客户要求关键错误修正时)。单元测试不是功能测试、集成测试或用户承受能力测试等其它测试的替代品。但它是可行的、行之有效的,见识过其功用后,你将对之前曾没有用它而感到奇怪。
这几章覆盖的内容很多,很大一部分都不是 Python 所特有的。许多语言都有单元测试框架,但所有框架都要求掌握同一基本概念:
© 2001–9 Mark Pilgrim