# Chapter 9 單元測試
> " Certitude is not the test of certainty. We have been cocksure of many things that were not so. "
> — [Oliver Wendell Holmes, Jr.](http://en.wikiquote.org/wiki/Oliver_Wendell_Holmes,_Jr.)
## (不要)深入
在此章節中,你將要編寫及調試一系列用于阿拉伯數字與羅馬數字相互轉換的方法。你閱讀了在[“案例學習:羅馬數字”](regular-expressions.html#romannumerals)中關于構建及校驗羅馬數字的機制。那么,現在考慮擴展該機制為一個雙向的方法。
[羅馬數字的規則](regular-expressions.html#romannumerals)引出很多有意思的結果:
1. 只有一種正確的途徑用阿拉伯數字表示羅馬數字。
2. 反過來一樣,一個字符串類型的有效的羅馬數字也僅可以表示一個阿拉伯數字(即,這種轉換方式也是只有一種)。
3. 只有有限范圍的阿拉伯數字可以以羅馬數字表示,那就是1-3999。而羅馬數字表示大數字卻有幾種方式。例如,為了表示一個數字連續出現時正確的值則需要乘以`1000`。為了達到本節的目的,限定羅馬數字在 1 到 3999 之間。
4. 無法用羅馬數字來表示 0 。
5. 無法用羅馬數字來表示負數 。
6. 無法用羅馬數字來表示分數或非整數 。
現在,開始設計 `roman.py` 模塊。它有兩個主要的方法:`to_roman()` 及 `from_roman()`。`to_roman()` 方法接收一個從 `1` 到 `3999` 之間的整型數字,然后返回一個字符串類型的羅馬數字。
在這里停下來。現在讓我們進行一些意想不到的操作:編寫一個測試用例來檢測 `to_roman` 函數是否實現了你想要的功能。你想得沒錯:你正在編寫測試尚未編寫代碼的代碼。
這就是所謂的_測試驅動開發_ 或 TDD。那兩個轉換方法( `to_roman()` 及之后的 `from_roman()`)可以獨立于任何使用它們的大程序而作為一個單元來被編寫及測試。Python 自帶一個單元測試框架,被恰當地命名為 `unittest` 模塊。
單元測試是整個以測試為中心的開發策略中的一個重要部分。編寫單元測試應該安排在項目的早期,同時要讓它隨同代碼及需求變更一起更新。很多人都堅持測試代碼應該先于被測試代碼的,而這種風格也是我在本節中所主張的。但是,不管你何時編寫,單元測試都是有好處的。
* 在編寫代碼之前,通過編寫單元測試來強迫你使用有用的方式細化你的需求。
* 在編寫代碼時,單元測試可以使你避免過度編碼。當所有測試用例通過時,實現的方法就完成了。
* 重構代碼時,單元測試用例有助于證明新版本的代碼跟老版本功能是一致的。
* 在維護代碼期間,如果有人對你大喊:你最新的代碼修改破壞了原有代碼的狀態,那么此時單元測試可以幫助你反駁(“_先生_,所有單元測試用例通過了我才提交代碼的...”)。
* 在團隊編碼中,縝密的測試套件可以降低你的代碼影響別人代碼的機會,這是因為你需要優先執行別人的單元測試用例。(我曾經在代碼沖刺見過這種實踐。一個團隊把任務分解,每個人領取其中一小部分任務,同時為其編寫單元測試;然后,團隊相互分享他們的單元測試用例。這樣,所有人都可以在編碼過程中提前發現誰的代碼與其他人的不可以良好工作。)
## 一個簡單的問題
每個測試都是一個孤島。
一個測試用例僅回答一個關于它正在測試的代碼問題。一個測試用例應該可以:
* ……完全自動運行,而不需要人工干預。單元測試幾乎是全自動的。
* ……自主判斷被測試的方法是通過還是失敗,而不需要人工解釋結果。
* ……獨立運行,而不依賴其它測試用例(即使測試的是同樣的方法)。即,每一個測試用例都是一個孤島。
讓我們據此為第一個需求建立一個測試用例:
1. `to_roman()` 方法應該返回代表`1`-`3999`的羅馬數字。
這些代碼功效如何并不那么顯而易見。它定義了一個沒有`__init__` 方法的類。而該類_當然_有其它方法,但是這些方法都不會被調用。在整個腳本中,有一個__main__ 塊,但它并不引用該類及它的方法。但我承諾,它做別的事情了。
```
import roman1
import unittest
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'),
'''to_roman should give known result with known input'''
for integer, numeral in self.known_values:
if __name__ == '__main__':
unittest.main()
```
1. 為了編寫測試用例,首先使該測試用例類成為`unittest` 模塊的`TestCase` 類的子類。TestCase 提供了很多你可以用于測試特定條件的測試用例的有用的方法。
2. 這是一張我手工核實過的整型數字-羅馬數字對的列表。它包括最小的十個數字、最大數字、每一個有唯一一個字符串格式的羅馬數字的數字以及一個有其它有效數字產生的隨機數。你沒有必要測試每一個可能的輸入,而需要測試所有明顯的邊界用例。
3. 每一個獨立的測試都有它自己的不含參數及沒有返回值的方法。如果方法不拋出異常而正常退出則認為測試通過;否則,測試失敗。
4. 這里調用了真實的 `to_roman()` 方法. (當然,該方法還沒編寫;但一旦該方法被實現,這就是調用它的行號)。注意,現在你已經為 `to_roman()` 方法定義了 接口:它必須包含一個整型(被轉換的數字)及返回一個字符串(羅馬數字的表示形式)。如果 接口 實現與這些定義不一致,那么測試就會被視為失敗。同樣,當你調用 `to_roman()` 時,不要捕獲任何異常。這些都是unittest 故意設計的。當你以有效的輸入調用 `to_roman()` 時它不會拋出異常。如果 `to_roman()` 拋出了異常,則測試被視為失敗。
5. 假設 `to_roman()` 方法已經被正確定義,正確調用,成功實現以及返回了一個值,那么最后一步就是去檢查它的返回值是否 _right_ 。這是測試中一個普遍的問題。`TestCase` 類提供了一個方法 `assertEqual` 來檢查兩個值是否相等。如果 `to_roman()` (`result`) 的返回值跟已知的期望值g (`numeral`)不一致,則拋出異常,并且測試失敗。如果兩值相等, `assertEqual` 不會做任何事情。如果 `to_roman()` 的所有返回值均與已知的期望值一致,則 `assertEqual` 不會拋出任何異常,于是,`test_to_roman_known_values` 最終會會正常退出,這就意味著 `to_roman()` 通過此次測試。
編寫一個失敗的測試,然后進行編碼直到該測試通過。
一旦你有了測試用例,你就可以開始編寫 `to_roman()` 方法。首先,你應該用一個空方法作為存根,同時確認該測試失敗。因為如果在編寫任何代碼之前測試已經通過,那么你的測試對你的代碼是完全不會有效果的!單元測試就像跳舞:測試先行,編碼跟隨。編寫一個失敗的測試,然后進行編碼直到該測試通過。
```
# roman1.py
def to_roman(n):
'''convert integer to Roman numeral'''
```
1. 在此階段,你想定義to_roman()方法的 API ,但是你還不想編寫(首先,你的測試需要失敗)。為了存根,需要使用Python 保留關鍵字`pass`,它恰恰什么都沒做。
在命令行上運行 `romantest1.py` 來執行該測試。如果使用-v命令行參數的話,會有更詳細的輸出來幫助你精確地查看每一條用例的執行過程。幸運的話,你的輸出應該如下:
```
you@localhost:~/diveintopython3/examples$ python3 romantest1.py -v
======================================================================
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)
----------------------------------------------------------------------
```
1. 運行腳本就會執行 `unittest.main()` , 該方法執行了每一條測試用例。而每一條測試用例都是 `romantest.py` 中的類方法。這些測試類沒有必要的組織要求;它們每一個都包括一個獨立的測試方法,或者你也可以編寫一個含有多個測試方法的類。唯一的要求就是每一個測試類都必須繼承 `unittest.TestCase`。
2. 對于每一個測試用例, `unittest` 模塊會打印出測試方法的 `docstring` ,并且說明該測試失敗還是成功。正如預期那樣,該測試用例失敗了。
3. 對于每一個失敗的測試用例, `unittest` 模塊會打印出詳細的跟蹤信息。在該用例中, `assertEqual()` 的調用拋出了一個 `AssertionError` 的異常,這是因為 `to_roman(1)` 本應該返回 `'I'` 的,但是它沒有。(因為沒有顯示的返回值,故方法返回了 Python 的空值 `None`)
4. 在說明每個用例的詳細執行結果之后, `unittest` 打印出一個簡述來說明“多少用例被執行了”和“測試執行了多長時間”。
5. 從整體上說,該測試執行失敗,因為至少有一條用例沒有成功。如果測試用例沒有通過的話, `unittest` 可以區別用例執行失敗跟程序錯誤的。像 `assertXYZ` 、`assertRaises` 這樣的 `assertEqual` 方法的失敗是因為被聲明的條件不是為真,或者預期的異常沒有拋出。錯誤,則是另一種異常,它是因為被測試的代碼或者單元測試用例本身的代碼問題而引起的。
_至此_,你可以實現 `to_roman()` 方法了。
```
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),
def to_roman(n):
'''convert integer to Roman numeral'''
result = ''
for numeral, integer in roman_numeral_map:
result += numeral
n -= integer
return result
```
1. `roman_numeral_map` 是一個由元組組成的元組,它定義了三樣東西:代表最基本的羅馬數字的字符、羅馬數字的順序(逆序,從 `M` 到 `I`)、每一個羅馬數字的阿拉伯數值。每一個內部的元組都是一個`(`數`,`值`)`對。它不但定義了單字符羅馬數字,也定義了雙字符羅馬數字,如`CM`(“比一千小一百”)。該元組使得 `to_roman()` 方法實現起來更簡單。
2. 這里得益于 `roman_numeral_map` 的數據結構,因為你不需要任何特別得邏輯去處理減法。為了轉化成羅馬數字,通過查找等于或者小于輸入值的最大值來簡化對 `roman_numeral_map` 的迭代。一旦找到,就把羅馬數字的字符串追加至輸出值(result)末段,同時輸入值要減去相應的數值,如此重復。
如果你仍然不清楚 `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)
----------------------------------------------------------------------
Ran 1 test in 0.016s
OK
```
1. 萬歲!`to_roman()` 函數通過了“known values” 測試用例。該測試用例并不復雜,但是它的確使該方法按著輸入值的變化而執行,其中的輸入值包括:每一個單字符羅馬數字、最大值數字(`3999`)、最長字符串數字(`3888`)。通過這些,你就可以有理由對“該方法接收任何正常的輸入值都工作正常”充滿信心了。
“正常”輸入?”嗯。那“非法”輸入呢?
## “停止然后著火”
Python 方式的停止并點火實際是引發一個例外。
僅僅在“正常”值時證明方法通過的測試是不夠的;你同樣需要測試當輸入“非法”值時方法失敗。但并不是說要枚舉所有的失敗類型,而是說必要在你預期的范圍內失敗。
```
>>> import roman1
>>> roman1.to_roman(4000)
'MMMM'
>>> roman1.to_roman(5000)
'MMMMM'
'MMMMMMMMM'
```
1. 這明顯不是你所期望的──那也不是一個合法的羅馬數字!事實上,這些輸入值都超過了允許的范圍,但該函數卻返回了假值。悄悄返回的錯誤值是 _很糟糕_ 的,因為如果一個程序要掛掉的話,迅速且引人注目地掛掉會好很多。正如諺語“停止然后著火”。Python 方式的停止并點火實際是引發一個例外。
那問題是:我該如何表達這些內容為可測試需求呢?下面就是一個開始:
> 當輸入值大于 `3999` 時, `to_roman()` 函數應該拋出一個 `OutOfRangeError` 異常。
具體測試代碼如下:
```
'''to_roman should fail with large input'''
```
1. 如前一個測試用例,創建一個繼承于 `unittest.TestCase` 的類。你可以在每個類中實現多個測試(正如你在本節中將會看到的一樣),但是我卻選擇了創建一個新類,因為該測試與上一個有點不同。這樣,我們可以把正常輸入的測試跟非法輸入的測試分別放入不同的兩個類中。
2. 如前一個測試用例,測試本身是類一個方法,并且該方法以 `test` 開頭命名。
3. `unittest.TestCase` 類提供e `assertRaises` 方法,該方法需要以下參數:你期望的異常、你要測試的方法及傳入給方法的參數。(如果被測試的方法需要多個參數的話,則把所有參數依次傳入 `assertRaises`, assertRaises 會正確地把參數傳遞給被測方法的。)
請關注代碼的最后一行。這里并不需要直接調用 `to_roman()` ,同時也不需要手動檢查它拋出的異常類型(通過 [一個 `try...except` 塊](your-first-python-program.html#exceptions)來包裝),而這些 `assertRaises` 方法都給我們完成了。你要做的所有事情就是告訴assertRaises你期望的異常類型( `roman2.OutOfRangeError`)、被測方法(`to_roman()`)以及方法的參數(`4000`)。`assertRaises` 方法負責調用 `to_roman()` 和檢查方法拋出 `roman2.OutOfRangeError` 的異常。
另外,注意你是把 `to_roman()` 方法作為參數傳遞;你沒有調用被測方法,也不是把被測方法作為一個字符串名字傳遞進去。我是否在之前提到過 [Python 中萬物皆對象](your-first-python-program.html#everythingisanobject)有多么輕便?
那么,當你執行該含有新測試的測試套件時,結果如下:
```
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)
======================================================================
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)
----------------------------------------------------------------------
Ran 2 tests in 0.000s
FAILED (errors=1)
```
1. 測試本應該是失敗的(因為并沒有任何代碼使它通過),但是它沒有真正的“失敗”,而是出現了“錯誤”。這里有些微妙但是重要的區別。單元測試事實上有 _三種_ 返回值:通過、失敗以及錯誤。“通過”,但當然就是說測試成功了──被測代碼符合你的預期。“失敗”就是就如之前的測試用例一樣(直到你編寫代碼令它通過)──執行了被測試的代碼但返回值并不是所期望的。“錯誤”就是被測試的代碼甚至沒有正確執行。
2. 為什么代碼沒有正確執行呢?回溯說明了一切。你正在測試的模塊沒有叫 `OutOfRangeError` 的異常。回憶一下,該異常是你傳遞給 `assertRaises()` 方法的,因為你期望當傳遞給被測試方法一個超大值時可以拋出該異常。但是,該異常并不存在,因此 `assertRaises()` 的調用會失敗。事實上測試代碼并沒有機會測試 `to_roman()` 方法,因為它還沒有到達那一步。
為了解決該問題,你需要在 `roman2.py` 中定義 `OutOfRangeError` 。
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)
======================================================================
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)
----------------------------------------------------------------------
Ran 2 tests in 0.016s
FAILED (failures=1)
```
1. 新的測試仍然沒有通過,但是它并沒有返回錯誤而是失敗。相反,測試失敗了。這就是進步!它意味著這回 `assertRaises()` 方法的調用是成功的,同時,單元測試框架事實上也測試了 `to_roman()` 函數。
2. 當然 `to_roman()` 方法沒有引發你所定義的 `OutOfRangeError` 異常,因為你并沒有讓它這么做。這真是個好消息!因為它意味著這是個合格的測試案例——在編寫代碼使之通過之前它將會以失敗為結果。
現在可以編寫代碼使其通過了。
```
def to_roman(n):
'''convert integer to Roman numeral'''
if n > 3999:
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)
----------------------------------------------------------------------
Ran 2 tests in 0.000s
OK
```
1. 萬歲!兩個測試都通過了。因為你是在測試與編碼之間來回反復開發的,所以你可以肯定使得其中一個測試從“失敗”轉變為“通過”的原因就是你剛才新添的兩行代碼。雖然這種信心來得并不簡單,但是這種代價會在你代碼的生命周期中得到回報。
## More Halting, More Fire
與測試超大值一樣,也必須測試超小值。[正如我們在功能需求中提到的那樣](#divingin),羅馬數字無法表達 0 或負數。
```
>>> import roman2
>>> roman2.to_roman(0)
''
>>> roman2.to_roman(-1)
''
```
顯然,_這不是_好的結果。讓我們為這些條件逐條添加測試。
```
class ToRomanBadInput(unittest.TestCase):
def test_too_large(self):
'''to_roman should fail with large input'''
def test_zero(self):
'''to_roman should fail with 0 input'''
def test_negative(self):
'''to_roman should fail with negative input'''
```
1. `test_too_large()` 方法跟之前的步驟一樣。我把它包含進來是為了說明新代碼的位置。
2. 這里是新的測試方法: `test_zero()` 。如 `test_too_large()` 一樣,它調用了在n `unittest.TestCase` 中定義的 `assertRaises()` 方法,并且以參數值 0 傳入給 `to_roman()`,最后檢查它拋出相應的異常:`OutOfRangeError`。
3. `test_negative()` 也幾乎類似,除了它給 `to_roman()` 函數傳入 `-1` 。如果新的測試中 _沒有_ 任何一個拋出了異常 `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)
```
太棒了!兩個測試都如期地失敗了。接著轉入被測試的代碼并且思考如何才能使得測試通過。
```
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. 這是Python優雅的快捷方法:一次性的多比較。它等價于 `if not ((0 < n) and (n < 4000))`,但前者更適合閱讀。這一行代碼應該捕獲那些超大的、負值的或者為 0 的輸入。
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
```
## 還有一件事情……
還有一個把阿拉伯數字轉換成羅馬數字的 [功能性需求](#divingin) :處理非整型數字。
```
>>> import roman3
''
'I'
```
1. 喔,糟糕了。
2. 喔,更糟糕了。兩個用例都本該拋出異常的。但卻返回了假的結果。
測試非整數并不困難。首先,定義一個 `NotIntegerError` 例外。
```
# roman4.py
class OutOfRangeError(ValueError): pass
<mark>class NotIntegerError(ValueError): pass</mark>
```
然后,編寫一個檢查 `NotIntegerError` 例外的案例。
```
class ToRomanBadInput(unittest.TestCase):
.
.
.
def test_non_integer(self):
'''to_roman should fail with non-integer input'''
<mark>self.assertRaises(roman4.NotIntegerError, roman4.to_roman, 0.5)</mark>
```
然后,檢查該測試是否可以正確地失敗。
```
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)
<mark>AssertionError: NotIntegerError not raised by to_roman</mark>
----------------------------------------------------------------------
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)')
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大容易,而檢查一個字符串是否為有效的羅馬數字則要困難些。但是,我們已經構造了[一個用于檢查羅馬數字的規則表](regular-expressions.html#romannumerals),因此規則表的工作可以免了。
現在剩余的工作就是轉換字符串了。正如我們將要看到的一樣,多虧我們定義的用于單個羅馬數字映射至阿拉伯數字的良好的數據結構,`from_roman()` 的實現本質上與 `to_roman()` 一樣簡單。
不過,測試先行!為了證明其準確性,我們將需要一個對“已知取值”進行的測試。我們的測試套件已經包含了[一個已知取值的映射表](#romantest1),那么,我們就重用它。
```
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()` 方法,得到一個整型數字,并且跟傳給to_roman()方法的數字是一樣的。
```
n = from_roman(to_roman(n)) for all values of n
```
在本用例中,“全有取值”是說 `從1到3999` 的所有數值,因為這是 `to_roman()` 方法的有效輸入范圍。為了表達這兩個方法之間的對稱性,我們可以設計這樣的測試用例,它的測試數據集是從`1到3999之間`(包括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'''
```
(嘿,你注意到了么?我定義了一個除了 [docstring](your-first-python-program.html#docstrings) 之外沒有任何東西的方法。這是合法的 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:
result += integer
index += len(numeral)
return result
```
1. 此處的匹配模式與 [`to_roman()`](#romantest1) 完全相同。遍歷整個羅馬數字數據結構 (一個元組的元組),與前面不同的是不去一個個地搜索最大的整數,而是搜尋 “最大的”羅馬數字字符串。
如果不清楚 `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)
<mark>print('found', numeral, 'of length', len(numeral), ', adding', integer)</mark>
```
```
>>> 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()` 存在錯誤而導致一些特定輸入會產生錯誤的羅馬數字表示,_and_ `from_roman()` 也存在相應的錯誤,把 `to_roman()` 錯誤產生的這些羅馬數字錯誤地轉換為最初的整數。取決于你的應用程序和你的要求,你或許需要考慮這個可能性;如果是這樣,編寫更全面的測試用例直到解決這個問題。)
## 更多錯誤輸入
現在 `from_roman()` 對于有效輸入能夠正常工作了,是揭開最后一個謎底的時候了:使它正常工作于無效輸入的情況下。這意味著要找出一個方法檢查一個字符串是不是有效的羅馬數字。這比中[驗證有效的數字輸入](#romantest3)困難,但是你可以使用一個強大的工具:正則表達式。(如果你不熟悉正則表達式,現在是該好好讀讀[正則表達式](regular-expressions.html)那一章節的時候了。)
如你在 [個案研究:羅馬字母s](regular-expressions.html#romannumerals)中所見到的,構建羅馬數字有幾個簡單的規則:使用的字母`M` , `D` , `C` , `L` , `X` , `V`和`I` 。讓我們回顧一下:
* 有時字符是疊加組合的。`I` 是 `1`, `II` 是 `2`,而`III` 是 `3`. `VI` 是 `6` (從字面上理解, “`5` 和 `1`”), `VII` 是 `7`, 而 `VIII` 是 `8`。
* 十位的字符 (`I`、 `X`、 `C` 和 `M`) 可以被重復最多三次。對于 `4`,你則需要利用下一個能夠被5整除的字符進行減操作得到。你不能把 `4` 表示為`IIII`,而應該表示為`IV` (“比 `5` 小 `1` ”)。`40` 則被寫作 `XL` (“比 `50` 小 `10`”),`41` 表示為 `XLI`,`42` 表示為 `XLII`,`43` 表示為 `XLIII`, `44` 表示為 `XLIV` (“比 `50` 小 `10`,加上 `5` 小 `1`”)。
* 有時,字符串是……加法的對立面。通過將某些字符串放的其他一些之前,可以從最終值中相減。例如,對于 `9`,你需要從下一個最高十位字符串中減去一個值:`8` 是 `VIII`,但 `9` 是 `IX`(“ 比 `10` 小 `1`”),而不是`VIIII` (由于 `I` 字符不能重復四次)。`90` 是 `XC`, `900` 是 `CM`。
* 表示 5 的字符不能重復。`10` 總是表示為 `X`,而決不能是 `VV`。 `100` 總是 `C`,決不能是 `LL`。
* 羅馬數字從左向右讀,因此字符的順序非常重要。`DC` 是 `600`; `CD` 則是完全不同的數字 (`400`, “比 `500` 小 `100` ”)。 `CI` 是 `101`; `IC` 甚至不是合法的羅馬數字(因為你不能直接從 `100` 減 `1`;你將不得不將它表示為 `XCIX`,“比 `100` 小`10` ,然后比 `10`” 小 `1`)。
因此,有用的測試將會確保 `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)
```
另一有效測試是檢查某些未被重復的模式。例如,`IX` 代表 `9`,但 `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)
```
第三個測試應當檢測數字是否以正確順序出現,從最高到最低位。例如,`CL` 是 `150`,而 `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)
```
好!現在,我們要做的所有事情就是添加[正則表達式](regular-expressions.html#romannumerals)到 `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'''
<mark>if not roman_numeral_pattern.search(s):
raise InvalidRomanNumeralError('Invalid Roman numeral: {0}'.format(s))</mark>
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` 模塊輸出。
- 版權信息
- Chapter -1 《深入 Python 3》中有何新內容
- Chapter 0 安裝 Python
- Chapter 1 你的第一個 Python 程序
- Chapter 2 內置數據類型
- Chapter 3 解析
- Chapter 4 字符串
- Chapter 5 正則表達式
- Chapter 6 閉合 與 生成器
- Chapter 7 類 & 迭代器
- Chapter 8 高級迭代器
- Chapter 9 單元測試
- Chapter 10 重構
- Chapter 11 文件
- Chapter 12 XML
- Chapter 13 序列化Python對象
- Chapter 14 HTTP Web 服務
- Chapter 15 案例研究:將chardet移植到Python 3
- Chapter 16 打包 Python 類庫
- Chapter A 使用2to3將代碼移植到Python 3
- Chapter B 特殊方法名稱
- Chapter C 接下來閱讀什么?