測試驅動開發

測試驅動開發的概覽。


測試驅動開發(TDD)是一種程式設計風格,先撰寫測試,以引導程式設計在程式碼中的實作。

在開始寫程式之前,會先撰寫一個或多個測試(特別是單元測試)。 這些測試旨在涵蓋程式行為的某一個面向,可能聚焦於單一函式或方法。 撰寫測試能把程式需求和整體架構,轉化為特定實作方式的設計。 接著執行測試,這些測試應該會失敗,因為程式碼還沒實作。 程式碼實作完成後,再次執行測試。 如果測試通過,代表這個行為的實作完成了,或者還需要再建立更多必要的測試。 如果測試沒有通過,就對程式碼除錯,然後再次執行測試。 測試與寫程式的循環會不斷重複,直到所有必要的測試都通過;到了這個時候,程式行為中這個面向的實作就算完成了……至少目前是如此。

重構

重構是為了改善設計而改寫程式碼。 它不只是為了修正 bug 而改寫程式碼。 有時人們會把「修改程式碼讓測試通過」也稱作「重構」。 雖然修改程式碼_可能_包含為了通過測試而改善設計,但單純除錯不見得是在改善程式碼的_設計_,因此也不見得算得上是_重構_。

以下是除錯但不重構的例子:

# A function intended to return x added to y.
# x and y are bad parameter names, but we ignore that for now.
def add(x, y):
    # used multiply operator by mistake. It fails the tests.
    return x * y

# Function corrected. It passes the tests. It has been debugged, but not refactored.
def add(x, y):
    return x + y

以下是先重構、再除錯的例子:


# Function name and parameter names are modified to something more meaningful. This is refactoring.
def lot_inventory(old_cars, new_cars):
    # Introduced multiply operator by mistake. It fails the tests. This is why we test.
    return old_cars * new_cars

# Function corrected. It passes the tests. This is debugging.
def lot_inventory(old_cars, new_cars):
    return old_cars + new_cars

TDD 與 Exercism 上的 Python

Exercism 的 Python 學習軌道在其練習中採用 TDD 方法論。 單元測試都已經寫好了。 學生可以查看這些測試,以更詳細地了解解答需要滿足哪些條件才會通過。 也可能會提供解答的程式碼骨架給學生。

在網頁編輯器中排解 Exercism 上失敗的測試

當一個 Python 解答有一個或多個測試失敗時,對應的任務就不會有綠色背景。 第一個失敗的任務區域會自動展開,它的標題看起來大概像這樣

Task 1 Extract coordinates -

點擊減號可以把任務收合起來,這樣就能查看其他任務;不過現在我們先繼續看這個任務。

下方會是一個已展開的 Test 區域,看起來大概像這樣

       Test 1                               ⌄
FAILED TisburyTreasure > get coordinate

其中Tisbury Treasure代表練習,而get_coordinate代表失敗的函式或方法。

Test 1通常是一種範本,裡面會有一段用來設定測試的程式碼區段。 它不包含失敗的特定測試相關資訊。 它會在底部附近寫著

One or more variations of this test failed. Details can be found under each [variant#].

點擊⌄會收合這個測試。

下方會是一個收合起來的測試,看起來大概像這樣:

       Test 2                                                    >
FAILED TisburyTreasure > get coordinate [variation #1] (item=
       ("Scrimshaw Whale's Tooth", '2A'), result='2A')

實際外觀會依右側面板設定的寬度而有所不同。 點擊>會展開這個測試。 輸入資料和預期結果資料很可能會顯示在程式碼區段中。 這些資料可能是這個任務所有測試的資料。 底部會有個Test Failure區段,說明這個測試失敗的具體原因。 看起來可能像這樣:

AssertionError: ['2A'] != '2A'

在這個例子中,它表示['2A']這個回傳值不等於預期的'2A'。

如果我們看一下get_coordinate的程式碼,會發現它是這樣實作的

def get_coordinate(record):
    return [record[1]]

如果我們把陣列的方括號拿掉(例如return record[1]),再執行一次測試,任務 1 的測試就會通過。

如果還有一個或多個任務持續失敗,就針對每一個重複上述流程,直到所有測試都通過。

有時候,預期資料和回傳資料太過龐大,無法全部放進Test Failure區段。 看起來可能像這樣:

AssertionError: '("Sc[67 chars]\')\n\n(\'Brass Spyglass\', \'Abandoned Lighth[952 chars]')\n' != '("Sc[67 chars]\')\n(\'Brass Spyglass\', \'Abandoned Lighthou[928 chars]')\n'
Diff is 970 characters long. Set self.maxDiff to None to see it.

資料可能還是足以看出問題在哪。 在上述情況中,回傳的內容有兩個換行(例如\n\n(\'Brass Spyglass),但預期只有一個(例如\n(\'Brass Spyglass)。

所有測試都通過之後

恭喜! 所有測試都通過了。 接下來呢? 你可以立刻發布解答。 或者,既然程式碼已經能運作,如果你基於任何理由想重構它,也可以修改程式碼並提交另一次疊代。 如果你覺得程式碼還能更好,卻不知道該怎麼做,可以為這個解答申請引導。 如果有導師有空,他們可能會聯絡你,提供其他解題方法的想法。 發布解答時,你可以允許留言,其他學生就可能趁這個機會留言或提問。

效能最佳化

雖然「過早最佳化是萬惡之源」(這句話同時被歸給 Tony Hoare 和 Donald Knuth),但總會有那麼一個時刻:即使解答已經能運作,你仍會想改善它的效能。 其中一種情況,就是解答通過了某些測試,卻在其他測試上逾時。 確切知道某段程式碼花了多少時間,會很有幫助。 timeit 模組可以用來測量程式碼的執行時間,短到極小的時距都能測。 timeit函式最多可以接受五個引數:timeit.timeit(stmt='pass', setup='pass', timer=<default timer>, number=1000000, globals=None)。 stmt參數定義實際要執行並計時的程式碼。 number參數決定stmt程式碼要執行多少次。 setup參數定義只執行一次、用來準備執行stmt程式碼的程式碼。 setup程式碼的執行時間會計入總時間。 stmt程式碼執行的疊代次數越多,每次疊代分攤到的setup時間就越少。 timer參數可以讓你傳入不同於預設值的Timer。 timer參數的預設引數是 perf_counter,對大多數情況來說應該就很夠用了。 number參數的預設引數是1_000_000。 globals參數指定執行程式碼所用的命名空間。 globals參數的預設引數是None。

以下是一個使用timeit的例子,用來看看判斷一個句子是否包含所有英文母音需要花多久時間:


import timeit

# run one million times
loops = 1_000_000

# first positional argument is for stmt
# second positional argument is for setup
# third (named) argument is for number
print(timeit.timeit("""has_all_vowels('Another piggy digs up the truffles.')""",
                    """

VOWELS = "AEIOU"

def has_all_vowels(sentence):
    return all(letter in sentence.casefold() for letter in VOWELS)
""", number=loops) / loops)

把程式碼執行一百萬次,平均每次呼叫花了4.965089999896008e-07秒(每次呼叫約497奈秒)。

以下這個例子,則是要看看把casefold呼叫移出列表推導式是否能省下一些時間:


import timeit

loops = 1_000_000

print(timeit.timeit("""has_all_vowels('Another piggy digs up the truffles.')""",
                    """

VOWELS = "AEIOU"

def has_all_vowels(sentence):
    sentence = sentence.casefold()
    return all(letter in sentence for letter in VOWELS)
""", number=loops) / loops)

把程式碼執行一百萬次,平均每次呼叫花了4.923898000270128e-07秒(每次呼叫約492奈秒)。 所以,把casefold移出列表推導式後,每次呼叫大約省下5奈秒,一百萬次呼叫總共大約省下5毫秒。

cProfile也可以用來對程式碼做效能分析;不過它的粒度沒那麼細,因為最小只到毫秒等級。