Python Deep Drive IV 第 3 節 – Project 1 (45-46)

Transactions

Lecture 45. Project Solution - Transactions

deposit

雖然都可以,但因為是 class valriable,使用 Account._transaction_codes 會比 self._transaction_codes 更合適。

transaction_code = Account._transaction_codes['deposit']

先產生 transaction code,然後才進行實際交易動作。這裡是 instance method,所以用 self.generate_confirmation_code

conf_code = self.generate_confirmation_code(transaction_code)
展開看程式碼
    def deposit(self, value):
        if not isinstance(value, numbers.Real):
            raise ValueError('Deposit value must be a real number.')
        if value <= 0:
            raise ValueError('Deposit value must be a positive number.')
        
        # get transaction code
        transaction_code = Account._transaction_codes['deposit']
        
        # generate a confirmation code
        conf_code = self.generate_confirmation_code(transaction_code)
        
        # make deposit and return conf code
        self._balance += value
        return conf_code

withdraw

同理,withdraw 也是類似(class valriable & 先產生 transaction code,然後才進行實際交易動作)。

不過這裡老師刻意留了個 bug,給下一節的 Unit Testing 用。

展開看程式碼
    def withdraw(self, value):
        # hmmm... repetitive code! we'll need to fix this
        # TODO: refactor a function to validate a valid positive number
        #       and use in __init__, deposit and 
        
        accepted = False
        if self.balance - value < 0:
            # insufficient funds - we'll reject this transaction
            transaction_code = Account._transaction_codes['rejected']
        else:
            transaction_code = Account._transaction_codes['withdraw']
            accepted = True
            
        conf_code = self.generate_confirmation_code(transaction_code)
        
        # Doing this here in case there's a problem generating a confirmation code
        # - do not want to modify the balance if we cannot generate a transaction code successfully
        if accepted:
            self._balance -= value
            
        return conf_code

pay interest

同理,pay interest 也是類似(class valriable & 先產生 transaction code,然後才進行實際交易動作)。

    def pay_interest(self):
        interest = self.balance * Account.get_interest_rate() / 100
        conf_code = self.generate_confirmation_code(Account._transaction_codes['interest'])
        self._balance += interest
        return conf_code

重構 refactoring

前面在做存提款必須是正實數(不含零)的確認程式碼,既然在存提款兩個 method 都會用到(事實上還有 __init__ ,之前一樣刻意留下來的 bug),那可以獨立另寫一個 (static)method。

    @staticmethod
    def validate_real_number(value, min_value=None):
        if not isinstance(value, numbers.Real):
            raise ValueError('Value must be a real number.')
            
        if min_value is not None and value < min_value:
            raise ValueError(f'Value must be at least {min_value}')
            
        # validation passed, return valid value
        return value

Happy Test

Lecture 46. Project Solution - Testing with unittest

問題:

  1. 只測我們預期輸入的數字,無法測到非預期的結果。

  2. 手工業(工人智慧)。一個個輸入測試數字,一個個觀察輸出結果。不僅耗時費力,萬一改寫程式,很多測試程式,也要跟著重寫。


Unit Test

重點:

  • 隔離測試 Isolation Testing

  • 每個測試 def test_xxx(self) 會各自獨立執行 setUp & tearDown

  • 執行順序無關:unittest 不保證 test case 間執行的順序,所以每一個 test case 都必須要做到 self-contained,不會因執行順序改變就失敗。

subtest 尚未撰寫筆記

比較複雜的測試,無法做隔離測試的,像是 Database 資料庫、或是呼叫遠端 API 等,通常會使用 Mocking Testing(最下方參考資料中,有一些文章介紹)。

撰寫 Unit Testing Code,可能比被測試的程式碼本身還龐大。

老師建議:寫程式時,同時撰寫 Unit Testing Code,不要等程式完成後再寫,那會很痛苦。(好吧,最後一句是我稍微誇大的寫法)

    @staticmethod
    def validate_real_number(value, min_value=None):
        if not isinstance(value, numbers.Real):
            raise ValueError('Value must be a real number.')
            
        if min_value is not None and value < min_value:
            raise ValueError(f'Value must be at least {min_value}')
            
        # validation passed, return valid value
        return value

通常撰寫 Unit Testing Code,會是另外獨立的 *.py 檔。不過由於老師是用 Jupyter Notebook 示範,所以就寫在同一個檔案裡了。

import unittest

def run_tests(test_class):
    suite = unittest.TestLoader().loadTestsFromTestCase(test_class)
    runner = unittest.TextTestRunner(verbosity=2)
    result = runner.run(suite)

class TestAccount(unittest.TestCase):
    
    def test_create_timezone(self):
        tz = TimeZone('ABC', -1, -30)
        self.assertEqual('ABC', tz.name)
        self.assertEqual(timedelta(hours=-1, minutes=-30), tz.offset)
        
    def test_timezones_equal(self):
        tz1 = TimeZone('ABC', -1, -30)
        tz2 = TimeZone('ABC', -1, -30)
        self.assertEqual(tz1, tz2)
        
    def test_timezones_not_equal(self):
        tz = TimeZone('ABC', -1, -30)
        
        test_timezones = (
            TimeZone('DEF', -1, -30),
            TimeZone('ABC', -1, 0),
            TimeZone('ABC', 1, -30)
        )
        for i, test_tz in enumerate(test_timezones):
            with self.subTest(test_number=i):
                self.assertNotEqual(tz, test_tz)
                
    def test_create_account(self):
        account_number = 'A100'
        first_name = 'FIRST'
        last_name = 'LAST'
        tz = TimeZone('TZ', 1, 30)
        balance = 100.00
        
        a = Account(account_number, first_name, last_name, tz, balance)
        self.assertEqual(account_number, a.account_number)
        self.assertEqual(first_name, a.first_name)
        self.assertEqual(last_name, a.last_name)
        self.assertEqual(first_name + ' ' + last_name, a.full_name)
        self.assertEqual(tz, a.timezone)
        self.assertEqual(balance, a.balance)
        
    def test_create_account_blank_first_name(self):
        account_number = 'A100'
        first_name = ''
        last_name = 'LAST'
        tz = TimeZone('TZ', 1, 30)
        balance = 100.00
        
        with self.assertRaises(ValueError):
            a = Account(account_number, first_name, last_name, tz, balance)
            
    def test_create_account_negative_balance(self):
        account_number = 'A100'
        first_name = 'FIRST'
        last_name = 'LAST'
        tz = TimeZone('TZ', 1, 30)
        balance = -100.00
        
        with self.assertRaises(ValueError):
            a = Account(account_number, first_name, last_name, tz, balance)
            
    def test_account_deposit_ok(self):
        account_number = 'A100'
        first_name = 'FIRST'
        last_name = 'LAST'
        balance = 100.00
        
        a = Account(account_number, first_name, last_name, initial_balance=balance)
        conf_code = a.deposit(100)
        self.assertEqual(200, a.balance)
        self.assertIn('D-', conf_code)
    
    def test_account_deposit_negative_amount(self):
        account_number = 'A100'
        first_name = 'FIRST'
        last_name = 'LAST'
        balance = 100.00
        
        a = Account(account_number, first_name, last_name, initial_balance=balance)
        with self.assertRaises(ValueError):
            conf_code = a.deposit(-100)
        
    def test_account_withdraw_ok(self):
        account_number = 'A100'
        first_name = 'FIRST'
        last_name = 'LAST'
        balance = 100.00
        
        a = Account(account_number, first_name, last_name, initial_balance=balance)
        conf_code = a.withdraw(20)
        self.assertEqual(80, a.balance)
        self.assertIn('W-', conf_code)
        
    
    def test_account_withdraw_overdraw(self):
        account_number = 'A100'
        first_name = 'FIRST'
        last_name = 'LAST'
        balance = 100.00
        
        a = Account(account_number, first_name, last_name, initial_balance=balance)
        conf_code = a.withdraw(200)
        self.assertIn('X-', conf_code)
        self.assertEqual(balance, a.balance)

unittest 模組

最前面的 run_tests 函式,使用標準的 unnitest 的流程步驟。

unittest 模組主要包括四個部份:

  1. Test case(測試案例)一個 test case 是一個獨立的測試單元。 unittest 提供一個基礎類別 TestCase,可以用來建立一個新的測試案例。

  2. Test fixture(測試設備)執行一或多個測試前必要的預備資源,以及相關的清除資源動作。例如可以是建立臨時性的或是代理用 (proxy) 資料庫、目錄、或是啟動一個伺服器程序。

  3. Test suite(測試套件)一組測試案例、測試套件或者是兩者的組合。

  4. Test runner(測試執行器)負責執行測試並提供測試結果的元件。

我們逐一介紹這四個模組:

1. Test case 測試案例

unittest 模組提供了一個基礎類別 TestCase ,你可以繼承它來建立新的測試案例。

import unittest

class TestAccount(unittest.TestCase):
    
    def test_create_timezone(self):
        tz = TimeZone('ABC', -1, -30)
        self.assertEqual('ABC', tz.name)
        self.assertEqual(timedelta(hours=-1, minutes=-30), tz.offset)

    def test_xxx(self):  ## <=
        ...

每個測試必須定義在一個 test 名稱為開頭的方法中(例如範例中的 test_create_timezone)。一個 TestCase 的子類別,通常用來為某個類別或模組的單元方法或函式定義測試。

2. Test fixture 測試設備

許多單元測試經常使用相同的測試設備,你可以在 TestCase 的子類別中定義 setUptearDown 方法,測試執行器會在每個測試運行之前執行 setUp 方法,每個測試運行之後執行 tearDown 方法。

class TestAccount(unittest.TestCase):
    def setUp(self):
        print('Running setup...')
        self.account_number = 'A100'
        
    def tearDown(self):
        print('Running tear down...')
        
    def test_xxx(self):
        ...

3. Test suite 測試套件

根據測試的需求不同,你可能會想要將不同的測試組合在一起。有幾種方法:

  1. addTest: 使用 addTest 逐一加上想做的測試。範例:

例如,zzzCase 中可能有數個 test_xxx 方法,而你只想將 test_xxxtest_yyy 組裝為一個測試套件的話,可以這樣做:

suite = unittest.TestSuite()
suite.addTest(zzzTestCase('test_xxx'))
suite.addTest(zzzTestCase('test_yyy'))
...
  1. list: 使用一個 list 來定義要組裝的 test_xxx 方法清單。範例:
tests = ['test_xxx', 'test_yyy']
suite = unittest.TestSuite(map(zzzTestCase, tests))
  1. loadTestsFromTestCase: 自動載入 TestCase 子類別中所有 test_xxx 方法。範例:
unittest.TestLoader().loadTestsFromTestCase(zzzTestCase)
  1. 組合技:和另外的測試套件,混合使用。範例:
suite2 = unittest.TestSuite()
suite2.addTest(suite)
suite2.addTest(OtherTestCase('test_hahaha'))  ## <= OtherTestCase('test_hahaha')
suite1 = module1.TheTestSuite()
suite2 = module2.TheTestSuite()
alltests = unittest.TestSuite([suite1, suite2])

老師課程中,使用的是第三種做法:自動載入全部 test_xxx 方法。

def run_tests(test_class):
    suite = unittest.TestLoader().loadTestsFromTestCase(test_class)  ## <=
    runner = unittest.TextTestRunner(verbosity=2)
    result = runner.run(suite)

4. Test runner 測試執行器

幾個方法:

  1. TextTestRunner
suite = (unittest.TestLoader().loadTestsFromTestCase(CalculatorTestCase))
unittest.TextTestRunner(verbosity=2).run(suite)
  1. unittest.main

verbosity:執行測試時得到更多細節(verbosity 愈高, 細節愈多)

unittest.main(verbosity=2)
  1. 命令列中 command line

以命令執行時,可以加上許多參考,請參考最下方參考資料的介紹。

python test_zzz.py

老師課程中,使用第一種做法。

def run_tests(test_class):
    suite = unittest.TestLoader().loadTestsFromTestCase(test_class)
    runner = unittest.TextTestRunner(verbosity=2)  ## <=
    result = runner.run(suite)  ## <=

做法相同,只是拆成兩步來做(紅色是上述範例,綠色是老師的程式),我們看一下對照:

def run_tests(test_class):
    suite = unittest.TestLoader().loadTestsFromTestCase(test_class)
-    unittest.TextTestRunner(verbosity=2).run(suite)
+    runner = unittest.TextTestRunner(verbosity=2)
+    result = runner.run(suite)

unnittest 參考資訊

以上 unnittest 的介紹,主要參考自以下網站:

unnittest 官方文件:

良葛格的 Python Tutorial:

題外話:良葛格逝世後,有點擔心他過去網路文章的保留問題。

像這個網址 openhome.cc 到期日是 2023年12月17日。


Unit Test Mocking

本節老師僅提及,未做介紹。以下提供兩部分參考資料。

第一部分是簡單介紹,這部分主要由 ChatGPT 協助撰寫。

第二部分是參考資料連結。

關於 Unit Test Mocking

圖檔來源:測試中常見的名詞:Stub, Dummy, Mock..等等 | 只放拖鞋的鞋櫃

Mock Object 就是帶有判斷功能的 Stub,判斷 SUT 是不是正確地使用這個 DOC。

Stub:Stub 是一種模擬物件,用於模擬程式的某些部分,通常是被測試物件所依賴的外部系統或服務。在 Mock Testing 中,Stub 通常用於模擬物件的行為,例如模擬方法的返回值或引發異常。Stub 常常被用來測試一個模組的一個部分,而不是整個模組。

SUT:System Under Test 待測物。

DOC:Depended-On Component,SUT 所依賴的元件,也就是 Mock, Stub …想要抽換掉的元件。

Python 的 Mock Testing 是一種 Unit Test(單元測試)技術,可以模擬或替換特定程式碼的功能,並在測試中使用這些模擬對象。

這使得測試更容易、快速、可靠,因為它們可以獨立於應用程序或其他測試中的其他組件執行。

範例程式:

from unittest.mock import Mock
import requests

def get_data(url):
    response = requests.get(url)
    if response.status_code == 200:
        return response.json()
    else:
        return None

def test_get_data():
    mock_response = Mock()
    mock_response.status_code = 200
    mock_response.json.return_value = {"key": "value"}

    requests.get = Mock(return_value=mock_response)

    assert get_data("http://example.com/api/data") == {"key": "value"}

在上面這個範例中,我們想測試一個名為 get_data 的函數。這個函數從一個 URL 網址取得數據並傳回 JSON 數據,如果獲取數據失敗,則傳回 None。

在測試函數 test_get_data 中,我們創建了一個名為 mock_response 的模擬對象,並將其用於模擬 requests.get 的回應。我們可以設置模擬對象的屬性,以使其回應我們需要的數據,在這個範例中,我們將 status_code 設置為 200,並將 json 方法設置為返回一個我們指定的字典 {“key”: “value”}。

接下來,我們使用 Mock 函數將 requests.get 函數替換為我們剛剛創建的模擬對象。這將使 get_data 函數在獲取數據時返回我們指定的數據,而不是從實際 URL 網址讀取數據。

最後,我們使用 assert 語句來斷言 get_data 函數是否返回我們期望的數據。

這只是一個簡單的範例,在實際應用中,Mock Testing 可以幫助我們測試各種複雜的情況,例如非同步函數、多執行緒等。

何時適用?

在複雜的應用程式中,使用 mock testing 可以大大簡化測試過程,使開發人員更專注於實作應用程式的核心邏輯。

  1. 依賴外部服務或資源的應用程式:例如使用 API、資料庫、文件系統等,使用 mock testing 可以測試應用程式在這些服務不可用或出現異常時的反應。

  2. 模組之間有複雜的相依性:如果模組之間有複雜的相依性,使用 mock testing 可以幫助我們獨立地測試每個模組,從而更容易地找到問題並進行調試。

  3. 非測試環境:如果我們無法在測試環境中訪問某些資源,例如某些特定硬體或操作系統等,使用 mock testing 可以模擬這些資源,從而使我們能夠在非測試環境中進行單元測試。

  4. 非常規的使用情況:有時我們需要測試應用程式在非常規情況下的反應,例如錯誤輸入、異常操作等,使用 mock testing 可以輕鬆地模擬這些情況。


參考資料

在 command line 輸入以下命令查詢 unittest 的使用說明:

python -m unittest -h

下圖為輸出範例:


assert methods

TestCase 類提供了一些 斷言方法(assert methods)用於檢查並報告失敗。 下表列出了最常用的方法(請查看下文的其他表來瞭解更多的斷言方法)。

我將老師用到的 assertXxx 以黃底標註。

方法 檢查對象 引入版本
assertEqual(a, b) a == b
assertNotEqual(a, b) a != b
assertTrue(x) bool(x) is True
assertFalse(x) bool(x) is False
assertIs(a, b) a is b 3.1
assertIsNot(a, b) a is not b 3.1
assertIsNone(x) x is None 3.1
assertIsNotNone(x) x is not None 3.1
assertIn(a, b) a in b 3.1
assertNotIn(a, b) a not in b 3.1
assertIsInstance(a, b) isinstance(a, b) 3.2
assertNotIsInstance(a, b) not isinstance(a, b) 3.2

還可以使用下列方法來檢查異常、警告和日誌消息的產生:

方法 檢查對象 引入版本
assertRaises(exc, fun, *args, **kwds) fun(*args, **kwds) 引發了 exc
assertRaisesRegex(exc, r, fun, *args, **kwds) fun(*args, **kwds) 引發了 exc 並且消息可與正規表示式 r 相匹配 3.1
assertWarns(warn, fun, *args, **kwds) fun(*args, **kwds) 引發了 warn 3.2
assertWarnsRegex(warn, r, fun, *args, **kwds) fun(*args, **kwds) 引發了 warn 並且消息可與正規表示式 r 相匹配 3.2
assertLogs(logger, level) with 程式碼塊在 logger 上使用了最小的 level 等級寫入日誌 3.4
assertNoLogs(logger, level) with 程式碼塊沒有在logger 上使用最小的 level 等級寫入日誌 3.10

還有其他一些方法可用於執行更專門的檢查,例如:

方法 檢查對象 引入版本
assertAlmostEqual(a, b) round(a-b, 7) == 0
assertNotAlmostEqual(a, b) round(a-b, 7) != 0
assertGreater(a, b) a > b 3.1
assertGreaterEqual(a, b) a >= b 3.1
assertLess(a, b) a < b 3.1
assertLessEqual(a, b) a <= b 3.1
assertRegex(s, r) r.search(s) 3.1
assertNotRegex(s, r) not r.search(s) 3.2
assertCountEqual(a, b) ab 具有同樣數量的相同元素,無論其順序如何。 3.2

以下是 assertEqual() 自動選用的不同類型的比較方法。一般情況下不需要直接在測試中呼叫這些方法。

方法 用作比較 引入版本
assertMultiLineEqual(a, b) 字串 3.1
assertSequenceEqual(a, b) 序列 3.1
assertListEqual(a, b) 列表 3.1
assertTupleEqual(a, b) 元組 3.1
assertSetEqual(a, b) 集合 3.1
assertDictEqual(a, b) 字典 3.1

棄用 的別名:

方法名 已棄用的別名 已棄用的別名
assertEqual() failUnlessEqual assertEquals
assertNotEqual() failIfEqual assertNotEquals
assertTrue() failUnless assert_
assertFalse() failIf
assertRaises() failUnlessRaises
assertAlmostEqual() failUnlessAlmostEqual assertAlmostEquals
assertNotAlmostEqual() failIfAlmostEqual assertNotAlmostEquals
assertRegex() assertRegexpMatches
assertNotRegex() assertNotRegexpMatches
assertRaisesRegex() assertRaisesRegexp

Exception hierarchy

The class hierarchy for built-in exceptions is:

BaseException
 ├── BaseExceptionGroup
 ├── GeneratorExit
 ├── KeyboardInterrupt
 ├── SystemExit
 └── Exception
      ├── ArithmeticError
      │    ├── FloatingPointError
      │    ├── OverflowError
      │    └── ZeroDivisionError
      ├── AssertionError
      ├── AttributeError
      ├── BufferError
      ├── EOFError
      ├── ExceptionGroup [BaseExceptionGroup]
      ├── ImportError
      │    └── ModuleNotFoundError
      ├── LookupError
      │    ├── IndexError
      │    └── KeyError
      ├── MemoryError
      ├── NameError
      │    └── UnboundLocalError
      ├── OSError
      │    ├── BlockingIOError
      │    ├── ChildProcessError
      │    ├── ConnectionError
      │    │    ├── BrokenPipeError
      │    │    ├── ConnectionAbortedError
      │    │    ├── ConnectionRefusedError
      │    │    └── ConnectionResetError
      │    ├── FileExistsError
      │    ├── FileNotFoundError
      │    ├── InterruptedError
      │    ├── IsADirectoryError
      │    ├── NotADirectoryError
      │    ├── PermissionError
      │    ├── ProcessLookupError
      │    └── TimeoutError
      ├── ReferenceError
      ├── RuntimeError
      │    ├── NotImplementedError
      │    └── RecursionError
      ├── StopAsyncIteration
      ├── StopIteration
      ├── SyntaxError
      │    └── IndentationError
      │         └── TabError
      ├── SystemError
      ├── TypeError
      ├── ValueError
      │    └── UnicodeError
      │         ├── UnicodeDecodeError
      │         ├── UnicodeEncodeError
      │         └── UnicodeTranslateError
      └── Warning
           ├── BytesWarning
           ├── DeprecationWarning
           ├── EncodingWarning
           ├── FutureWarning
           ├── ImportWarning
           ├── PendingDeprecationWarning
           ├── ResourceWarning
           ├── RuntimeWarning
           ├── SyntaxWarning
           ├── UnicodeWarning
           └── UserWarning

Unittest Mock

Unittest Mock 官方文件:

這篇是 2022年7月9日 的文章:

這篇是 2022年2月16日 的文章(不確定是否內容限定在 SAP):

這篇是 2014年1月7日 的文章:


Python Testing Framework

本節僅提及,不介紹。

選這兩篇文章,是因為 google 排名較前,也因為寫作日期較新。