在軟體測試中,有效隔離外部依賴對於單元測試至關重要。測試雙倍技術,特別是模擬物件,提供了一種精確控制依賴行為的機制。Python 的 unittest.mock 模組提供 MockMagicMock 物件,方便開發者建立和組態模擬物件。Mock 適用於一般函式和方法的模擬,而 MagicMock 則支援神奇方法,適用於模擬具有特殊行為的物件。透過模擬物件,我們可以驗證程式碼與依賴的互動,確保程式碼在預期條件下正確執行。實際應用中,模擬 HTTP 請求、資料函式庫連線等外部依賴,可以提升測試效率和穩定性,避免受外部因素幹擾。結合 pytest fixture,可以更簡潔地管理模擬物件的建立和清理,提升測試程式碼的可讀性和可維護性。

測試雙倍(Test Double)與模擬物件(Mock Object)的應用

在軟體測試中,測試雙倍是一種常見的技術,用於隔離被測試程式碼的外部依賴。模擬物件(Mock Object)是測試雙倍的一種實作方式,允許開發者定義物件在特定呼叫下的行為,並記錄其被呼叫的次數和引數。

模擬物件的基本原理

模擬物件的核心功能是記錄其被呼叫的資訊,包括引數、次數等。這使得開發者可以在稍後的階段驗證應用程式的行為。在Python中,unittest.mock 模組提供了 MockMagicMock 物件,用於建立模擬物件。

Mock 與 MagicMock 的區別

  • Mock 物件可以被組態為傳回任意值,並記錄被呼叫的資訊。
  • MagicMock 物件除了具備 Mock 的功能外,還支援神奇方法(magic methods),如 __getitem____len__ 等。
from unittest.mock import Mock, MagicMock

class GitBranch:
    def __init__(self, commits):
        self._commits = {c["id"]: c for c in commits}

    def __getitem__(self, commit_id):
        return self._commits[commit_id]

    def __len__(self):
        return len(self._commits)

def author_by_id(commit_id, branch):
    return branch[commit_id]["author"]

# 使用 MagicMock 來模擬 GitBranch 物件
mbranch = MagicMock()
mbranch.__getitem__.return_value = {"author": "test"}
assert author_by_id("123", mbranch) == "test"

內容解密:

  1. MagicMock 的使用:由於 author_by_id 函式依賴 branch 物件的神奇方法 __getitem__,因此需要使用 MagicMock 而不是 Mock
  2. __getitem__.return_value 的組態:透過設定 mbranch.__getitem__.return_value,我們控制了 author_by_id 函式在執行時的傳回值,從而能夠進行正確的測試。

測試雙倍的實際應用案例

考慮一個名為 BuildStatus 的類別,它負責通知合併請求(Merge Request)的狀態。這個類別依賴於 requests 函式庫來傳送 HTTP 請求。

# mock_2.py
from datetime import datetime
import requests
from constants import STATUS_ENDPOINT

class BuildStatus:
    @staticmethod
    def build_date():
        return datetime.utcnow().isoformat()

    @classmethod
    def notify(cls, merge_request_id, status):
        build_status = {
            "id": merge_request_id,
            "status": status,
            "built_at": cls.build_date(),
        }
        response = requests.post(STATUS_ENDPOINT, json=build_status)
        response.raise_for_status()
        return response

測試案例

為了測試 BuildStatus.notify 方法,我們需要隔離 requests 函式庫的依賴,並控制 build_date 方法的傳回值。

# test_mock_2.py
from unittest import mock
from constants import STATUS_ENDPOINT
from mock_2 import BuildStatus

@mock.patch("mock_2.requests")
def test_build_notification_sent(mock_requests):
    build_date = "2018-01-01T00:00:01"
    with mock.patch(
        "mock_2.BuildStatus.build_date",
        return_value=build_date
    ):
        BuildStatus.notify(123, "OK")
        expected_payload = {
            "id": 123,
            "status": "OK",
            "built_at": build_date
        }
        mock_requests.post.assert_called_with(
            STATUS_ENDPOINT, json=expected_payload
        )

內容解密:

  1. @mock.patch("mock_2.requests"):使用 @mock.patch 裝飾器替換 requests 模組,使得我們可以控制其行為並驗證其被呼叫的情況。
  2. with mock.patch("mock_2.BuildStatus.build_date", return_value=build_date):透過上下文管理器替換 BuildStatus.build_date 方法的傳回值,以確保測試的可預測性。
  3. mock_requests.post.assert_called_with:驗證 requests.post 方法是否以預期的引數被呼叫。

單元測試與重構:提升程式碼的可維護性

在軟體開發過程中,單元測試扮演著至關重要的角色。它不僅能夠確保程式碼的正確性,還能在重構過程中提供安全網,使開發者能夠放心地修改程式碼結構而不必擔心引入新的錯誤。本文將探討如何利用單元測試和重構來提升程式碼的可維護性。

外部依賴的補丁與測試

在進行單元測試時,經常需要隔離外部依賴,以確保測試的獨立性和可重複性。Pytest 提供了一個強大的工具——pytest.fixture,結合 unittest.mock.patch,可以輕鬆地對外部依賴進行模擬。

@pytest.fixture(autouse=True)
def no_requests():
    with patch("requests.post"):
        yield

上述程式碼定義了一個自動使用的 fixture,它會在所有單元測試中自動套用,模擬 requests.post 方法,避免實際的 HTTP 請求。

內容解密:

  1. @pytest.fixture(autouse=True):定義一個自動使用的 fixture,無需在測試函式中明確指定。
  2. with patch("requests.post")::使用 unittest.mock.patch 來模擬 requests.post 方法,使其在測試期間不執行實際的 HTTP 請求。
  3. yield:標誌著 fixture 的執行點,在此之前進行設定,在此之後進行清理。

重構:改變程式碼結構而不改變其外部行為

重構是軟體維護中的一個關鍵活動。它涉及改變程式碼的內部結構,而不修改其外部行為。透過重構,可以提高程式碼的可讀性、可維護性和擴充套件性。

class BuildStatus:
    endpoint = STATUS_ENDPOINT

    def __init__(self, transport):
        self.transport = transport

    @staticmethod
    def build_date() -> str:
        return datetime.utcnow().isoformat()

    def compose_payload(self, merge_request_id, status) -> dict:
        return {
            "id": merge_request_id,
            "status": status,
            "built_at": self.build_date(),
        }

    def deliver(self, payload):
        response = self.transport.post(self.endpoint, json=payload)
        response.raise_for_status()
        return response

    def notify(self, merge_request_id, status):
        return self.deliver(self.compose_payload(merge_request_id, status))

內容解密:

  1. __init__(self, transport):透過建構子注入 transport 依賴,使類別更加靈活。
  2. compose_payloaddeliver:將原有的 notify 方法拆分為兩個更小的方法,提高了程式碼的可讀性和可測試性。
  3. deliver 方法使用注入的 transport 物件傳送請求,降低了對特定實作的依賴。

單元測試的演進

隨著程式碼的不斷演進,單元測試也需要相應地調整,以保持其有效性。

@pytest.fixture
def build_status():
    bstatus = BuildStatus(Mock())
    bstatus.build_date = Mock(return_value="2018-01-01T00:00:01")
    return bstatus

def test_build_notification_sent(build_status):
    build_status.notify(1234, "OK")
    expected_payload = {
        "id": 1234,
        "status": "OK",
        "built_at": build_status.build_date(),
    }
    build_status.transport.post.assert_called_with(
        build_status.endpoint, json=expected_payload
    )

內容解密:

  1. build_status fixture:建立一個 BuildStatus 物件,並模擬其 build_date 方法和 transport 屬性。
  2. test_build_notification_sent:驗證 notify 方法是否正確地呼叫了 transport.post,並傳遞了預期的 payload。

進階測試技術與程式碼品質保證

當業務規則發生變化時,我們的程式碼也需要相應地修改以支援新的需求。由於生產程式碼的變更,測試程式碼同樣需要更新以適應新版本的生產程式碼。

建立更高層級的抽象

在之前的範例中,我們為合併請求(Merge Request)物件建立了一系列測試,嘗試不同的組合並檢查合併請求的狀態。這是一個好的起步,但我們可以做得更好。一旦我們更好地理解了問題,就可以開始建立更好的抽象。

測試類別設計

class TestMergeRequestStatus(unittest.TestCase):
    def setUp(self):
        self.merge_request = MergeRequest()

    def assert_rejected(self):
        self.assertEqual(
            self.merge_request.status, MergeRequestStatus.REJECTED
        )

    def assert_pending(self):
        self.assertEqual(
            self.merge_request.status, MergeRequestStatus.PENDING
        )

    def assert_approved(self):
        self.assertEqual(
            self.merge_request.status, MergeRequestStatus.APPROVED
        )

    def test_simple_rejected(self):
        self.merge_request.downvote("maintainer")
        self.assert_rejected()

    def test_just_created_is_pending(self):
        self.assert_pending()

內容解密:

  1. setUp 方法:在每個測試方法執行前初始化 MergeRequest 物件。
  2. assert_rejectedassert_pendingassert_approved 方法:封裝狀態檢查邏輯,避免重複撰寫相同的斷言,提高程式碼的可維護性。
  3. test_simple_rejectedtest_just_created_is_pending 方法:測試合併請求的不同場景,驗證其狀態是否正確。

進階測試技術

屬性基礎測試(Property-Based Testing)

屬性基礎測試透過生成測試案例資料來找出程式碼中未被覆寫的錯誤場景。主要使用的函式庫是 hypothesis,它可以幫助我們找到使程式碼失敗的資料。

突變測試(Mutation Testing)

突變測試是一種評估測試品質的方法,透過修改原始程式碼來產生變異版本(稱為突變體),檢查現有的測試是否能夠捕捉到這些變化。

範例:突變測試實作

假設我們有一個簡單的函式 evaluate_merge_request,根據贊同票數和反對票數傳回合併請求的狀態:

# File: mutation_testing_example.py
from mrstatus import MergeRequestStatus as Status

def evaluate_merge_request(upvote_count, downvotes_count):
    if downvotes_count > 0:
        return Status.REJECTED
    if upvote_count >= 2:
        return Status.APPROVED
    return Status.PENDING

對應的單元測試:

# File: test_mutation_testing_example.py
class TestMergeRequestEvaluation(unittest.TestCase):
    def test_approved(self):
        result = evaluate_merge_request(3, 0)
        self.assertEqual(result, Status.APPROVED)

使用 mutpy 進行突變測試,可以幫助我們評估現有測試的有效性。

內容解密:

  1. evaluate_merge_request 函式:根據輸入的票數傳回合併請求的狀態。
  2. TestMergeRequestEvaluation 類別:包含對 evaluate_merge_request 函式的單元測試。
  3. 突變測試工具 mutpy:透過修改原始程式碼來評估測試的有效性。

突變測試:提升程式碼品質的利器

突變測試是一種軟體測試方法,透過故意在程式碼中引入錯誤(稱為「突變」),然後執行測試使用案例以檢測是否能夠發現這些錯誤。這個過程有助於評估測試使用案例的品質和有效性。

使用突變測試工具

在給定的範例中,使用了一個名為 mut.py 的突變測試工具。該工具可以根據指定的運算元對程式碼進行修改,產生多個版本的「突變」程式碼。然後執行測試使用案例,以檢查是否能夠殺死(即檢測到)這些突變。

$ PYTHONPATH=src mut.py \
--target src/mutation_testing_${CASE}.py \
--unit-test tests/test_mutation_testing_${CASE}.py \
--operator AOD `# 刪除算術運算元` \
--operator AOR `# 替換算術運算元` \
--operator COD `# 刪除條件運算元` \
--operator COI `# 插入條件運算元` \
--operator CRP `# 替換常數` \
--operator ROR `# 替換關係運算元` \
--show-mutants

內容解密:

  1. PYTHONPATH=src:設定 PYTHONPATH 環境變數,使 Python 能夠在 src 目錄下尋找模組。
  2. mut.py:突變測試工具的主程式。
  3. --target:指定要進行突變測試的目標程式碼檔案。
  4. --unit-test:指定對應的單元測試檔案。
  5. --operator:指定要使用的突變運算元,例如刪除或替換某些運算元。
  6. --show-mutants:顯示產生的突變。

突變測試結果分析

執行突變測試後,會得到一份報告,顯示有多少突變被殺死(即被測試使用案例檢測到)。在範例中,突變得分為 100%,表示所有產生的突變都被測試使用案例殺死。

[*] 突變得分 [0.04649 s]: 100.0%
- all: 4
- killed: 4 (100.0%)
- survived: 0 (0.0%)
- incompetent: 0 (0.0%)
- timeout: 0 (0.0%)

內容解密:

  1. 突變得分:表示測試使用案例殺死突變的比例。
  2. all:總共產生的突變數量。
  3. killed:被測試使用案例殺死的突變數量。
  4. survived:未被測試使用案例殺死的突變數量。
  5. incompetent:無效的突變數量。
  6. timeout:超時的突變數量。

突變測試的優缺點

突變測試可以有效地評估測試使用案例的品質,但也存在一些缺點。優點包括能夠發現測試使用案例的不足之處,缺點則包括需要耗費大量資源和時間。

常見的測試主題

在進行測試時,有一些常見的主題需要考慮,例如邊界值、等價類別和邊緣案例。

邊界值

邊界值通常是程式碼中的一個麻煩來源。需要檢查條件陳述式中的邊界值,並新增相應的測試使用案例。

等價類別

等價類別是一種劃分輸入資料的方法,使得同一類別中的資料具有相同的特性。可以根據等價類別設計測試使用案例,以提高測試效率。

def my_function(number: int):
    return "even" if number % 2 == 0 else "odd"

內容解密:

  1. 該函式根據輸入整數的奇偶性傳回不同的字串。
  2. 可以將輸入整數劃分為偶數和奇數兩個等價類別。
  3. 只需選擇每個類別中的一個代表元素進行測試,即可覆寫所有情況。

邊緣案例

邊緣案例是指一些特殊或極端的情況,需要特別關注。例如,日期處理中的閏年或二月二十九日等。

測試驅動開發(TDD)

TDD是一種開發方法,先編寫測試使用案例,然後再實作相應的程式碼。這種方法可以確保程式碼的正確性和可測試性。