Python 3 Tutorial 第十一堂(1)使用 assert 與 doctest


對於靜態定型語言(Statically-typing language),因為變數有型態資訊,因而編譯器等工具,可以在程式運行之前檢查出許多型態不正確的資訊。

Python 是動態定型語言(Dynamically-typing language),也就是說,在 Python 中變數沒有型態,只是用來作為參考實際物件的一根柄(Handle),如果有型態錯誤上的操作,基本上會是在執行時期運行至該段程式碼時,才會產生錯誤訊息,因此對於 Python 來說,檢查出型態不正確的任務,必須開發者本身來承擔,為程式設計測試程式,會是個不錯的方式之一。

對於靜態定型語言,雖然有編譯器等工具,協助開發者於程式運行之前檢查型態錯誤問題,然而,設計優良測試程式檢測執行時期功能是否符合預期亦非常重要;對於動態語言,現在也有一些型態註解方案,可提供分析工具於程式運行前檢查型態資訊,像是 Python 3.5 中也加入的 Type hinting

在 Python 的世界中,當然不乏撰寫測試的相關工具,像是 …

  • assert 陳述在程式中安插除錯用的斷言(Assertion)檢查時很方便的一個方式。
  • doctest 模組在程式碼中找尋類似 Python 互動環境的文字片段,執行並驗證程式是否如預期方式執行。
  • unittest 模組有時稱為 “PyUnit”,是 JUnit 的 Python 語言實現。
  • 第三方測試工具(已支援 Python 3)

這一篇會先介紹一下 assertdoctest,不過在繼續之前,先來談一下每個模組中都會有的 __name__ 全域變數,當你執行直接某個 Python 模組時,例如:

python3.5 fibo.py

模組中的程式碼會像你執行 import 時般運行,不過 __name__ 這個變數會被設定為 '__main__' 這個字串名稱,因此,如果想要為這個模組撰寫一個簡單的自我測試,可以如以下方式撰寫:

if __name__ == "__main__":
    測試的程式碼

當你直接執行某個模組時,if 條件才會成立,測試的程式碼才會執行,而 import 該模組時,因為 __name__ 會是模組名稱,因此就不會在 import 執行測試的程式碼。

assert

要在程式中安插斷言,使用 assert 很方便,其語法如下:

assert_stmt ::=  "assert" expression ["," expression]

使用 assert expression 的話,相當於以下的程式片段:

if __debug__:
    if not expression: raise AssertionError

如果有兩個 expression,例如 assert expression1, expression2,相當於以下的程式片段:

if __debug__:
    if not expression1: raise AssertionError(expression2)

也就是說,第二個 expression 的結果,會被當作 AssertionError 的錯誤資訊結果。 __debug__ 是個內建變數,一般情況下會是 True,如果執行時需要最佳化時(在執行時加上 -O 引數)則會是 False。例如以下是互動環境中的一些例子:

$ python3.5
Python 3.5.0+ (default, Oct 11 2015, 09:05:38) 
[GCC 5.2.1 20151010] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> assert 1 == 1
>>> assert 1 != 1
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AssertionError
>>> __debug__
True
>>> exit()
$ python3.5 -O
Python 3.5.0+ (default, Oct 11 2015, 09:05:38) 
[GCC 5.2.1 20151010] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> assert 1 != 1
>>> __debug__
False
>>> 

那麼何時該使用斷言呢?…一般有幾個建議:

  • 前置條件(通常在私有函式之中)斷言客戶端呼叫函式前,已經準備好某些條件。
  • 後置條件驗證客戶端呼叫函式後,具有函式承諾有結果。
  • 類別不變量(Class invariant)驗證物件某個時間點下的狀態。
  • 內部不變量(Internal invariant)使用斷言取代註解。
  • 流程不變量(Control-flow invariant)斷言程式流程中絕不會執行到的程式碼部份。

前置條件斷言的例子如下:

def __set_refresh_Interval(interval):
    if interval > 0 and interval <= 1000 / MAX_REFRESH_RATE:
        raise ValueError('Illegal interval: ' + interval)
    # 函式中的程式流程

程式中的 if 檢查進行了防禦式程式設計(Defensive programming ),如果想要用 assert 取代,可以如下:

def __set_refresh_Interval(rate):
    (assert interval > 0 and interval <= 1000 / MAX_REFRESH_RATE, 
            'Illegal interval: ' + interval)
    # 函式中的程式流程

防禦式程式設計有些不好的名聲,不過並不是做了防禦式程式設計就不好,可以參考〈避免隱藏錯誤的防禦性設計〉。

一個內部不變量的例子則是如下:

if balance >= 10000:
    ...
elif 10000 > balance >= 100:
    ...
else: # balance 一定是少於 100 的情況
    ...

如果要在 elsebalance 不是少於 100 的情況下拋出 AssertError,以實現速錯(Fail fast)概念,而不是只使用註解來提醒開發者,則可以改為以下:

if balance >= 10000:
    ...
else if 10000 > balance >= 100:
    ...
else:
    assert balance < 100, balance
    ...

另一個情況是:

if suit == Suit.CLUBS:
    ...
elif suit == Suit.DIAMONDS:
    ...
elif suit == Suit.HEARTS:
    ...
elif suit == Suit.SPADES:
    ...

如果列舉檢查只會有以上四個條件,也可以運用斷言來實現速錯:

if suit == Suit.CLUBS:
    ...
elif suit == Suit.DIAMONDS:
    ...
elif suit == Suit.HEARTS:
    ...
elif suit == Suit.SPADES:
    ...
else:
    assert False, suit

程式碼中有些一定不會執行到的流程區段,可以使用斷言來確保這些區段被執行時拋出錯誤。例如:

def foo(list):
    for ele in list:
        if ...:
            return
    # 這邊應該永遠不會被執行到

可以改為:

def foo(list):
    for ele in list:
        if ...:
            return
    assert False

doctest

doctest 一方面是測試程式碼,一方面也是用來確認 docStrings 的內容沒有過期,基本上它驗證互動式的範例來執行回歸測試(Regression testing),開發者只要為套件撰寫輸入輸出式的教學範例就可以了,這有點文學測試(Literate testing) 或可執行文件(executable documentation)的味道。

舉例來說,你也許為 util.py 中的 sorted 撰寫了以下的 docstrings:

import functools

def ascending(a, b): return a - b
def descending(a, b): return -ascending(a, b)

def __select(xs, compare):
    selected = functools.reduce(
        lambda slt, elem: elem if compare(elem, slt) < 0 else slt, xs)
    remain = [elem for elem in xs if elem != selected]
    return (xs if not remain
               else [elem for elem in xs if elem == selected]
                   + __select(remain, compare))

def sorted(xs, compare = ascending):
    '''
    sorted(xs) -> new sorted list from xs' item in ascending order.
    sorted(xs, func) -> new sorted list. func should return a negative integer, 
                        zero, or a positive integer as the first argument is 
                        less than, equal to, or greater than the second.

    >>> sorted([2, 1, 3, 6, 5])
    [1, 2, 3, 5, 6]
    >>> sorted([2, 1, 3, 6, 5], ascending)
    [1, 2, 3, 5, 6]
    >>> sorted([2, 1, 3, 6, 5], descending)
    [6, 5, 3, 2, 1]
    >>> sorted([2, 1, 3, 6, 5], lambda a, b: a - b)
    [1, 2, 3, 5, 6]
    >>> sorted([2, 1, 3, 6, 5], lambda a, b: b - a)
    [6, 5, 3, 2, 1]
    '''

    return [] if not xs else __select(xs, compare)

if __name__ == '__main__':
    import doctest
    doctest.testmod()

在同一個模組中,撰寫了以下的程式片段:

if __name__ == '__main__':
    import doctest
    doctest.testmod()

那麼直接執行模組時,就會執行測試,加上 -v 會顯示細節:

$ python3.5 util.py
$ python3.5 util.py -v
Trying:
    sorted([2, 1, 3, 6, 5])
Expecting:
    [1, 2, 3, 5, 6]
ok
Trying:
    sorted([2, 1, 3, 6, 5], ascending)
Expecting:
    [1, 2, 3, 5, 6]
ok
Trying:
    sorted([2, 1, 3, 6, 5], descending)
Expecting:
    [6, 5, 3, 2, 1]
ok
Trying:
    sorted([2, 1, 3, 6, 5], lambda a, b: a - b)
Expecting:
    [1, 2, 3, 5, 6]
ok
Trying:
    sorted([2, 1, 3, 6, 5], lambda a, b: b - a)
Expecting:
    [6, 5, 3, 2, 1]
ok
4 items had no tests:
    __main__
    __main__.__select
    __main__.ascending
    __main__.descending
1 items passed all tests:
   5 tests in __main__.sorted
5 tests in 5 items.
5 passed and 0 failed.
Test passed.

你也可以將這類文件寫在文字檔案中,例如一個 util_test.txt:

The ``util`` module
======================

Using ``sorted``
-------------------

>>> from util import *
>>> sorted([2, 1, 3, 6, 5])
[1, 2, 3, 5, 6]
>>> sorted([2, 1, 3, 6, 5], ascending)
[1, 2, 3, 5, 6]
>>> sorted([2, 1, 3, 6, 5], descending)
[6, 5, 3, 2, 1]
>>> sorted([2, 1, 3, 6, 5], lambda a, b: a - b)
[1, 2, 3, 5, 6]
>>> sorted([2, 1, 3, 6, 5], lambda a, b: b - a)
[6, 5, 3, 2, 1]

而 util.py 中改寫為以下,就可以從文字檔案中讀取內容並執行測試:

if __name__ == '__main__':
    import doctest
    doctest.testfile("util_test.txt")

你也可以直接執行 doctest 模組來載入測試用的文字檔案以執行測試,例如:

$ python3.5 -m doctest -v util_test.txt
Trying:
    from util import *
Expecting nothing
ok
Trying:
    sorted([2, 1, 3, 6, 5])
Expecting:
    [1, 2, 3, 5, 6]
ok
Trying:
    sorted([2, 1, 3, 6, 5], ascending)
Expecting:
    [1, 2, 3, 5, 6]
ok
Trying:
    sorted([2, 1, 3, 6, 5], descending)
Expecting:
    [6, 5, 3, 2, 1]
ok
Trying:
    sorted([2, 1, 3, 6, 5], lambda a, b: a - b)
Expecting:
    [1, 2, 3, 5, 6]
ok
Trying:
    sorted([2, 1, 3, 6, 5], lambda a, b: b - a)
Expecting:
    [6, 5, 3, 2, 1]
ok
1 items passed all tests:
   6 tests in util_test.txt
6 tests in 1 items.
6 passed and 0 failed.
Test passed.