Python 單元測試(Unit Testing)


unittest

Python 自 2.1 起開始內建 unittest,做為標準的 unit testing framework。unittest 在 Python 2.7 做了很多改進,因此以下的說明都以 Python 2.7 為主。

Important Python 做為一個 scripting language,許多錯誤在 compile-time 並沒有辦法被找出來,因此更需要 unit testing 的幫忙,在 build-time 跑過所有的程式碼(這部份可以借助 coverage tools 來提供回饋),儘可能把所有的 programming error 都找出來。
Tip unittest 的前身是 PyUnit,直到 Python 2.1 才成為 standard library 的一部份,因此有時候 unittest 也稱做 PyUnit。使用上會發現有很多地方都跟 JUnit 很類似,那是因為 PyUnit 在設計上是參考 JUnit,這一點在 PyUnit 的官網有提到。

從 self-test 到 unittest

首先用 self-test code 做簡單的測試:

calc.py

class Calculator:

    def mod(self, dividend, divisor):
        remainder = dividend % divisor
        quotient = (dividend - remainder) / divisor
        return quotient, remainder

if __name__ == '__main__':
    cal = Calculator()
    assert cal.mod(5, 3) == (1, 2) # 5 / 3 = 1 ... 2
    assert cal.mod(8, 4) == (1, 0) # 8 / 4 = 2 ... 0

執行結果:

$ python calc.py
Traceback (most recent call last):
  File "calc.py", line 11, in <module>
    assert cal.mod(8, 4) == (1, 0) # 8 / 4 = 2 ... 0 1
AssertionError
1 沒有明確指出實際/預期結果兩者間的差異。

改成 unittest 的寫法:

calc.py

import unittest

class Calculator:

    def mod(self, dividend, divisor):
        remainder = dividend % divisor
        quotient = (dividend - remainder) / divisor
        return quotient, remainder

class CalculatorTest(unittest.TestCase):                 1

    def test_mod_with_remainder(self):                   2
        cal = Calculator()
        self.assertEqual(cal.mod(5, 3), (1, 2))          3

    def test_mod_without_remainder(self):
        cal = Calculator()
        self.assertEqual(cal.mod(8, 4), (1, 0))          4

    def test_mod_divide_by_zero(self):
        cal = Calculator()
        assertRaises(ZeroDivisionError, cal.mod, 7, 1)   5

if __name__ == '__main__':
    unittest.main()                                      6
1 只要繼承自 unittest.TestCase 即可,類別名稱沒有特別要求,但通常會在後面串上 Test
2 test 開頭的方法都會被視為 test method,分別代表不同的 test case。
3 TestCase.assert*() 來做檢查。下面會說明它跟直接用 assert 來做驗證有什麼差別。
4 這裡故意寫成 (1, 0),是為了產生 test failure。
5 TestCase.assertRaises() 來驗證呼叫某個 function 必須丟出 exception。這裡故意少寫了 self.,是為了產生 test error。
6 透過 unittest.main() 可以執行同一 module 裡所有的 test case。

重新執行的結果:

$ python calc.py
E.F 1
======================================================================
ERROR: test_mod_divide_by_zero (__main__.CalculatorTest) 2
----------------------------------------------------------------------
Traceback (most recent call last):
  File "calc.py", line 22, in test_mod_divide_by_zero
    assertRaises(ZeroDivisionError, cal.mod, 7, 1)
NameError: global name 'assertRaises' is not defined

======================================================================
FAIL: test_mod_without_remainder (__main__.CalculatorTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "calc.py", line 18, in test_mod_without_remainder
    self.assertEqual(cal.mod(8, 4), (1, 0))
AssertionError: Tuples differ: (2, 0) != (1, 0) 3

First differing element 0:
2
1

- (2, 0)
?  ^

+ (1, 0)
?  ^


----------------------------------------------------------------------
Ran 3 tests in 0.001s

FAILED (failures=1, errors=1) 4
1 每一個字元都表示不同 test case 的執行結果。. 表示成功,F 表示失敗(failure),E 表示錯誤(error)。
2 逐項列出 test failure/error 的細節。
3 同樣是丟出 AssertionError,但透過 TestCase.assert*() 來做驗證,會產生比較詳細的訊息。
4 測試不成功時區分為 test failure (單純是結果與預期不符) 與 test error (執行期發生其他錯誤)。
Note

用不用 TestCase.assert*() 有關係!

unittest 內部是用丟出 exception 的型態來識別 test failure/error;也就是說,丟出的 exception 其型態跟 TestCase.failureException 一致時,會被視為 test failure,其他的 exception type 則會被視為 test error。

事實上,TestCase.failureException 同時也決定了 TestCase.assert*() 可能丟出的例外,也因此 TestCase.assert*() 丟出的例外總會被視為 test failure。雖然說 TestCase.failureException 目前的預設值是 AssertionError,跟直接使用 assert 做驗證的結果一樣,但難保哪一天會預設為其他 exception(官方文件是這麼說的,但機會真的不大!),到時候 assert 丟出的 AssertionError,就會被誤判為 test error。

Important

上面 3 個字元(E.F)由左到右,表示 test case 執行的順序,有沒有發現這跟原始碼 test method 宣告的順序不同?

由於 unittest 不保證 test case 間執行的順序,所以每一個 test case 都必須要做到 self-contained,不會因執行順序改變就失敗。某種程度上,這也意謂著 unittest 並不適合拿來做 functional test,這部份就得靠其他的工具(例如 nose)來幫忙了。

檢查結果是否符合預期

首先從 assertTrue()assertEqual() 講起:

  • assertTrue(expr, msg=None)
  • assertFalse(expr, msg=None)
  • assertEqual(first, second, msg=None)
  • assertNotEqual(first, second, msg=None)

assertTrue()assertFalse() 內部分別會做 bool(expr) is Truebool(expr) is False 的檢查,而 assertEqual()assertNotEqual(),內部則分別會做(簡單地說) first == secondfirst != second 的檢查。例如:

self.assertTrue(True or False)
self.assertEqual(2, 2)
Tip

assertEqual(actual, expected)assertEqual(expected, actual)

在 Python 裡,使用 assertEqual(actual, expected)assertEqual(expected, actual),從結果來看並沒有什麼差別。

test_assert.py

import unittest

class AssertTest(unittest.TestCase):

    def test_actual_expected(self):
        self.assertEqual('actual', 'expected')

    def test_expected_expected(self):
        self.assertEqual('expected', 'actual')

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

執行結果:

$ python test_assert.py
FF
======================================================================
FAIL: test_actual_expected (__main__.AssertTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "test_assert.py", line 6, in test_actual_expected
    self.assertEqual('actual', 'expected')
AssertionError: 'actual' != 'expected' 1

======================================================================
FAIL: test_expected_actual (__main__.AssertTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "test_assert.py", line 9, in test_expected_actual
    self.assertEqual('expected', 'actual')
AssertionError: 'expected' != 'actual'

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

FAILED (failures=2)
1 'actual' != 'expected''expected' != 'actual' 在語意上並沒有差別。

由於 unittest 設計上是參考 JUnit,如果硬要分出 actual/expected 的順序的話,建議依循 JUnit 的做法-也就是 expected 在前,actual 在後。

雖然 expr 可以發揮的空間很大,但如果有其他更為適用的 assert*() 時,就不建議使用 assertTrue()assertFalse() 來做驗證,原因是可以得到比較詳細的錯誤訊息。最簡單的例子就是,寫成 assertEqual(a, b) 會比 assertTrue(a == b) 來得好。下面用 list 的比對進一步做說明:

test_assert.py

import unittest

class AssertTest(unittest.TestCase):

    def test_assert_true(self):
        self.assertTrue([1, 2, 3, 4] == [1, 2, 4, 8])

    def test_assert_equal(self):
        self.assertEqual([1, 2, 3, 4], [1, 2, 4, 8])

    def test_assert_sequence_equal(self):
        self.assertListEqual([1, 2, 3, 4], [1, 2, 4, 8])

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

執行結果:

$ python test_assert.py
FFF
======================================================================
FAIL: test_assert_equal (__main__.AssertTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "test_assert.py", line 9, in test_assert_equal
    self.assertEqual([1, 2, 3, 4], [1, 2, 4, 8])
AssertionError: Lists differ: [1, 2, 3, 4] != [1, 2, 4, 8]

First differing element 2:
3
4

- [1, 2, 3, 4]
?        ^  ^

+ [1, 2, 4, 8]
?        ^  ^


======================================================================
FAIL: test_assert_sequence_equal (__main__.AssertTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "test_assert.py", line 12, in test_assert_sequence_equal
    self.assertListEqual([1, 2, 3, 4], [1, 2, 4, 8])
AssertionError: Lists differ: [1, 2, 3, 4] != [1, 2, 4, 8]

First differing element 2:
3
4

- [1, 2, 3, 4]
?        ^  ^

+ [1, 2, 4, 8]
?        ^  ^


======================================================================
FAIL: test_assert_true (__main__.AssertTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "test_assert.py", line 6, in test_assert_true
    self.assertTrue([1, 2, 3, 4] == [1, 2, 4, 8])
AssertionError: False is not True

----------------------------------------------------------------------
Ran 3 tests in 0.002s

FAILED (failures=3)

就比較兩個 list 內容的這件事而言,顯然 assertEqual()assertListEqual() 都比 assertTrue() 來得適用,但從結果來看,使用 asserEqual()assertListEqual() 並沒有差別。那是因為 assertEqual() 發現要比較的兩個對象都是 list 時,內部就會轉呼叫 assertListEqual() 來做處理,同樣的情形也會發生在 tuple、dict、set 或 unicode 等身上。

Tip 既然 assertEqual() 內部會自動做判斷,在實務上就沒有必要直接使用 assertListEqual()assertTupleEqual() 等,讓測試碼保持彈性。
Note

assertEqual() 獨厚 unicode?

比較特別的地方是 assertEqual() 在比對兩個字串時,只有雙方都是 unicode 時才會詳細指出差異的地方:

test_assert_string.py

import unittest

class AssertTest(unittest.TestCase):

    def test_unicode(self):
        self.assertEqual(u'hello world', u'Hello World')

    def test_str(self):
        self.assertEqual('hello world', 'Hello World')

    def test_mix(self):
        self.assertEqual('hello world', u'Hello World')

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

執行結果:

$ python test_assert_string.py
FFF
======================================================================
FAIL: test_mix (__main__.AssertTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "test_assert_string.py", line 12, in test_mix
    self.assertEqual('hello world', u'Hello World')
AssertionError: 'hello world' != u'Hello World' 1

======================================================================
FAIL: test_str (__main__.AssertTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "test_assert_string.py", line 9, in test_str
    self.assertEqual('hello world', 'Hello World')
AssertionError: 'hello world' != 'Hello World'

======================================================================
FAIL: test_unicode (__main__.AssertTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "test_assert_string.py", line 6, in test_unicode
    self.assertEqual(u'hello world', u'Hello World')
AssertionError: u'hello world' != u'Hello World' 2
- hello world
? ^     ^
+ Hello World
? ^     ^


----------------------------------------------------------------------
Ran 3 tests in 0.001s

FAILED (failures=3)
1 單一方是 unicode 時,並不會指出差異的地方。
2 只有雙方都是 unicode 時,內部才會轉呼叫 assertMultiLineEqual() 指出差異的地方(跟字串內容是否有換行字元無關)。

要讓 assertEqual() 也以相同的方式對待 str,有以下兩種方式:

test_assert_string.py

import unittest

class AssertTest(unittest.TestCase):

    def test_convert(self):
        self.assertEqual(unicode('hello world'), unicode('Hello World')) 1

    def test_enhance(self):
        self.addTypeEqualityFunc(str, self.assertMultiLineEqual)         2
        self.assertEqual('hello world', 'Hello World')

if __name__ == '__main__':
    unittest.main()
1 事先將兩個字串都強制轉成 unicode。
2 assertEqual() 遇到雙方都是 str 時也會轉給 assertMultiLineEqual() 處理。

執行結果:

$ python test_assert_string.py
FF
======================================================================
FAIL: test_convert (__main__.AssertTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "test_assert_string.py", line 6, in test_convert
    self.assertEqual(unicode('hello world'), unicode('Hello World'))
AssertionError: u'hello world' != u'Hello World'
- hello world
? ^     ^
+ Hello World
? ^     ^


======================================================================
FAIL: test_enhance (__main__.AssertTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "test_assert_string.py", line 10, in test_enhance
    self.assertEqual('hello world', 'Hello World')
AssertionError: 'hello world' != 'Hello World'
- hello world
? ^     ^
+ Hello World
? ^     ^


----------------------------------------------------------------------
Ran 2 tests in 0.001s

FAILED (failures=2)

assertEqual() 衍生出來,但只適用於字串內容比對的方法有:

  • assertRegexpMatches(self, text, regexp, msg=None)
  • assertNotRegexpMatches(self, text, regexp, msg=None)

assertNotEqual() 衍生出來的驗證方法有:

  • assertLess(first, second, msg=None) – 檢查 first < second
  • assertLessEqual(first, second, msg=None) – 檢查 first <= second
  • assertGreater(first, second, msg=None) – 檢查 first > second
  • assertGreaterEqual(first, second, msg=None) – 檢查 first >= second

跟 object 相關的驗證方法有:

  • assertIsNone(expr, msg=None) – 檢查 expr is None
  • assertIsNotNone(expr, msg=None) – 檢查 expr is not None
  • assertIs(first, second, msg=None) – 檢查 first is second,也就是 firstsecond 指向同一個 object instance。
  • assertIsNot(first, second, msg=None) – 檢查 first is not second,也就是 firstsecond 指向不同的 object instance。
  • assertIsInstance(obj, cls, msg=None) – 檢查 isinstance(obj, cls)
  • assertNotIsInstance(obj, cls, msg=None) – 檢查 not isinstance(obj, cls)。(注意是 NotIs 而非慣用的 IsNot

例如:

def test_something(self):
    self.assertIsNone(None)
    self.assertIsNotNone('None')

    s1 = 'hello' + ', world'
    s2 = unicode('hello, ' + 'world')
    s3 = s1
    self.assertIs(s1, s3)
    self.assertIsNot(s1, s2)
    self.assertEqual(s1, s2)

    self.assertIsInstance(s2, unicode)
    self.assertNotIsInstance(s2, str)

跟 container 相關的驗證方法有:

  • assertIn(self, first, second, msg=None) – 檢查 first in second
  • assertNotIn(self, first, second, msg=None) – 檢查 first not in second
  • assertItemsEqual(self, first, second, msg=None) – 檢查 sorted(first) == sorted(second),也就不管順序為何,只要雙方的數量跟項目都相同即可。

最後是跟 exception 相關的驗證方法。

  • assertRaises(exception, callable, *args, **kwargs) – 檢查呼叫 callable(*args, **kwargs) 會丟出 exception
  • assertRaisesRegexp(exception, regexp, callable, *args, **kwargs) – 用法跟 assertRaises() 一樣,除了檢查會丟出 exception 之外,還會進一步檢查 error message 是否符合 regexp

例如:

test_raise.py

import unittest

def mod(dividend, divisor):
    remainder = dividend % divisor
    quotient = (dividend - remainder) / divisor
    return quotient, remainder

class RaiseTest(unittest.TestCase):

    def test_raise(self):
        self.assertRaises(ZeroDivisionError, mod, 7, 0)

    def test_raise_regexp(self):
        self.assertRaisesRegexp(ZeroDivisionError, r'.*?Zero', mod, 7, 0)

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

執行結果:

$ python test_raise.py
.F
======================================================================
FAIL: test_raise_regexp (__main__.RaiseTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "test_raise.py", line 14, in test_raise_regexp
    self.assertRaisesRegexp(ZeroDivisionError, r'.*?Zero', mod, 7, 0)
AssertionError: ".*?Zero" does not match "integer division or modulo by zero" 1

----------------------------------------------------------------------
Ran 2 tests in 0.001s

FAILED (failures=1)
1 assertRaisesRegexp() 會比對 error message 的內容,有助於判定 error message 裡內含的關鍵數據。

assertRaises()assertRaisesRegexp() 還支援 context manager 的用法:

  • assertRaises(exception) – 檢查離開這個 context 前,必須丟出特定的 exception。
  • assertRaisesRegexp(exception, regexp) – 除了檢查會丟出特定的 exception 之外,還會進一步檢查 error message 是否符合 regexp

改寫上面的例子:

import unittest

def mod(dividend, divisor):
    remainder = dividend % divisor
    quotient = (dividend - remainder) / divisor
    return quotient, remainder

class RaiseTest(unittest.TestCase):

    def test_raise(self):
        #self.assertRaises(ZeroDivisionError, mod, 7, 0)
        with self.assertRaises(ZeroDivisionError) as cm:
             mod(7, 0)

    def test_raise_regexp(self):
        #self.assertRaisesRegexp(ZeroDivisionError, r'.*?Zero', mod, 7, 0)
        with self.assertRaisesRegexp(ZeroDivisionError, r'.*?Zero') as cm:
            mod(7, 0)

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

assertRaises*(exception[, regexp]) 的陷阱

這種 context manager 的用法是 Python 2.7 才有的,單純只是語法上的甜頭(syntax sugar),因為使用上存在著一些陷阱:

def test_raise(self):
    with self.assertRaises(ZeroDivisionError) as cm:
         divisor = a + b / c 1
         mod(7, 0)
1 如果 c 的內容是 0 的話,這一行就會丟出 ZeroDivisionError

也就是說 ZeroDivisionError 是丟出來了,但卻不一定 mod() 丟出來的,在某些情況下,就會讓我們誤判 mod() 的實作是沒有問題的。

Test Fixture

unittest 支援 test fixture,包括測試開始前的準備工作,以及測試結束後的善後(清理)工作。

calc.py

class CalculatorTest(unittest.TestCase):

    def test_mod_with_remainder(self):
        cal = Calculator()
        self.assertEqual(cal.mod(5, 3), (1, 2))

    def test_mod_without_remainder(self):
        cal = Calculator()
        self.assertEqual(cal.mod(8, 4), (2, 0))

    def test_mod_divide_by_zero(self):
        cal = Calculator()
        self.assertRaises(ZeroDivisionError, calc.mod, 7, 0)

上面每一個 test method 的開頭都有一行 cal = Calculator(),就屬於測試開始前的準備工作。將這些通用的準備工作抽離出來:

calc.py

import unittest

class Calculator:

    def mod(self, dividend, divisor):
        remainder = dividend % divisor
        quotient = (dividend - remainder) / divisor
        return quotient, remainder

class CalculatorTest(unittest.TestCase):

    def setUp(self):    1
        self.cal = Calculator()

    def tearDown(self): 2
        self.cal = None

    def test_mod_with_remainder(self):
        self.assertEqual(self.cal.mod(5, 3), (1, 2)) 3

    def test_mod_without_remainder(self):
        self.assertEqual(self.cal.mod(8, 4), (2, 0))

    def test_mod_divide_by_zero(self):
        self.assertRaises(ZeroDivisionError, self.cal.mod, 7, 0)

if __name__ == '__main__':
    unittest.main()
1 將測試前的準備工作寫在 setUp() 裡,在每一個 test case 開始前執行。
2 將測試後的清理工作寫在 tearDown() 裡,在每一個 test case 結束後執行,無論測試結果如何(甚至是 test error)。
3 直接使用 setUp() 準備好的測試資料。

為了確保 test isolation,每一個 test method 都是透過一個全新的 TestCase 來執行。下面透過一個簡單的範例來證實這一點,也順便觀察 setUp()、test method 以及 tearDown() 的執行順序:

fixture.py

import unittest

class FixtureTest(unittest.TestCase):

    def log(self, msg):
        objid = hex(id(self))
        print '<%s>: %s -- %s' % (objid, msg, self._testMethodName) 1

    def setUp(self):
        self.log('setUp() invoked.')

    def tearDown(self):
        self.log('tearDown() invoked.')

    def test_case_1(self):
        self.log('conduct test #1.')

    def test_case_2(self):
        self.log('conduct test #2. [fail]')
        self.fail('test fail')

    def test_case_3(self):
        self.log('conduct test #3. [error]')
        raise Exception('test error')

if __name__ == '__main__':
    unittest.main()
1 TestCase._testMethodName 記錄著該 TestCase instance 是對應到哪個 test method。

執行結果:

$ python fixture.py
<0x2204610>: setUp() invoked. -- test_case_1       1
<0x2204610>: conduct test #1. -- test_case_1
<0x2204610>: tearDown() invoked. -- test_case_1
<0x22045d0>: setUp() invoked. -- test_case_2
<0x22045d0>: conduct test #2. [fail] -- test_case_2
<0x22045d0>: tearDown() invoked. -- test_case_2
<0x2204690>: setUp() invoked. -- test_case_3
<0x2204690>: conduct test #3. [error] -- test_case_3
<0x2204690>: tearDown() invoked. -- test_case_3    2
.FE
======================================================================
ERROR: test_case_3 (__main__.FixtureTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "fixture.py", line 24, in test_case_3
    raise Exception('test error')
Exception: test error

======================================================================
FAIL: test_case_2 (__main__.FixtureTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "fixture.py", line 20, in test_case_2
    self.fail('test fail')
AssertionError: test fail

----------------------------------------------------------------------
Ran 3 tests in 0.001s

FAILED (failures=1, errors=1)
1 setUp() 固定會在 test case 開始前被呼叫。注意訊息前面帶的 object ID,可以看出 3 個 test case 都是不同的 instance。
2 tearDown() 固定會在 test case 結束後被呼叫。

如果 setUp()tearDown() 自己發生錯誤會怎樣?同樣做個簡單的實驗:

fixture_error.py

import unittest

class BaseTest(unittest.TestCase):

    def log(self, msg):
        objid = hex(id(self))
        print '<%s>: %s -- %s' % (objid, msg, self._testMethodName)

class SetUpTest(BaseTest):

    def setUp(self):
        self.log('SetUpTest > setUp() invoked. [error]')
        raise Exception('Error in setUp()')

    def tearDown(self):
        self.log('SetUpTest > tearDown() invoked.')

    def test_case_1(self):
        self.log('SetUpTest > conduct test #1.')

    def test_case_2(self):
        self.log('SetUpTest > conduct test #2.')

class TearDownTest(BaseTest):

    def setUp(self):
        self.log('TearDownTest > setUp() invoked.')

    def tearDown(self):
        self.log('TearDownTest > tearDown() invoked. [error]')
        raise Exception('Error in setUp()')

    def test_case_1(self):
        self.log('TearDownTest > conduct test #1.')

    def test_case_2(self):
        self.log('TearDownTest > conduct test #2.')

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

執行結果:

$ python fixture_error.py
<0x23b5990>: SetUpTest > setUp() invoked. [error] -- test_case_1 1
<0x23b5a10>: SetUpTest > setUp() invoked. [error] -- test_case_2
<0x23b5c10>: TearDownTest > setUp() invoked. -- test_case_1
<0x23b5c10>: TearDownTest > conduct test #1. -- test_case_1
<0x23b5c10>: TearDownTest > tearDown() invoked. [error] -- test_case_1
<0x23b5c90>: TearDownTest > setUp() invoked. -- test_case_2      2
<0x23b5c90>: TearDownTest > conduct test #2. -- test_case_2
<0x23b5c90>: TearDownTest > tearDown() invoked. [error] -- test_case_2
EEEE
======================================================================
ERROR: test_case_1 (__main__.SetUpTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "fixture_error.py", line 13, in setUp
    raise Exception('Error in setUp()')
Exception: Error in setUp()

======================================================================
ERROR: test_case_2 (__main__.SetUpTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "fixture_error.py", line 13, in setUp
    raise Exception('Error in setUp()')
Exception: Error in setUp()

======================================================================
ERROR: test_case_1 (__main__.TearDownTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "fixture_error.py", line 31, in tearDown
    raise Exception('Error in setUp()')
Exception: Error in setUp()

======================================================================
ERROR: test_case_2 (__main__.TearDownTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "fixture_error.py", line 31, in tearDown
    raise Exception('Error in setUp()')
Exception: Error in setUp()

----------------------------------------------------------------------
Ran 4 tests in 0.001s

FAILED (errors=4)
1 setUp() 發生錯誤時,test case 不會被執行,連帶的 tearDown() 也不會被呼叫。
2 tearDown() 發生錯誤時,不影響下一個 test case 的 setUp()

將測試碼與受測碼獨立開來

上面把測試碼跟受測碼擺在同一個 module 的做法並不妥當,因為這會迫使測試碼一定要隨著受測碼散佈出去,而且執行期也要一併載入哪些只有在測試時才會用到的 module。事實上,複雜的測試還會用到其他 mock/testing framework,這個問題會更為明顯…

下面將測試碼獨立出來:

PROJECT_DIR
|-- mycalc/      1
|   |-- calc.py
|   `-- __init__.py
|-- mycalc.egg-info/
|-- PKG-INFO
|-- LICENSE
|-- README
|-- setup.cfg
|-- setup.py
`-- tests/       2
    |-- __init__.py
    |-- functional/
    `-- unit/
        |-- __init__.py
        `-- test_calc.py 3
1 假設產品名稱是 mycalc,慣例上會把主要的程式碼都放在這底下。
2 慣例上會把所有關於測試的程式碼都放在 tests/ 底下,之後再細分出 unit/functional test 等專用的子目錄。
3 通常一支 <product_name>/xxx.py,都會對應一支 tests/unit/test_xxx.py。習慣在檔名前面慣上 test_,使能直接搭配 unittest 或 nose 的 test discovery。

例如:

mycalc/calc.py

class Calculator:

    def mod(self, dividend, divisor):
        remainder = dividend % divisor
        quotient = (dividend - remainder) / divisor
        return quotient, remainder

tests/unit/test_calc.py

import unittest
from mycalc.calc import Calculator

class CalculatorTest(unittest.TestCase):

    def setUp(self):    1
        self.cal = Calculator()

    def tearDown(self): 2
        self.cal = None

    def test_mod_with_remainder(self):
        self.assertEqual(self.cal.mod(5, 3), (1, 2)) 3

    def test_mod_without_remainder(self):
        self.assertEqual(self.cal.mod(8, 4), (2, 0))

    def test_mod_divide_by_zero(self):
        with self.assertRaises(ZeroDivisionError) as cm:
            self.cal.mod(7, 0)

if __name__ == '__main__':
    unittest.main()
Note 下面的說明,都假設 PYTHONPATH 裡包含有 PROJECT_DIR。最簡單的方式就是把目錄切換到 PROJECT_DIR,再進行其他測試。

到目前為止,我們執行測試的方式都是直接執行 .py 檔。可以這麼做是因為加了 unittest.main() 這一行的關係。

$ python tests/unit/test_calc.py

事實上 unittest.main() 這一行也可以不加,但要執行測試時就得透過 -m unittest 了。

透過 -m unittest 來執行測試的好處是,透過給定 fully-qualified module/class/method name,可以控制到只執行某個 class 底下所有的 test method,或是單一個 test method。例如:

$ python -m unittest tests.unit.test_calc                1
...
----------------------------------------------------------------------
Ran 3 tests in 0.001s

OK

$ python -m unittest tests.unit.test_calc.CalculatorTest 2
...
----------------------------------------------------------------------
Ran 3 tests in 0.001s

OK

$ python -m unittest tests.unit.test_calc.CalculatorTest.test_mod_with_remainder 3
.
----------------------------------------------------------------------
Ran 1 test in 0.000s

OK
1 執行某個 module 底下所有的 test case。注意這裡 tests.unit.test_calc 給的是 fully-qualified module name,不要誤寫成 tests.unit.test_calc.py
2 執行某個 class 底下所有的 test case。
3 執行特定一個 test case。

Python 2.7 另外支援 test discovery,可以自動找出某個資料夾底下所有的測試(預設會找 test*.py),例如:

$ python -m unittest discover tests/unit

透過 nose 執行測試更簡單:

$ nosetests
...
----------------------------------------------------------------------
Ran 3 tests in 0.023s

OK

參考資料

對「Python 單元測試(Unit Testing)」的一則回應

發表留言