unittest
Python 自 2.1 起開始內建 unittest,做為標準的 unit testing framework。unittest 在 Python 2.7 做了很多改進,因此以下的說明都以 Python 2.7 為主。
Python 做為一個 scripting language,許多錯誤在 compile-time 並沒有辦法被找出來,因此更需要 unit testing 的幫忙,在 build-time 跑過所有的程式碼(這部份可以借助 coverage tools 來提供回饋),儘可能把所有的 programming error 都找出來。 |
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 AssertionError |
沒有明確指出實際/預期結果兩者間的差異。 |
改成 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): 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), (1, 0)) def test_mod_divide_by_zero(self): cal = Calculator() assertRaises(ZeroDivisionError, cal.mod, 7, 1) if __name__ == '__main__': unittest.main() |
只要繼承自 unittest.TestCase 即可,類別名稱沒有特別要求,但通常會在後面串上 Test。 | |
以 test 開頭的方法都會被視為 test method,分別代表不同的 test case。 | |
用 TestCase.assert*() 來做檢查。下面會說明它跟直接用 assert 來做驗證有什麼差別。 | |
這裡故意寫成 (1, 0),是為了產生 test failure。 | |
用 TestCase.assertRaises() 來驗證呼叫某個 function 必須丟出 exception。這裡故意少寫了 self.,是為了產生 test error。 | |
透過 unittest.main() 可以執行同一 module 裡所有的 test case。 |
重新執行的結果:
$ python calc.py E.F ====================================================================== ERROR: test_mod_divide_by_zero (__main__.CalculatorTest) ---------------------------------------------------------------------- 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) First differing element 0: 2 1 - (2, 0) ? ^ + (1, 0) ? ^ ---------------------------------------------------------------------- Ran 3 tests in 0.001s FAILED (failures=1, errors=1) |
每一個字元都表示不同 test case 的執行結果。. 表示成功,F 表示失敗(failure),E 表示錯誤(error)。 | |
逐項列出 test failure/error 的細節。 | |
同樣是丟出 AssertionError,但透過 TestCase.assert*() 來做驗證,會產生比較詳細的訊息。 | |
測試不成功時區分為 test failure (單純是結果與預期不符) 與 test error (執行期發生其他錯誤)。 |
用不用 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。 |
上面 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 True 與 bool(expr) is False 的檢查,而 assertEqual() 跟 assertNotEqual(),內部則分別會做(簡單地說) first == second 與 first != second 的檢查。例如:
self.assertTrue(True or False) self.assertEqual(2, 2) |
assertEqual(actual, expected) 或 assertEqual(expected, actual)? 在 Python 裡,使用 assertEqual(actual, expected) 或 assertEqual(expected, actual),從結果來看並沒有什麼差別。 test_assert.py
執行結果:
由於 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 等身上。
既然 assertEqual() 內部會自動做判斷,在實務上就沒有必要直接使用 assertListEqual()、assertTupleEqual() 等,讓測試碼保持彈性。 |
assertEqual() 獨厚 unicode? 比較特別的地方是 assertEqual() 在比對兩個字串時,只有雙方都是 unicode 時才會詳細指出差異的地方: test_assert_string.py
執行結果:
要讓 assertEqual() 也以相同的方式對待 str,有以下兩種方式: test_assert_string.py
執行結果:
|
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,也就是 first 與 second 指向同一個 object instance。
- assertIsNot(first, second, msg=None) – 檢查 first is not second,也就是 first 與 second 指向不同的 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" ---------------------------------------------------------------------- Ran 2 tests in 0.001s FAILED (failures=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() |
assertRaises*(exception[, regexp]) 的陷阱 這種 context manager 的用法是 Python 2.7 才有的,單純只是語法上的甜頭(syntax sugar),因為使用上存在著一些陷阱:
也就是說 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): self.cal = Calculator() def tearDown(self): self.cal = None def test_mod_with_remainder(self): self.assertEqual(self.cal.mod(5, 3), (1, 2)) 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() |
將測試前的準備工作寫在 setUp() 裡,在每一個 test case 開始前執行。 | |
將測試後的清理工作寫在 tearDown() 裡,在每一個 test case 結束後執行,無論測試結果如何(甚至是 test error)。 | |
直接使用 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) 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() |
TestCase._testMethodName 記錄著該 TestCase instance 是對應到哪個 test method。 |
執行結果:
$ python fixture.py <0x2204610>: setUp() invoked. -- test_case_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 .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) |
setUp() 固定會在 test case 開始前被呼叫。注意訊息前面帶的 object ID,可以看出 3 個 test case 都是不同的 instance。 | |
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 <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 <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) |
setUp() 發生錯誤時,test case 不會被執行,連帶的 tearDown() 也不會被呼叫。 | |
tearDown() 發生錯誤時,不影響下一個 test case 的 setUp()。 |
將測試碼與受測碼獨立開來
上面把測試碼跟受測碼擺在同一個 module 的做法並不妥當,因為這會迫使測試碼一定要隨著受測碼散佈出去,而且執行期也要一併載入哪些只有在測試時才會用到的 module。事實上,複雜的測試還會用到其他 mock/testing framework,這個問題會更為明顯…
下面將測試碼獨立出來:
PROJECT_DIR |-- mycalc/ | |-- calc.py | `-- __init__.py |-- mycalc.egg-info/ |-- PKG-INFO |-- LICENSE |-- README |-- setup.cfg |-- setup.py `-- tests/ |-- __init__.py |-- functional/ `-- unit/ |-- __init__.py `-- test_calc.py |
假設產品名稱是 mycalc,慣例上會把主要的程式碼都放在這底下。 | |
慣例上會把所有關於測試的程式碼都放在 tests/ 底下,之後再細分出 unit/functional test 等專用的子目錄。 | |
通常一支 <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): self.cal = Calculator() def tearDown(self): self.cal = None def test_mod_with_remainder(self): self.assertEqual(self.cal.mod(5, 3), (1, 2)) 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() |
下面的說明,都假設 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 ... ---------------------------------------------------------------------- Ran 3 tests in 0.001s OK $ python -m unittest tests.unit.test_calc.CalculatorTest ... ---------------------------------------------------------------------- Ran 3 tests in 0.001s OK $ python -m unittest tests.unit.test_calc.CalculatorTest.test_mod_with_remainder . ---------------------------------------------------------------------- Ran 1 test in 0.000s OK |
執行某個 module 底下所有的 test case。注意這裡 tests.unit.test_calc 給的是 fully-qualified module name,不要誤寫成 tests.unit.test_calc.py。 | |
執行某個 class 底下所有的 test case。 | |
執行特定一個 test case。 |
Python 2.7 另外支援 test discovery,可以自動找出某個資料夾底下所有的測試(預設會找 test*.py),例如:
$ python -m unittest discover tests/unit
透過 nose 執行測試更簡單:
$ nosetests ... ---------------------------------------------------------------------- Ran 3 tests in 0.023s OK |
寫的真好,謝謝分享 🙂
Jeremy整理得真好!