python-復盤-錯誤/調試/測試

錯誤處理

try...except...finally...

try:
    print('try...')      #  try except finally 簡單套路
    r = 10 / int('a')
    print('result:', r)
except ValueError as e:
    print('ValueError:', e)
except ZeroDivisionError as e:
    print('ZeroDivisionError:', e)
finally:
    print('finally...')
print('END')


調用堆棧

如果錯誤沒有被捕獲,它就會一直往上拋,最后被Python解釋器捕獲,打印一個錯誤信息,然后程序退出。來看看err.py

# err.py:
def foo(s):
    return 10 / int(s)

def bar(s):
    return foo(s) * 2

def main():
    bar('0')

main()

執行,結果如下:

$ python3 err.py
Traceback (most recent call last):
  File "err.py", line 11, in <module>
    main()
  File "err.py", line 9, in main
    bar('0')
  File "err.py", line 6, in bar
    return foo(s) * 2
  File "err.py", line 3, in foo
    return 10 / int(s)
ZeroDivisionError: division by zero

出錯并不可怕,可怕的是不知道哪里出錯了。解讀錯誤信息是定位錯誤的關鍵。我們從上往下可以看到整個錯誤的調用函數鏈:

錯誤信息第1行:

Traceback (most recent call last):

告訴我們這是錯誤的跟蹤信息。
第2~3行:

  File "err.py", line 11, in <module>
    main()

調用main()出錯了,在代碼文件err.py的第11行代碼,但原因是第9行:

............省略.就是上游污染下游都污染,都PM爆表,系統把所有污染地區都展示出來,你順著往上游去,就能摸到污染源.............

原因是return foo(s) * 2這個語句出錯了,但這還不是最終原因,繼續往下看:

  File "err.py", line 3, in foo
    return 10 / int(s)

原因是return 10 / int(s)這個語句出錯了,這是錯誤產生的源頭,因為下面打印了:

ZeroDivisionError: integer division or modulo by zero

根據錯誤類型ZeroDivisionError,我們判斷,int(s)本身并沒有出錯,但是int(s)返回0,在計算10 / 0時出錯,至此,找到錯誤源頭。


記錄錯誤 logging

如果不捕獲錯誤,自然可以讓Python解釋器來打印出錯誤堆棧,但程序也被結束了。既然我們能捕獲錯誤,就可以把錯誤堆棧打印出來,然后分析錯誤原因,同時,讓程序繼續執行下去。

Python內置的logging模塊可以非常容易地記錄錯誤信息:

# err_logging.py

import logging

def foo(s):
    return 10 / int(s)

def bar(s):
    return foo(s) * 2

def main():
    try:
        bar('0')
    except Exception as e:
        logging.exception(e)

main()
print('END')

同樣是出錯,但程序打印完錯誤信息后會繼續執行,并正常退出:

$ python3 err_logging.py
ERROR:root:division by zero
Traceback (most recent call last):
  File "err_logging.py", line 13, in main
    bar('0')
  File "err_logging.py", line 9, in bar
    return foo(s) * 2
  File "err_logging.py", line 6, in foo
    return 10 / int(s)
ZeroDivisionError: division by zero
END

通過配置,logging還可以把錯誤記錄到日志文件里,方便事后排查。


拋出錯誤

因為錯誤是class,捕獲一個錯誤就是捕獲到該class的一個實例。因此,錯誤并不是憑空產生的,而是有意創建并拋出的。Python的內置函數會拋出很多類型的錯誤,我們自己編寫的函數也可以拋出錯誤。

如果要拋出錯誤,首先根據需要,可以定義一個錯誤的class,選擇好繼承關系,然后,用raise語句拋出一個錯誤的實例:

# err_raise.py
class FooError(ValueError):
    pass

def foo(s):
    n = int(s)
    if n==0:
        raise FooError('invalid value: %s' % s)
    return 10 / n

foo('0')

執行,可以最后跟蹤到我們自己定義的錯誤:

$ python3 err_raise.py 
Traceback (most recent call last):
  File "err_throw.py", line 11, in <module>
    foo('0')
  File "err_throw.py", line 8, in foo
    raise FooError('invalid value: %s' % s)
__main__.FooError: invalid value: 0

只有在必要的時候才定義我們自己的錯誤類型。如果可以選擇Python已有的內置的錯誤類型(比如ValueError,TypeError),盡量使用Python內置的錯誤類型。
最后,我們來看另一種錯誤處理的方式:

# err_reraise.py

def foo(s):
    n = int(s)
    if n==0:
        raise ValueError('invalid value: %s' % s)
    return 10 / n

def bar():
    try:
        foo('0')
    except ValueError as e:
        print('ValueError!')
        raise

bar()

bar()函數中,我們明明已經捕獲了錯誤,但是,打印一個ValueError!后,又把錯誤通過raise語句拋出去了,這不有病么?

其實這種錯誤處理方式不但沒病,而且相當常見。捕獲錯誤目的只是記錄一下,便于后續追蹤。但是,由于當前函數不知道應該怎么處理該錯誤,所以,最恰當的方式是繼續往上拋,讓頂層調用者去處理。好比一個員工處理不了一個問題時,就把問題拋給他的老板,如果他的老板也處理不了,就一直往上拋,最終會拋給CEO去處理。

raise語句如果不帶參數,就會把當前錯誤原樣拋出。此外,在exceptraise一個Error,還可以把一種類型的錯誤轉化成另一種類型.



調試

斷言

凡是用print()來輔助查看的地方,都可以用斷言(assert)來替代:

def foo(s):
    n = int(s)
    assert n != 0, 'n is zero!'
    return 10 / n

def main():
    foo('0')

assert的意思是,表達式n != 0應該是True,否則,根據程序運行的邏輯,后面的代碼肯定會出錯。

如果斷言失敗,assert語句本身就會拋出AssertionError

$ python3 err.py
Traceback (most recent call last):
  ...
AssertionError: n is zero!

logging

print()替換為logging是第3種方式,和assert比,logging不會拋出錯誤,而且可以輸出到文件:

import logging

s = '0'
n = int(s)
logging.info('n = %d' % n)
print(10 / n)

logging.info()就可以輸出一段文本。運行,發現除了ZeroDivisionError,沒有任何信息。怎么回事?

別急,在import logging之后添加一行配置再試試:

import logging
logging.basicConfig(level=logging.INFO)

看到輸出了:

$ python3 err.py
INFO:root:n = 0
Traceback (most recent call last):
  File "err.py", line 8, in <module>
    print(10 / n)
ZeroDivisionError: division by zero

這就是logging的好處,它允許你指定記錄信息的級別,有debug,info,warning,error等幾個級別,當我們指定level=INFO時,logging.debug就不起作用了。同理,指定level=WARNING后,debug和info就不起作用了。這樣一來,你可以放心地輸出不同級別的信息,也不用刪除,最后統一控制輸出哪個級別的信息。

logging的另一個好處是通過簡單的配置,一條語句可以同時輸出到不同的地方,比如console和文件。

pdb pdb.set_trace() IDE 暫略

單元測試

“測試驅動開發”(TDD:Test-Driven Development)
單元測試是用來對一個模塊、一個函數或者一個類來進行正確性檢驗的測試工作。

我們來編寫一個Dict類,這個類的行為和dict一致,但是可以通過屬性來訪問,用起來就像下面這樣:

>>> d = Dict(a=1, b=2)
>>> d['a']
1
>>> d.a
1

為了編寫單元測試,我們需要引入Python自帶的unittest模塊,編寫mydict_test.py如下:

import unittest

from mydict import Dict

class TestDict(unittest.TestCase):

    def test_init(self):
        d = Dict(a=1, b='test')
        self.assertEqual(d.a, 1)
        self.assertEqual(d.b, 'test')
        self.assertTrue(isinstance(d, dict))

    def test_key(self):
        d = Dict()
        d['key'] = 'value'
        self.assertEqual(d.key, 'value')

    def test_attr(self):
        d = Dict()
        d.key = 'value'
        self.assertTrue('key' in d)
        self.assertEqual(d['key'], 'value')

    def test_keyerror(self):
        d = Dict()
        with self.assertRaises(KeyError):
            value = d['empty']

    def test_attrerror(self):
        d = Dict()
        with self.assertRaises(AttributeError):
            value = d.empty

編寫單元測試時,我們需要編寫一個測試類,從unittest.TestCase繼承。

test開頭的方法就是測試方法,不以test開頭的方法不被認為是測試方法,測試的時候不會被執行。

對每一類測試都需要編寫一個test_xxx()方法。由于unittest.TestCase提供了很多內置的條件判斷,我們只需要調用這些方法就可以斷言輸出是否是我們所期望的。最常用的斷言就是
assertEqual():

self.assertEqual(abs(-1), 1) # 斷言函數返回的結果與1相等

另一種重要的斷言就是期待拋出指定類型的Error,比如通過d['empty']訪問不存在的key時,斷言會拋出KeyError:

with self.assertRaises(KeyError):
    value = d['empty']

而通過d.empty訪問不存在的key時,我們期待拋出AttributeError:

with self.assertRaises(AttributeError):
    value = d.empty

運行單元測試

一旦編寫好單元測試,我們就可以運行單元測試。最簡單的運行方式是在mydict_test.py的最后加上兩行代碼:

if __name__ == '__main__':
    unittest.main()

這樣就可以把mydict_test.py當做正常的python腳本運行:

$ python3 mydict_test.py

另一種方法是在命令行通過參數-m unittest直接運行單元測試:

$ python3 -m unittest mydict_test
.....
----------------------------------------------------------------------
Ran 5 tests in 0.000s

OK

這是推薦的做法,因為這樣可以一次批量運行很多單元測試,并且,有很多工具可以自動來運行這些單元測試。

setUp與tearDown

可以在單元測試中編寫兩個特殊的setUp()tearDown()方法。這兩個方法會分別在每調用一個測試方法的前后分別被執行。

setUp()tearDown()方法有什么用呢?設想你的測試需要啟動一個數據庫,這時,就可以在setUp()方法中連接數據庫,在tearDown()方法中關閉數據庫,這樣,不必在每個測試方法中重復相同的代碼:

class TestDict(unittest.TestCase):

    def setUp(self):
        print('setUp...')

    def tearDown(self):
        print('tearDown...')

可以再次運行測試看看每個測試方法調用前后是否會打印出setUp...和tearDown...。

文檔測試

如果你經常閱讀Python的官方文檔,可以看到很多文檔都有示例代碼。比如re模塊就帶了很多示例代碼:

>>> import re
>>> m = re.search('(?<=abc)def', 'abcdef')
>>> m.group(0)
'def'

可以把這些示例代碼在Python的交互式環境下輸入并執行,結果與文檔中的示例代碼顯示的一致。

這些代碼與其他說明可以寫在注釋中,然后,由一些工具來自動生成文檔。既然這些代碼本身就可以粘貼出來直接運行,那么,可不可以自動執行寫在注釋中的這些代碼呢?

答案是肯定的。

當我們編寫注釋時,如果寫上這樣的注釋:

def abs(n):
    '''
    Function to get absolute value of number.

    Example:

    >>> abs(1)
    1
    >>> abs(-1)
    1
    >>> abs(0)
    0
    '''
    return n if n >= 0 else (-n)

無疑更明確地告訴函數的調用者該函數的期望輸入和輸出。

并且,Python內置的“文檔測試”(doctest)模塊可以直接提取注釋中的代碼并執行測試。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容