返回文章列表

Python 單元測試 原則與實踐

本文探討 Python 單元測試的原則與實踐,涵蓋測試特性、不同測試型別、與敏捷開發的關係,以及如何透過單元測試驅動乾淨程式碼。文章以合併請求狀態管理系統為例,演示如何使用 unittest 框架編寫有效的單元測試,並強調程式碼覆寫率的正確理解。

軟體測試 Python

單元測試是確保程式碼品質的根本,並在敏捷開發流程中扮演關鍵角色。有效能、可重複性、自我驗證的單元測試能快速驗證程式碼的正確性,減少錯誤並提升開發效率。整合測試著重於模組間的協作,而驗收測試則從使用者角度驗證系統功能。良好的軟體設計應具備可測試性,單元測試正是驅動乾淨程式碼的關鍵。透過單元測試,開發者能及早發現設計缺陷,並促使程式碼重構以提升可維護性。

單元測試的原則與實踐

單元測試是軟體開發過程中不可或缺的一環,其主要目的是驗證程式碼的正確性和穩定性。在進行單元測試時,我們需要遵循一些基本的原則,以確保測試的有效性和可靠性。

單元測試的特性

單元測試應該具備以下特性:

  • 效能:單元測試必須快速執行,因為它們會被重複執行多次。
  • 可重複性:單元測試應該能夠客觀地評估軟體的狀態,並且結果應該是可重複的。如果一個測試失敗了,它應該持續失敗,直到程式碼被修復。
  • 自我驗證:單元測試的執行結果應該能夠自我驗證,不需要額外的步驟來解釋測試結果。

在 Python 中,單元測試通常被放在新的 .py 檔案中,並使用特定的工具來執行。這些檔案會包含 import 陳述式,以便引入需要測試的商業邏輯。

內容解密:

  1. 效能:單元測試需要快速執行,這是因為開發者在開發過程中會頻繁地執行測試,以確保程式碼的正確性。
  2. 可重複性:測試結果的可重複性確保了測試的可靠性。如果一個測試失敗了,它應該持續失敗,直到程式碼被修復。
  3. 自我驗證:單元測試的執行結果應該能夠自我驗證,這意味著不需要額外的步驟來解釋測試結果。

其他形式的自動化測試

除了單元測試之外,還有其他形式的自動化測試,例如整合測試和驗收測試。

  • 整合測試:整合測試旨在測試多個元件之間的互動,以驗證它們是否能夠協同工作。在整合測試中,我們會模擬實際的生產環境,並測試元件之間的互動。
  • 驗收測試:驗收測試是一種自動化測試,旨在從使用者的角度驗證系統的功能。

內容解密:

  1. 整合測試:整合測試關注的是多個元件之間的互動,而不是個別元件的功能。
  2. 驗收測試:驗收測試旨在驗證系統是否滿足使用者的需求和期望。

單元測試與敏捷軟體開發

在現代軟體開發中,我們希望能夠快速地交付價值,並盡早地獲得回饋。單元測試在這個過程中扮演著重要的角色,因為它能夠幫助我們快速地驗證程式碼的正確性。

為了實作敏捷軟體開發,我們需要編寫大量的單元測試,並在開發過程中頻繁地執行它們。同時,我們也需要編寫一些整合測試和驗收測試,以驗證系統的功能和效能。

內容解密:

  1. 敏捷軟體開發:敏捷軟體開發強調快速交付價值和盡早獲得回饋的重要性。
  2. 單元測試的重要性:單元測試在敏捷軟體開發中扮演著重要的角色,因為它能夠幫助我們快速地驗證程式碼的正確性。
此圖示說明瞭單元測試、整合測試、驗收測試和敏捷軟體開發之間的關係。
  1. 單元測試關注的是個別元件的功能,並具備效能、可重複性和自我驗證的特性。
  2. 整合測試關注的是多個元件之間的互動,並模擬實際的生產環境。
  3. 驗收測試從使用者的角度驗證系統的功能。
  4. 敏捷軟體開發強調快速交付價值和盡早獲得回饋的重要性。

軟體測試與設計的關聯:單元測試如何驅動乾淨的程式碼

單元測試不僅是軟體開發中的重要環節,更是確保程式碼品質和可維護性的關鍵因素。當我們談論單元測試時,我們不僅是在討論如何撰寫測試案例,更是在探討如何設計出可測試、易於維護的軟體。

為什麼單元測試是必要的?

單元測試提供了對程式碼的正式驗證,確保在修改或擴充套件程式碼時不會引入新的錯誤或迴歸問題。遵循SOLID原則設計的軟體,如果沒有足夠的單元測試,很難保證其在修改後仍能正確執行。

單元測試就像是一張安全網,讓開發團隊能夠信心十足地對程式碼進行修改和最佳化。有了良好的單元測試,團隊可以更快速地交付價值,而不必擔心頻繁出現的錯誤。

單元測試與軟體設計的關係

良好的軟體設計應該具備可測試性(testability),這不僅是一種錦上添花的特性,更是驅動乾淨程式碼的重要因素。單元測試並不是對主要程式碼的補充,而是對程式碼設計有直接影響的因素。

當我們試圖為某些程式碼新增單元測試時,我們可能會發現需要修改程式碼以使其更易於測試。這種過程會促使我們改程式式碼的設計,最終形成更好的版本。

例項分析:透過單元測試改程式式碼設計

假設我們有一個Process物件,代表某個任務,並使用一個外部的MetricsClient來傳送指標資料到外部系統。原始的程式碼如下:

class MetricsClient:
    """第三方指標客戶端"""
    def send(self, metric_name, metric_value):
        if not isinstance(metric_name, str):
            raise TypeError("預期 metric_name 的型別為 str")
        if not isinstance(metric_value, str):
            raise TypeError("預期 metric_value 的型別為 str")
        logger.info("傳送 %s = %s", metric_name, metric_value)

class Process:
    def __init__(self):
        self.client = MetricsClient()  # 第三方指標客戶端

    def process_iterations(self, n_iterations):
        for i in range(n_iterations):
            result = self.run_process()
            self.client.send(f"iteration.{i}", str(result))

內容解密:

  • MetricsClient類別是第三方函式庫,用於傳送指標資料。
  • Process類別使用MetricsClient來傳送處理結果。
  • process_iterations方法在每次迭代後傳送結果。

在這個例子中,如果run_process方法的結果不是字串,MetricsClientsend方法會丟擲錯誤。為了避免這種情況,我們可以修改Process類別,使其使用一個包裝過的客戶端:

class WrappedClient:
    def __init__(self):
        self.client = MetricsClient()

    def send(self, metric_name, metric_value):
        return self.client.send(str(metric_name), str(metric_value))

class Process:
    def __init__(self):
        self.client = WrappedClient()
    # 其他程式碼保持不變

內容解密:

  • 建立了一個新的WrappedClient類別來包裝MetricsClient
  • WrappedClientsend方法確保所有引數在傳遞給MetricsClient之前都被轉換為字串。
  • 這樣可以避免因型別不符而導致的錯誤。

單元測試的重要性與實作

在軟體開發的過程中,單元測試扮演著至關重要的角色。它不僅能夠幫助開發者確保程式碼的正確性,還能夠在重構或修改程式碼時提供安全保障。單元測試的寫作過程甚至能夠反過來影響程式碼的設計,使其變得更加合理和可維護。

透過單元測試改善程式碼設計

試著為一段程式碼寫單元測試時,開發者往往會發現程式碼中存在的設計問題。例如,在處理外部函式庫或模組的互動時,單元測試能夠促使開發者思考如何更好地隔離這些依賴,以提高程式碼的可測試性。

以下是一個例子:假設我們有一個 WrappedClient 類別,它負責與外部客戶端進行互動。最初的實作可能直接在類別內部建立客戶端例項,這使得單元測試變得困難。透過單元測試的驅動,我們意識到應該將客戶端的建立與使用分離,例如透過依賴注入的方式來提供客戶端例項。

import unittest
from unittest.mock import Mock

class TestWrappedClient(unittest.TestCase):
    def test_send_converts_types(self):
        wrapped_client = WrappedClient()
        wrapped_client.client = Mock()
        wrapped_client.send("value", 1)
        wrapped_client.client.send.assert_called_with("value", "1")

這個測試案例促使我們改進 WrappedClient 的設計,使其更加靈活和可測試。

定義測試的邊界

在進行單元測試時,定義好測試的邊界至關重要。我們不應該試圖測試外部依賴的內部邏輯,而是應該關注於自己的程式碼邏輯是否正確,以及是否正確地與外部依賴進行了互動。

使用 Mock 物件進行隔離測試

在單元測試中,Mock 物件是一種非常有用的工具。它允許我們隔離外部依賴,專注於測試自己的程式碼邏輯。例如,在 TestWrappedClient 中,我們使用 Mock 物件來模擬 client 的行為,從而能夠專注於測試 WrappedClientsend 方法。

單元測試框架與工具

Python 中有多種單元測試框架和工具可供選擇。unittest 是 Python 標準函式庫的一部分,而 pytest 則是一個流行的外部函式庫,提供了更多的功能和靈活性。

使用 unittest 進行單元測試

import unittest

class TestMergeRequest(unittest.TestCase):
    def test_status_rejected(self):
        # 測試 MergeRequest 的狀態是否正確被拒絕
        pass

使用 pytest 進行單元測試

import pytest

def test_merge_request_status_rejected():
    # 測試 MergeRequest 的狀態是否正確被拒絕
    pass

程式碼覆寫率的誤區

雖然程式碼覆寫率可以作為一個參考指標,但它並不是衡量程式碼品質的絕對標準。高覆寫率不一定意味著程式碼是正確或可維護的。

合併請求狀態管理系統的單元測試例項

假設我們有一個合併請求狀態管理系統,其邏輯如下:

  • 如果至少有一人不同意,則合併請求被拒絕。
  • 如果沒有人不同意,且至少有兩人同意,則合併請求被批准。
  • 其他情況下,合併請求處於待定狀態。
from enum import Enum

class MergeRequestStatus(Enum):
    APPROVED = "approved"
    REJECTED = "rejected"
    PENDING = "pending"

class MergeRequest:
    def __init__(self):
        self._context = {
            "upvotes": set(),
            "downvotes": set(),
        }

    @property
    def status(self):
        # 根據 upvotes 和 downvotes 計算狀態
        if self._context["downvotes"]:
            return MergeRequestStatus.REJECTED
        elif len(self._context["upvotes"]) >= 2:
            return MergeRequestStatus.APPROVED
        else:
            return MergeRequestStatus.PENDING

MergeRequest 進行單元測試

import unittest

class TestMergeRequest(unittest.TestCase):
    def test_status_rejected(self):
        merge_request = MergeRequest()
        merge_request._context["downvotes"].add("user1")
        self.assertEqual(merge_request.status, MergeRequestStatus.REJECTED)

    def test_status_approved(self):
        merge_request = MergeRequest()
        merge_request._context["upvotes"].add("user1")
        merge_request._context["upvotes"].add("user2")
        self.assertEqual(merge_request.status, MergeRequestStatus.APPROVED)

    def test_status_pending(self):
        merge_request = MergeRequest()
        merge_request._context["upvotes"].add("user1")
        self.assertEqual(merge_request.status, MergeRequestStatus.PENDING)

內容解密:

  1. TestMergeRequest 類別:這個類別繼承自 unittest.TestCase,是用來組織針對 MergeRequest 類別的單元測試。
  2. test_status_rejected 方法:此方法測試當有使用者投下不同意票時,合併請求的狀態是否正確被標記為「已拒絕」。
  3. test_status_approved 方法:此方法檢查當至少兩名使用者投同意票且無不同意票時,狀態是否被正確標記為「已批准」。
  4. test_status_pending 方法:此方法驗證當只有一票同意且無不同意票時,合併請求是否處於「待定」狀態。

這些測試案例確保了 MergeRequeststatus 屬性根據不同的投票情況傳回正確的狀態。透過這些單元測試,我們能夠更有信心地確保程式碼邏輯的正確性。

使用unittest進行單元測試

單元測試是軟體開發中不可或缺的一部分,Python的unittest模組提供了一套豐富的API來撰寫各種測試條件。由於它是Python標準函式庫的一部分,因此非常通用且方便。

單元測試的基本概念

unittest模組的設計理念源自JUnit(Java),而JUnit又根據Smalltalk的單元測試概念。因此,unittest是物件導向的,測試透過類別來撰寫,檢查條件則透過方法來驗證。通常,我們會將相關的測試按照場景分組到類別中。

撰寫單元測試

要開始撰寫單元測試,首先需要建立一個繼承自unittest.TestCase的測試類別,並在其中定義要測試的條件。這些方法必須以test_開頭,並且可以內部使用任何從unittest.TestCase繼承而來的方法來檢查條件是否成立。

範例:測試合併請求狀態

class TestMergeRequestStatus(unittest.TestCase):
    def test_simple_rejected(self):
        merge_request = MergeRequest()
        merge_request.downvote("maintainer")
        self.assertEqual(merge_request.status, MergeRequestStatus.REJECTED)

    def test_just_created_is_pending(self):
        self.assertEqual(MergeRequest().status, MergeRequestStatus.PENDING)

    def test_pending_awaiting_review(self):
        merge_request = MergeRequest()
        merge_request.upvote("core-dev")
        self.assertEqual(merge_request.status, MergeRequestStatus.PENDING)

    def test_approved(self):
        merge_request = MergeRequest()
        merge_request.upvote("dev1")
        merge_request.upvote("dev2")
        self.assertEqual(merge_request.status, MergeRequestStatus.APPROVED)

測試API

unittest提供了多個有用的方法來進行比較,最常見的是assertEqual(<actual>, <expected>[, message]),可以用來比較操作結果與預期值,並且可以選擇性地提供錯誤訊息。

範例:檢查例外是否被丟擲

def test_cannot_upvote_on_closed_merge_request(self):
    self.merge_request.close()
    with self.assertRaises(MergeRequestException):
        self.merge_request.upvote("dev1")

def test_cannot_downvote_on_closed_merge_request(self):
    self.merge_request.close()
    with self.assertRaisesRegex(MergeRequestException, "can't vote on a closed merge request"):
        self.merge_request.downvote("dev1")

重構程式碼以支援測試

當我們擴充邏輯以允許使用者關閉合併請求,並確保在請求關閉後不能進行投票時,我們需要在程式碼中加入相應的檢查。

class MergeRequest:
    def __init__(self):
        self._context = {
            "upvotes": set(),
            "downvotes": set(),
        }
        self._status = MergeRequestStatus.OPEN

    def close(self):
        self._status = MergeRequestStatus.CLOSED

    def _cannot_vote_if_closed(self):
        if self._status == MergeRequestStatus.CLOSED:
            raise MergeRequestException("can't vote on a closed merge request")

    def upvote(self, by_user):
        self._cannot_vote_if_closed()
        self._context["downvotes"].discard(by_user)
        self._context["upvotes"].add(by_user)

    def downvote(self, by_user):
        self._cannot_vote_if_closed()
        self._context["upvotes"].discard(by_user)
        self._context["downvotes"].add(by_user)

重要注意事項

  • 使用assertEqual時,引數順序為<actual><expected>,這是一種常見的慣例,但並非強制。
  • assertRaisesassertRaisesRegex用於檢查是否拋出了特定的例外。
  • 當擴充程式碼邏輯時,應同步更新或新增相應的測試案例。

內容解密:

  1. unittest模組的使用:介紹瞭如何使用unittest模組來撰寫單元測試,包括建立測試類別和方法。
  2. 測試API的使用:展示瞭如何使用assertEqualassertRaisesassertRaisesRegex等方法來驗證程式碼行為。
  3. 重構程式碼以支援測試:說明瞭如何修改程式碼以支援新的功能(如關閉合併請求),並確保相關的測試案例得到更新。
  4. 最佳實踐:強調了保持測試程式碼與實作程式碼同步更新的重要性,以及遵循常見的編碼和測試慣例。