您当前位置:主页 深入 Python 3

难度级别:♦♦♢♢♢

单元测试

确定性不是确定性的检验。我们曾对许多不真实的事物确信无疑。
— 奥利弗·温德尔·霍姆斯,小

 

(不)深入

现在的孩子们。被这些快速的电脑和花哨的“动态”语言宠坏了。先写,后发布,再调试(如果有的话)。在我那个年代,我们有纪律。纪律,我说!我们必须手工纸上编写程序,然后在穿孔卡上输入到计算机。而且我们喜欢这样!

在本章中,您将编写并调试一组实用函数,用于在罗马数字之间进行转换。您在“案例研究:罗马数字”中看到了构建和验证罗马数字的机制。现在退一步,考虑将它扩展成一个双向实用程序需要什么。

罗马数字的规则导致了许多有趣的观察

  1. 只有一个正确的方法可以将特定数字表示为罗马数字。
  2. 反之亦然:如果一串字符是有效的罗马数字,它只代表一个数字(即它只能解释一次)。
  3. 可以使用罗马数字表示的数字范围有限,具体来说是13999。罗马人有几种表达更大数字的方法,例如在数字上加一条横线来表示其正常值应乘以1000。为了本章的目的,让我们规定罗马数字的范围为13999
  4. 罗马数字没有表示0的方法。
  5. 罗马数字没有表示负数的方法。
  6. 罗马数字没有表示分数或非整数的方法。

让我们开始规划一个roman.py模块应该做什么。它将有两个主要函数,to_roman()from_roman()to_roman()函数应该接受一个13999之间的整数,并返回一个字符串的罗马数字表示……

停在那里。现在让我们做一些意想不到的事情:编写一个测试用例,检查to_roman()函数是否按预期工作。您没有听错:您将编写测试代码,而这些代码尚未编写。

这称为测试驱动开发,或简称为TDD。这两个转换函数集——to_roman(),以及后面的from_roman()——可以作为一个单元单独编写和测试,独立于任何导入它们的更大程序。Python 有一个用于单元测试的框架,名为unittest模块。

单元测试是整体以测试为中心的开发策略的重要组成部分。如果您编写单元测试,那么必须尽早编写它们,并在代码和需求发生变化时保持更新。许多人主张在编写他们要测试的代码之前编写测试,而这也是我在本章中将要演示的风格。但是,无论何时编写,单元测试都是有益的。

一个问题

一个测试用例回答有关它正在测试的代码的单个问题。一个测试用例应该能够……

鉴于此,让我们为第一个需求构建一个测试用例

  1. to_roman()函数应该返回所有整数13999的罗马数字表示。

并不立即清楚这段代码是如何……嗯,做任何事情的。它定义了一个没有__init__()方法的类。该类确实有另一个方法,但它从未被调用。整个脚本有一个__main__块,但它没有引用该类或其方法。但我保证它确实做了某事。

[下载romantest1.py]

import roman1
import unittest

class KnownValues(unittest.TestCase):               
    known_values = ( (1, 'I'),
                     (2, 'II'),
                     (3, 'III'),
                     (4, 'IV'),
                     (5, 'V'),
                     (6, 'VI'),
                     (7, 'VII'),
                     (8, 'VIII'),
                     (9, 'IX'),
                     (10, 'X'),
                     (50, 'L'),
                     (100, 'C'),
                     (500, 'D'),
                     (1000, 'M'),
                     (31, 'XXXI'),
                     (148, 'CXLVIII'),
                     (294, 'CCXCIV'),
                     (312, 'CCCXII'),
                     (421, 'CDXXI'),
                     (528, 'DXXVIII'),
                     (621, 'DCXXI'),
                     (782, 'DCCLXXXII'),
                     (870, 'DCCCLXX'),
                     (941, 'CMXLI'),
                     (1043, 'MXLIII'),
                     (1110, 'MCX'),
                     (1226, 'MCCXXVI'),
                     (1301, 'MCCCI'),
                     (1485, 'MCDLXXXV'),
                     (1509, 'MDIX'),
                     (1607, 'MDCVII'),
                     (1754, 'MDCCLIV'),
                     (1832, 'MDCCCXXXII'),
                     (1993, 'MCMXCIII'),
                     (2074, 'MMLXXIV'),
                     (2152, 'MMCLII'),
                     (2212, 'MMCCXII'),
                     (2343, 'MMCCCXLIII'),
                     (2499, 'MMCDXCIX'),
                     (2574, 'MMDLXXIV'),
                     (2646, 'MMDCXLVI'),
                     (2723, 'MMDCCXXIII'),
                     (2892, 'MMDCCCXCII'),
                     (2975, 'MMCMLXXV'),
                     (3051, 'MMMLI'),
                     (3185, 'MMMCLXXXV'),
                     (3250, 'MMMCCL'),
                     (3313, 'MMMCCCXIII'),
                     (3408, 'MMMCDVIII'),
                     (3501, 'MMMDI'),
                     (3610, 'MMMDCX'),
                     (3743, 'MMMDCCXLIII'),
                     (3844, 'MMMDCCCXLIV'),
                     (3888, 'MMMDCCCLXXXVIII'),
                     (3940, 'MMMCMXL'),
                     (3999, 'MMMCMXCIX'))           

    def test_to_roman_known_values(self):           
        '''to_roman should give known result with known input'''
        for integer, numeral in self.known_values:
            result = roman1.to_roman(integer)       
            self.assertEqual(numeral, result)       

if __name__ == '__main__':
    unittest.main()
  1. 要编写一个测试用例,首先要对unittest模块的TestCase类进行子类化。这个类提供了许多有用的方法,您可以在测试用例中使用这些方法来测试特定条件。
  2. 这是一个整数/数字对的元组,我手动验证了它们。它包括最小的十个数字、最大的数字、每个转换为单个字符罗马数字的数字,以及其他有效数字的随机样本。您不需要测试所有可能的输入,但您应该尝试测试所有明显的边界情况。
  3. 每个单独的测试都是它自己的方法。测试方法不接受参数,不返回值,并且必须有一个以四个字母test开头的名称。如果测试方法正常退出,没有引发异常,则该测试被认为通过;如果方法引发异常,则该测试被认为失败。
  4. 这里您调用实际的to_roman()函数。(好吧,该函数还没有编写,但一旦编写了,这将是调用它的行。)请注意,您现在已经定义了to_roman()函数的API:它必须接受一个整数(要转换的数字)并返回一个字符串(罗马数字表示)。如果API与之不同,则该测试被认为失败。另请注意,您在调用to_roman()时没有捕获任何异常。这是故意的。to_roman()在您使用有效输入调用它时不应该引发异常,并且这些输入值都是有效的。如果to_roman()引发异常,则该测试被认为失败。
  5. 假设to_roman()函数定义正确、调用正确、成功完成并返回一个值,那么最后一步是检查它是否返回了正确的值。这是一个常见的问题,TestCase类提供了一个方法assertEqual,用于检查两个值是否相等。如果从to_roman()返回的结果(result)与您期望的已知值(numeral)不匹配,assertEqual将引发异常,测试将失败。如果两个值相等,assertEqual将不执行任何操作。如果从to_roman()返回的每个值都与您期望的已知值匹配,assertEqual永远不会引发异常,因此test_to_roman_known_values最终正常退出,这意味着to_roman()已通过此测试。

一旦您有了测试用例,就可以开始编写to_roman()函数。首先,您应该将其作为空函数进行存根,并确保测试失败。如果您在编写任何代码之前测试成功,那么您的测试根本没有测试您的代码!单元测试是一种舞蹈:测试引领,代码跟随。编写一个失败的测试,然后编码直到它通过。

# roman1.py

def to_roman(n):
    '''convert integer to Roman numeral'''
    pass                                   
  1. 在这个阶段,您想定义to_roman()函数的API,但您不想现在就编写它。(您的测试需要首先失败。)要将其存根,请使用 Python 保留字pass,它不执行任何操作。

在命令行上执行romantest1.py以运行测试。如果您使用-v命令行选项调用它,它将提供更详细的输出,以便您可以在每个测试用例运行时准确地看到发生了什么。如果幸运的话,您的输出应该如下所示

you@localhost:~/diveintopython3/examples$ python3 romantest1.py -v
test_to_roman_known_values (__main__.KnownValues) 
to_roman should give known result with known input ... FAIL 

======================================================================
FAIL: to_roman should give known result with known input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest1.py", line 73, in test_to_roman_known_values
    self.assertEqual(numeral, result)
AssertionError: 'I' != None                                            

----------------------------------------------------------------------
Ran 1 test in 0.016s                                                   

FAILED (failures=1)                                                    
  1. 运行脚本运行unittest.main(),它运行每个测试用例。每个测试用例都是romantest.py中类中的一个方法。这些测试类没有要求的组织;它们可以分别包含单个测试方法,或者您也可以有一个包含多个测试方法的类。唯一的要求是,每个测试类都必须继承自unittest.TestCase
  2. 对于每个测试用例,unittest模块将打印出该方法的docstring以及该测试是通过还是失败。不出所料,这个测试用例失败了。
  3. 对于每个失败的测试用例,unittest都会显示跟踪信息,显示确切发生了什么。在本例中,对assertEqual()的调用引发了AssertionError,因为它期望to_roman(1)返回'I',但它没有。(由于没有显式返回值语句,因此该函数返回了None,即 Python 的空值。)
  4. 在每个测试的详细信息之后,unittest会显示一个摘要,说明执行了多少个测试以及花费了多长时间。
  5. 总体而言,测试运行失败,因为至少有一个测试用例没有通过。当测试用例没有通过时,unittest会区分失败和错误。失败是对assertXYZ方法(如assertEqualassertRaises)的调用,该调用失败是因为断言的条件不为真,或者没有引发预期的异常。错误是在您正在测试的代码或单元测试用例本身中引发的任何其他类型的异常。

现在,最后,您可以编写to_roman()函数了。

[下载roman1.py]

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))                 

def to_roman(n):
    '''convert integer to Roman numeral'''
    result = ''
    for numeral, integer in roman_numeral_map:
        while n >= integer:                     
            result += numeral
            n -= integer
    return result
  1. roman_numeral_map是一个元组的元组,它定义了三件事:最基本的罗马数字的字符表示;罗马数字的顺序(按降序排列,从M一直到I);每个罗马数字的值。每个内部元组都是一个(numeral, value)对。它不仅仅是单个字符的罗马数字;它还定义了像CM(“比一千少一百”)这样的双字符对。这使得to_roman()函数代码更简单。
  2. 这里就是roman_numeral_map的丰富数据结构发挥作用的地方,因为您不需要任何特殊逻辑来处理减法规则。要转换为罗马数字,只需遍历roman_numeral_map,查找小于或等于输入的最大整数值。找到后,将罗马数字表示添加到输出的末尾,从输入中减去相应的整数值,然后重复此过程。

如果您仍然不清楚to_roman()函数是如何工作的,请在while循环的末尾添加一个print()调用


while n >= integer:
    result += numeral
    n -= integer
    print('subtracting {0} from input, adding {1} to output'.format(integer, numeral))

使用调试print()语句,输出如下所示

>>> import roman1
>>> roman1.to_roman(1424)
subtracting 1000 from input, adding M to output
subtracting 400 from input, adding CD to output
subtracting 10 from input, adding X to output
subtracting 10 from input, adding X to output
subtracting 4 from input, adding IV to output
'MCDXXIV'

因此,to_roman()函数似乎可以工作,至少在此手动现场检查中是这样的。但是它会通过您编写的测试用例吗?

you@localhost:~/diveintopython3/examples$ python3 romantest1.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok               

----------------------------------------------------------------------
Ran 1 test in 0.016s

OK
  1. 万岁!to_roman()函数通过了“已知值”测试用例。它并不全面,但它确实使用各种输入对该函数进行了测试,包括产生每个单个字符罗马数字的输入、最大可能的输入(3999)以及产生最长罗马数字的输入(3888)。此时,您可以有把握地相信该函数对您可以向其抛出的任何有效输入值都有效。

“好的”输入?嗯。那不好的输入呢?

“停止并着火”

仅仅测试函数在给定好的输入时是否成功是不够的;你还必须测试当给定不好的输入时它们是否失败。而且不仅仅是任何类型的失败;它们必须以你预期的方式失败。

>>> import roman1
>>> roman1.to_roman(4000)
'MMMM'
>>> roman1.to_roman(5000)
'MMMMM'
>>> roman1.to_roman(9000) 
'MMMMMMMMM'
  1. 这绝对不是你想要的 — 那甚至不是一个有效的罗马数字!事实上,这些数字中的每一个都超出了可接受的输入范围,但函数仍然返回了一个错误的值。默默地返回错误值是非常糟糕的;如果程序要失败,最好是它快速且响亮地失败。“停止并着火”,正如谚语所说。在 Python 中,停止并着火的方式是抛出异常。

要问自己的问题是,“如何将这表达成一个可测试的要求?” 以下是一个开始

to_roman() 函数在给定大于 3999 的整数时应该抛出 OutOfRangeError

那个测试会是什么样子?

[下载 romantest2.py]

import unittest, roman2
class ToRomanBadInput(unittest.TestCase):                                 
    def test_too_large(self):                                             
        '''to_roman should fail with large input'''
        self.assertRaises(roman2.OutOfRangeError, roman2.to_roman, 4000)  
  1. 与前面的测试用例类似,你创建一个从 unittest.TestCase 继承的类。你可以在一个类中拥有多个测试(正如你将在本章稍后看到的那样),但我选择在这里创建一个新类,因为这个测试与上一个测试不同。我们将把所有好的输入测试放在一个类中,并将所有不好的输入测试放在另一个类中。
  2. 与前面的测试用例类似,测试本身是类的其中一个方法,其名称以 test 开头。
  3. unittest.TestCase 类提供 assertRaises 方法,该方法接受以下参数:你所期待的异常、你正在测试的函数以及你传递给该函数的参数。(如果你正在测试的函数需要多个参数,则按顺序将它们全部传递给 assertRaises,它会将它们直接传递给你正在测试的函数。)

仔细注意这最后一行代码。而不是直接调用 to_roman() 并手动检查它是否抛出了特定的异常(通过将其包装在 try...except 中),assertRaises 方法已经为我们封装了所有这些。你所做的只是告诉它你期待的异常(roman2.OutOfRangeError)、函数(to_roman())以及函数的参数(4000)。assertRaises 方法负责调用 to_roman() 并检查它是否抛出了 roman2.OutOfRangeError

还要注意,你将 to_roman() 函数本身作为参数传递;你并没有调用它,也没有将它的名称作为字符串传递。我最近是否提到过 Python 中的一切都是对象 是多么方便?

那么,当你使用这个新的测试运行测试套件时会发生什么呢?

you@localhost:~/diveintopython3/examples$ python3 romantest2.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok
test_too_large (__main__.ToRomanBadInput)
to_roman should fail with large input ... ERROR                         

======================================================================
ERROR: to_roman should fail with large input                          
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest2.py", line 78, in test_too_large
    self.assertRaises(roman2.OutOfRangeError, roman2.to_roman, 4000)
AttributeError: 'module' object has no attribute 'OutOfRangeError'      

----------------------------------------------------------------------
Ran 2 tests in 0.000s

FAILED (errors=1)
  1. 你应该期望它会失败(因为你还没有编写任何代码来通过它),但是……它并没有真正“失败”,而是出现了一个“错误”。这是一个细微但重要的区别。单元测试实际上有三个返回值:通过、失败和错误。通过,当然,意味着测试通过了 — 代码做了你预期的事情。“失败”是之前测试用例所做的事情(直到你编写代码使其通过) — 它执行了代码,但结果不是你所预期的。“错误”意味着代码甚至没有正确地执行。
  2. 为什么代码没有正确地执行?回溯告诉你了一切。你正在测试的模块没有名为 OutOfRangeError 的异常。记住,你将这个异常传递给了 assertRaises() 方法,因为这是你希望函数在给定超出范围的输入时抛出的异常。但是这个异常不存在,所以调用 assertRaises() 方法失败了。它甚至没有机会测试 to_roman() 函数;它没有进行到那一步。

要解决这个问题,你需要在 roman2.py 中定义 OutOfRangeError 异常。

class OutOfRangeError(ValueError):  
    pass                            
  1. 异常是类。一个“超出范围”的错误是一种值错误 — 参数值超出了其可接受的范围。因此,这个异常继承自内置的 ValueError 异常。这并非严格必要(它可以只继承自基本 Exception 类),但它感觉很合理。
  2. 异常实际上并不做任何事情,但你需要至少一行代码来创建一个类。调用 pass 确实什么也不做,但它是一行 Python 代码,所以这使得它成为一个类。

现在再次运行测试套件。

you@localhost:~/diveintopython3/examples$ python3 romantest2.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok
test_too_large (__main__.ToRomanBadInput)
to_roman should fail with large input ... FAIL                          

======================================================================
FAIL: to_roman should fail with large input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest2.py", line 78, in test_too_large
    self.assertRaises(roman2.OutOfRangeError, roman2.to_roman, 4000)
AssertionError: OutOfRangeError not raised by to_roman                 

----------------------------------------------------------------------
Ran 2 tests in 0.016s

FAILED (failures=1)
  1. 新的测试仍然没有通过,但它也没有返回错误。相反,测试失败了。这是进步!这意味着调用 assertRaises() 方法这次成功了,并且单元测试框架实际上测试了 to_roman() 函数。
  2. 当然,to_roman() 函数并没有抛出你刚刚定义的 OutOfRangeError 异常,因为你还没有告诉它这样做。这是好消息!这意味着这是一个有效的测试用例 — 在你编写代码使其通过之前,它就失败了。

现在你可以编写代码来使这个测试通过。

[下载 roman2.py]

def to_roman(n):
    '''convert integer to Roman numeral'''
    if n > 3999:
        raise OutOfRangeError('number out of range (must be less than 4000)')  

    result = ''
    for numeral, integer in roman_numeral_map:
        while n >= integer:
            result += numeral
            n -= integer
    return result
  1. 这很简单:如果给定的输入(n)大于 3999,则抛出一个 OutOfRangeError 异常。单元测试不会检查异常附带的人类可读字符串,尽管你可以编写另一个测试来检查它(但要小心国际化问题,因为字符串会因用户的语言或环境而异)。

这会使测试通过吗?让我们看看。

you@localhost:~/diveintopython3/examples$ python3 romantest2.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok
test_too_large (__main__.ToRomanBadInput)
to_roman should fail with large input ... ok                            

----------------------------------------------------------------------
Ran 2 tests in 0.000s

OK
  1. 万岁!两个测试都通过了。因为你以迭代的方式工作,在测试和编码之间来回切换,所以你可以确定你刚刚编写的两行代码是导致一个测试从“失败”变为“通过”的原因。这种信心并不便宜,但它会在你的代码的生命周期中为它自己买单。

更多停止,更多火焰

除了测试过大的数字,你还需要测试过小的数字。正如 我们在功能需求中提到的那样,罗马数字不能表达 0 或负数。

>>> import roman2
>>> roman2.to_roman(0)
''
>>> roman2.to_roman(-1)
''

好吧,不好。让我们为这些条件中的每一个添加测试。

[下载 romantest3.py]

class ToRomanBadInput(unittest.TestCase):
    def test_too_large(self):
        '''to_roman should fail with large input'''
        self.assertRaises(roman3.OutOfRangeError, roman3.to_roman, 4000)  

    def test_zero(self):
        '''to_roman should fail with 0 input'''
        self.assertRaises(roman3.OutOfRangeError, roman3.to_roman, 0)     

    def test_negative(self):
        '''to_roman should fail with negative input'''
        self.assertRaises(roman3.OutOfRangeError, roman3.to_roman, -1)    
  1. test_too_large() 方法自上一步以来没有改变。我在这里包含它是为了显示新代码的位置。
  2. 这是一个新的测试:test_zero() 方法。与 test_too_large() 方法一样,它告诉 unittest.TestCase 中定义的 assertRaises() 方法使用参数 0 调用 to_roman() 函数,并检查它是否抛出了适当的异常 OutOfRangeError
  3. test_negative() 方法几乎完全相同,只是它将 -1 传递给 to_roman() 函数。如果这些新的测试中的任何一个没有抛出 OutOfRangeError(无论是函数返回了一个实际的值,还是它抛出了其他异常),测试都被认为是失败的。

现在检查测试是否失败

you@localhost:~/diveintopython3/examples$ python3 romantest3.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok
test_negative (__main__.ToRomanBadInput)
to_roman should fail with negative input ... FAIL
test_too_large (__main__.ToRomanBadInput)
to_roman should fail with large input ... ok
test_zero (__main__.ToRomanBadInput)
to_roman should fail with 0 input ... FAIL

======================================================================
FAIL: to_roman should fail with negative input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest3.py", line 86, in test_negative
    self.assertRaises(roman3.OutOfRangeError, roman3.to_roman, -1)
AssertionError: OutOfRangeError not raised by to_roman

======================================================================
FAIL: to_roman should fail with 0 input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest3.py", line 82, in test_zero
    self.assertRaises(roman3.OutOfRangeError, roman3.to_roman, 0)
AssertionError: OutOfRangeError not raised by to_roman

----------------------------------------------------------------------
Ran 4 tests in 0.000s

FAILED (failures=2)

很好。两个测试都按预期失败了。现在让我们切换到代码,看看我们可以做些什么来使它们通过。

[下载 roman3.py]

def to_roman(n):
    '''convert integer to Roman numeral'''
    if not (0 < n < 4000):                                              
        raise OutOfRangeError('number out of range (must be 1..3999)')  

    result = ''
    for numeral, integer in roman_numeral_map:
        while n >= integer:
            result += numeral
            n -= integer
    return result
  1. 这是一个很好的 Python 风格的捷径:一次进行多个比较。这等同于 if not ((0 < n) and (n < 4000)),但它更容易阅读。这唯一的一行代码应该可以捕获过大、负数或零的输入。
  2. 如果你更改了条件,请确保更新人类可读的错误字符串以匹配。unittest 框架不会关心,但如果你的代码抛出了描述不正确的异常,这将使手动调试变得困难。

我可以向你展示一系列不相关的示例,以证明一次进行多个比较的捷径是有效的,但我只会运行单元测试并证明这一点。

you@localhost:~/diveintopython3/examples$ python3 romantest3.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok
test_negative (__main__.ToRomanBadInput)
to_roman should fail with negative input ... ok
test_too_large (__main__.ToRomanBadInput)
to_roman should fail with large input ... ok
test_zero (__main__.ToRomanBadInput)
to_roman should fail with 0 input ... ok

----------------------------------------------------------------------
Ran 4 tests in 0.016s

OK

还有一件事……

还有一个 功能需求 用于将数字转换为罗马数字:处理非整数。

>>> import roman3
>>> roman3.to_roman(0.5) 
''
>>> roman3.to_roman(1.0) 
'I'
  1. 哦,这不好。
  2. 哦,这更糟糕。这两个情况都应该抛出一个异常。相反,它们给出了错误的结果。

测试非整数并不困难。首先,定义一个 NotIntegerError 异常。

# roman4.py
class OutOfRangeError(ValueError): pass
class NotIntegerError(ValueError): pass

接下来,编写一个测试用例,检查 NotIntegerError 异常。

class ToRomanBadInput(unittest.TestCase):
    .
    .
    .
    def test_non_integer(self):
        '''to_roman should fail with non-integer input'''
        self.assertRaises(roman4.NotIntegerError, roman4.to_roman, 0.5)

现在检查测试是否正确地失败。

you@localhost:~/diveintopython3/examples$ python3 romantest4.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok
test_negative (__main__.ToRomanBadInput)
to_roman should fail with negative input ... ok
test_non_integer (__main__.ToRomanBadInput)
to_roman should fail with non-integer input ... FAIL
test_too_large (__main__.ToRomanBadInput)
to_roman should fail with large input ... ok
test_zero (__main__.ToRomanBadInput)
to_roman should fail with 0 input ... ok

======================================================================
FAIL: to_roman should fail with non-integer input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest4.py", line 90, in test_non_integer
    self.assertRaises(roman4.NotIntegerError, roman4.to_roman, 0.5)
AssertionError: NotIntegerError not raised by to_roman

----------------------------------------------------------------------
Ran 5 tests in 0.000s

FAILED (failures=1)

编写使测试通过的代码。

def to_roman(n):
    '''convert integer to Roman numeral'''
    if not (0 < n < 4000):
        raise OutOfRangeError('number out of range (must be 1..3999)')
    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
  1. 内置的 isinstance() 函数测试一个变量是否是一个特定的类型(或者,从技术上讲,任何子类型)。
  2. 如果参数 n 不是 int,则抛出我们新创建的 NotIntegerError 异常。

最后,检查代码是否确实使测试通过。

you@localhost:~/diveintopython3/examples$ python3 romantest4.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok
test_negative (__main__.ToRomanBadInput)
to_roman should fail with negative input ... ok
test_non_integer (__main__.ToRomanBadInput)
to_roman should fail with non-integer input ... ok
test_too_large (__main__.ToRomanBadInput)
to_roman should fail with large input ... ok
test_zero (__main__.ToRomanBadInput)
to_roman should fail with 0 input ... ok

----------------------------------------------------------------------
Ran 5 tests in 0.000s

OK

to_roman() 函数通过了所有测试,我想不出任何其他测试了,所以是时候继续进行 from_roman() 了。

令人愉悦的对称性

将字符串从罗马数字转换为整数听起来比将整数转换为罗马数字更难。当然,存在验证问题。很容易检查一个整数是否大于 0,但要检查一个字符串是否是一个有效的罗马数字则要困难一些。但是我们已经构建了 一个正则表达式来检查罗马数字,所以这一部分已经完成了。

这留下了转换字符串本身的问题。正如我们将在稍后看到的那样,由于我们定义了丰富的用于将单个罗马数字映射到整数的结构,from_roman() 函数的核心与 to_roman() 函数一样简单。

但首先,测试。我们需要一个“已知值”测试来进行点检以确保准确性。我们的测试套件已经包含了 已知值的映射;让我们重用它。

    def test_from_roman_known_values(self):
        '''from_roman should give known result with known input'''
        for integer, numeral in self.known_values:
            result = roman5.from_roman(numeral)
            self.assertEqual(integer, result)

这里有一份令人愉悦的对称性。to_roman()from_roman() 函数是彼此的逆函数。第一个将整数转换为特殊格式的字符串,第二个将特殊格式的字符串转换为整数。理论上,我们应该能够通过传递给 to_roman() 函数来获取一个字符串,然后将该字符串传递给 from_roman() 函数来获取一个整数,最终得到与原始数字相同的数字来进行“往返”转换。

n = from_roman(to_roman(n)) for all values of n

在这种情况下,“所有值”意味着任何介于 1..3999 之间的数字,因为这是 to_roman() 函数的有效输入范围。我们可以在一个测试用例中表达这种对称性,该测试用例运行所有 1..3999 的值,调用 to_roman(),调用 from_roman(),并检查输出是否与原始输入相同。

class RoundtripCheck(unittest.TestCase):
    def test_roundtrip(self):
        '''from_roman(to_roman(n))==n for all n'''
        for integer in range(1, 4000):
            numeral = roman5.to_roman(integer)
            result = roman5.from_roman(numeral)
            self.assertEqual(integer, result)

这些新的测试甚至还没有失败。我们还没有定义 from_roman() 函数,所以它们只会抛出错误。

you@localhost:~/diveintopython3/examples$ python3 romantest5.py
E.E....
======================================================================
ERROR: test_from_roman_known_values (__main__.KnownValues)
from_roman should give known result with known input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest5.py", line 78, in test_from_roman_known_values
    result = roman5.from_roman(numeral)
AttributeError: 'module' object has no attribute 'from_roman'

======================================================================
ERROR: test_roundtrip (__main__.RoundtripCheck)
from_roman(to_roman(n))==n for all n
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest5.py", line 103, in test_roundtrip
    result = roman5.from_roman(numeral)
AttributeError: 'module' object has no attribute 'from_roman'

----------------------------------------------------------------------
Ran 7 tests in 0.019s

FAILED (errors=2)

一个快速的存根函数将解决这个问题。

# roman5.py
def from_roman(s):
    '''convert Roman numeral to integer'''

(嘿,你注意到了吗?我定义了一个函数,它只有 文档字符串。这是合法的 Python。事实上,一些程序员对此深信不疑。“不要使用存根;要记录!”)

现在测试用例将真正失败。

you@localhost:~/diveintopython3/examples$ python3 romantest5.py
F.F....
======================================================================
FAIL: test_from_roman_known_values (__main__.KnownValues)
from_roman should give known result with known input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest5.py", line 79, in test_from_roman_known_values
    self.assertEqual(integer, result)
AssertionError: 1 != None

======================================================================
FAIL: test_roundtrip (__main__.RoundtripCheck)
from_roman(to_roman(n))==n for all n
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest5.py", line 104, in test_roundtrip
    self.assertEqual(integer, result)
AssertionError: 1 != None

----------------------------------------------------------------------
Ran 7 tests in 0.002s

FAILED (failures=2)

现在是时候编写 from_roman() 函数了。

def from_roman(s):
    """convert Roman numeral to integer"""
    result = 0
    index = 0
    for numeral, integer in roman_numeral_map:
        while s[index:index+len(numeral)] == numeral:  
            result += integer
            index += len(numeral)
    return result
  1. 这里的模式与 to_roman() 函数相同。你遍历你的罗马数字数据结构(一个元组的元组),但不是尽可能地匹配最高整数,而是尽可能地匹配“最高”的罗马数字字符字符串。

如果你不清楚 from_roman() 的工作原理,请在 while 循环的末尾添加一个 print 语句

def from_roman(s):
    """convert Roman numeral to integer"""
    result = 0
    index = 0
    for numeral, integer in roman_numeral_map:
        while s[index:index+len(numeral)] == numeral:
            result += integer
            index += len(numeral)
            print('found', numeral, 'of length', len(numeral), ', adding', integer)
>>> import roman5
>>> roman5.from_roman('MCMLXXII')
found M of length 1, adding 1000
found CM of length 2, adding 900
found L of length 1, adding 50
found X of length 1, adding 10
found X of length 1, adding 10
found I of length 1, adding 1
found I of length 1, adding 1
1972

是时候重新运行测试了。

you@localhost:~/diveintopython3/examples$ python3 romantest5.py
.......
----------------------------------------------------------------------
Ran 7 tests in 0.060s

OK

这里有两个好消息。第一个是 from_roman() 函数对于良好输入有效,至少对于所有 已知值 是这样。第二个是“往返”测试也通过了。结合已知值测试,你可以相当确定 to_roman()from_roman() 函数对于所有可能的良好值都能正常工作。(这并非保证;理论上,to_roman() 可能存在错误,导致对于某些特定输入集生成错误的罗马数字,并且 from_roman() 可能存在相应的错误,对于 to_roman() 错误生成的那些罗马数字集产生相同的错误整数值。根据你的应用和需求,这个可能性可能会困扰你;如果是这样,编写更多全面的测试用例,直到不再困扰你为止。)

更多错误输入

现在 from_roman() 函数已经能够对良好输入正常工作,是时候将最后一个拼图部分拼凑起来了:让它对错误输入也能正常工作。这意味着要找到一种方法来查看一个字符串,并确定它是否是一个有效的罗马数字。这本质上比 to_roman() 函数中验证数字输入 更难,但你有一个强大的工具可用:正则表达式。(如果你不熟悉正则表达式,现在是阅读 正则表达式章节 的好时机。)

正如你在 案例研究:罗马数字 中所见,使用字母 MDCLXVI 来构建罗马数字,有一些简单的规则。让我们回顾一下这些规则。

因此,一个有用的测试是确保 from_roman() 函数在向其传递包含太多重复数字的字符串时应该失败。多少个数字算“太多”取决于数字本身。

class FromRomanBadInput(unittest.TestCase):
    def test_too_many_repeated_numerals(self):
        '''from_roman should fail with too many repeated numerals'''
        for s in ('MMMM', 'DD', 'CCCC', 'LL', 'XXXX', 'VV', 'IIII'):
            self.assertRaises(roman6.InvalidRomanNumeralError, roman6.from_roman, s)

另一个有用的测试是检查某些模式没有重复。例如,IX9,但 IXIX 永远无效。

    def test_repeated_pairs(self):
        '''from_roman should fail with repeated pairs of numerals'''
        for s in ('CMCM', 'CDCD', 'XCXC', 'XLXL', 'IXIX', 'IVIV'):
            self.assertRaises(roman6.InvalidRomanNumeralError, roman6.from_roman, s)

第三个测试可以检查数字是否按正确的顺序排列,从最高值到最低值。例如,CL150,但 LC 永远无效,因为 50 的数字永远不能放在 100 的数字前面。此测试包括一组随机选择的无效先行词:I 放在 M 之前,V 放在 X 之前,等等。

    def test_malformed_antecedents(self):
        '''from_roman should fail with malformed antecedents'''
        for s in ('IIMXCC', 'VX', 'DCM', 'CMM', 'IXIV',
                  'MCMC', 'XCX', 'IVI', 'LM', 'LD', 'LC'):
            self.assertRaises(roman6.InvalidRomanNumeralError, roman6.from_roman, s)

所有这些测试都依赖于 from_roman() 函数抛出一个新的异常 InvalidRomanNumeralError,我们还没有定义它。

# roman6.py
class InvalidRomanNumeralError(ValueError): pass

所有这三个测试都应该失败,因为 from_roman() 函数目前没有任何有效性检查。(如果它们现在没有失败,那么它们到底在测试什么?)

you@localhost:~/diveintopython3/examples$ python3 romantest6.py
FFF.......
======================================================================
FAIL: test_malformed_antecedents (__main__.FromRomanBadInput)
from_roman should fail with malformed antecedents
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest6.py", line 113, in test_malformed_antecedents
    self.assertRaises(roman6.InvalidRomanNumeralError, roman6.from_roman, s)
AssertionError: InvalidRomanNumeralError not raised by from_roman

======================================================================
FAIL: test_repeated_pairs (__main__.FromRomanBadInput)
from_roman should fail with repeated pairs of numerals
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest6.py", line 107, in test_repeated_pairs
    self.assertRaises(roman6.InvalidRomanNumeralError, roman6.from_roman, s)
AssertionError: InvalidRomanNumeralError not raised by from_roman

======================================================================
FAIL: test_too_many_repeated_numerals (__main__.FromRomanBadInput)
from_roman should fail with too many repeated numerals
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest6.py", line 102, in test_too_many_repeated_numerals
    self.assertRaises(roman6.InvalidRomanNumeralError, roman6.from_roman, s)
AssertionError: InvalidRomanNumeralError not raised by from_roman

----------------------------------------------------------------------
Ran 10 tests in 0.058s

FAILED (failures=3)

好极了。现在,我们只需要将 用于测试有效罗马数字的正则表达式 添加到 from_roman() 函数中。

roman_numeral_pattern = re.compile('''
    ^                   # beginning of string
    M{0,3}              # thousands - 0 to 3 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 from_roman(s):
    '''convert Roman numeral to integer'''
    if not roman_numeral_pattern.search(s):
        raise InvalidRomanNumeralError('Invalid Roman numeral: {0}'.format(s))

    result = 0
    index = 0
    for numeral, integer in roman_numeral_map:
        while s[index : index + len(numeral)] == numeral:
            result += integer
            index += len(numeral)
    return result

并重新运行测试……

you@localhost:~/diveintopython3/examples$ python3 romantest7.py
..........
----------------------------------------------------------------------
Ran 10 tests in 0.066s

OK

今年的年度反高潮大奖颁发给……单词“OK”,它是在所有测试通过时由 unittest 模块打印的。

© 2001–11 Mark Pilgrim