返回文章列表

Python 測試策略與實踐

本文探討了 Python 程式碼的測試策略,涵蓋了測試驅動開發(TDD)的優缺點、提升程式碼可測試性的方法,以及如何使用 Pytest 框架編寫和組織測試。文章重點介紹了例項方法、類別方法和靜態方法的區別,以及單元測試、整合測試和功能測試的應用場景。同時,也示範瞭如何使用 Pytest fixtures

軟體測試 Python

在 Python 開發中,測試是確保程式碼品質的關鍵環節。從單元測試到功能測試,不同的測試型別有著各自的應用場景和優缺點。本文將探討如何在 Python 中有效地運用各種測試策略,並結合 Pytest 框架和 fixtures 機制,構建更 robust 的程式碼。同時,我們也將探討例項方法、類別方法和靜態方法的特性,以及如何針對這些方法設計相應的測試案例。透過一個實際的 Python 版本號格式化功能的測試範例,展示如何從功能測試的不足之處,逐步引導至單元測試的重要性,並結合整合測試,最終形成一套完整的測試策略。此外,我們還將探討如何使用 Pytest fixtures 來最佳化測試結構,提高測試程式碼的可維護性和重用性。

何時撰寫測試?

軟體工程中有許多關於何時撰寫測試的強烈意見。測試驅動開發(TDD)主張在編寫程式碼之前先撰寫測試。這種方法有其支援者,因為它可以使開發過程更令人滿意,先透過測試再實作功能。

TDD 的優點

  • 有助於在深入實作細節之前思考程式碼的含義。
  • 可以提高開發效率,避免返工。

非 TDD 的情況

  • 有時開發者希望快速實作功能,然後再進行完善。
  • 在某些情況下,如一次性程式或未經測試的複雜現有程式碼函式庫,撰寫測試的成本效益比可能不高。

提升可測試性的格式化功能設計

在前一章節中,我們開發了一個簡單的命令列指令碼,用於列印各個感測器的數值。這個實作涉及手動呼叫多個函式,並在 main() 函式中獨立處理這些函式的格式化輸出。雖然這作為一個概念驗證是可行的,但這種方法並不適合構建大型系統。對於每個感測器值,我們需要能夠提取原始值以進行量化分析,同時也需要格式化值以便向終端使用者展示。

為何需要分離資料擷取與格式化

將資料擷取和格式化邏輯分離開來有幾個重要的原因:

  1. 職責分離:我們希望能夠獨立測試資料擷取的正確性和格式化邏輯的正確性,而不需要同時進行兩者的測試。如果資料擷取和格式化功能緊密耦合,我們就無法檢查不同值的格式化是否正確,而只能檢查當前機器上執行測試時的特定值,這可能會因執行環境的不同而有很大差異。

將函式擴充套件為類別以提升可測試性

為了實作上述目標,我們將原有的函式擴充套件為一個 Python 類別,這個類別不僅提供由感測器檢索的原始值,還包含一個輔助函式來適當地格式化這些值(列表 2-7)。這種方法使得在像命令列指令碼這樣的導向使用者的環境中顯示感測器的當前值變得更加容易,因為在周圍的指令碼中不再需要對個別的感測器值進行特殊處理。

新的溫度感測器實作範例

class Temperature(Sensor[Optional[float]]):
    title = "Ambient Temperature"

    def value(self) -> Optional[float]:
        try:
            # 連線到 GPIO 引腳 4 上的 DHT22 感測器
            from adafruit_dht import DHT22
            from board import D4
        except (ImportError, NotImplementedError):
            # 如果沒有 DHT 函式庫會導致 ImportError
            # 在未知平台上執行會導致 NotImplementedError
            return None
        try:
            return DHT22(D4).temperature
        except RuntimeError:
            return None

    @staticmethod
    def celsius_to_fahrenheit(value: float) -> float:
        return value * 9 / 5 + 32

    @classmethod
    def format(cls, value: Optional[float]) -> str:
        if value is None:
            return "Unknown"
        else:
            return "{:.1f}C ({:.1f}F)".format(value, cls.celsius_to_fahrenheit(value))

    def __str__(self) -> str:
        return self.format(self.value())

#### 內容解密:

  1. Temperature 類別設計:將溫度感測器的相關功能封裝在 Temperature 類別中,提供了更好的程式碼組織結構。
  2. value() 方法:負責從感測器中擷取溫度值,並處理可能的異常情況,如沒有安裝必要的函式庫或是在不支援的平台上執行。
  3. celsius_to_fahrenheit() 靜態方法:提供攝氏溫度到華氏溫度的轉換功能,使用 @staticmethod 修飾符表示這是一個靜態方法,不需要例項即可呼叫。
  4. format() 類別方法:根據提供的溫度值進行格式化輸出,如果值為 None,則傳回 “Unknown”。使用 @classmethod 修飾符,表示這是一個類別方法,第一個引數為類別本身。
  5. __str__() 方法:使得 Temperature 例項可以直接被轉換為字串,方便在不同情境下使用。

類別方法、例項方法與靜態方法的區別

  • 例項方法:需要在類別的例項上呼叫,第一個引數通常是 self,代表例項本身,可以存取例項的屬性和方法。
  • 類別方法:使用 @classmethod 修飾,第一個引數是 cls,代表類別本身,可以存取類別的屬性和方法。
  • 靜態方法:使用 @staticmethod 修飾,不需要類別或例項的參考,可以視為是一個普通的函式,但被定義在類別的名稱空間中。

#### 內容解密:

  • 本段主要闡述了在 Python 中定義在類別上的不同型別的方法,包括例項方法、類別方法和靜態方法。
  • 詳細解釋了這些方法之間的區別和使用場景,有助於讀者更好地理解如何在不同的情況下選擇合適的方法型別。

Python 中的方法型別與測試策略

在 Python 中,類別(class)的方法可以分為三種型別:例項方法(instance method)、類別方法(class method)與靜態方法(static method)。瞭解這些方法之間的差異對於撰寫清晰且可維護的程式碼至關重要。

例項方法、類別方法與靜態方法

  1. 例項方法:這是最常見的方法型別,第一個引數通常命名為 self,它指向類別的例項。例項方法可以存取和修改例項的屬性,也可以呼叫其他例項方法。

  2. 類別方法:類別方法的第一個引數是類別本身,通常命名為 cls。它可以用來定義替代建構函式或一些與類別相關但不依賴於特定例項的工具函式。類別方法可以透過類別或例項呼叫。

    class Temperature:
        @classmethod
        def format(cls, value):
            # 實作格式化邏輯
            pass
    

    內容解密:

    • @classmethod 裝飾器用於定義類別方法。
    • cls 引數代表類別本身,可以用來呼叫其他類別方法或存取類別屬性。
    • 類別方法可以在不需要建立例項的情況下被呼叫。
  3. 靜態方法:靜態方法沒有隱含的第一個引數,它們本質上是被定義在類別名稱空間中的普通函式。靜態方法可以用於將相關功能組織在類別中,但它們不依賴於類別或例項的狀態。

    class Temperature:
        @staticmethod
        def celsius_to_fahrenheit(celsius):
            return (celsius * 9/5) + 32
    

    內容解密:

    • @staticmethod 裝飾器用於定義靜態方法。
    • 靜態方法沒有 selfcls 引數,因此無法直接存取或修改例項或類別的狀態。
    • 靜態方法可以被視為與類別相關的工具函式。

測試策略

在軟體開發中,測試是確保程式碼品質的重要步驟。測試可以分為多種型別,包括單元測試(unit testing)、整合測試(integration testing)與功能測試(functional testing)。

單元測試

單元測試關注於驗證程式碼中的最小單元(如函式或方法)是否按照預期工作。對於上述的 Temperature 類別,我們可以寫單元測試來驗證 format 方法和 celsius_to_fahrenheit 方法的正確性。

import unittest
from your_module import Temperature  # 假設Temperature類別定義在your_module.py中

class TestTemperature(unittest.TestCase):
    def test_format(self):
        # 測試format方法的邏輯
        pass

    def test_celsius_to_fahrenheit(self):
        self.assertEqual(Temperature.celsius_to_fahrenheit(0), 32)
        self.assertEqual(Temperature.celsius_to_fahrenheit(100), 212)

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

內容解密:

  • 使用 unittest 框架來編寫和執行測試。
  • TestTemperature 類別繼承自 unittest.TestCase,其中每個以 test_ 開頭的方法都是一個測試案例。
  • test_celsius_to_fahrenheit 方法中,使用 assertEqual 方法來驗證 celsius_to_fahrenheit 方法的輸出是否符合預期。

功能測試

功能測試則關注於驗證整個應用程式或系統是否按照預期工作。例如,對於一個命令列工具,功能測試可能會模擬使用者輸入並驗證輸出的正確性。

from click.testing import CliRunner
from your_cli_module import cli  # 假設命令列工具的入口點定義在your_cli_module.py中

def test_cli_output():
    runner = CliRunner()
    result = runner.invoke(cli, ['show-sensors'])
    # 驗證result.output的內容是否符合預期
    pass

內容解密:

  • 使用 click.testing.CliRunner 來模擬執行命令列工具。
  • runner.invoke 方法用於呼叫命令列工具並捕捉其輸出。
  • 需要根據實際輸出來編寫斷言,以驗證工具的功能正確性。

測試策略與實踐:從功能測試到單元測試

在開發過程中,測試是確保程式碼品質和功能正確性的關鍵步驟。隨著程式碼的複雜度增加,測試的策略和實踐也需要相應調整。本文將探討從功能測試到單元測試的測試策略,並介紹如何有效地撰寫和組織測試。

功能測試的侷限性

功能測試(Functional Test)是檢查程式整體功能是否正確的一種測試方法。它通常模擬使用者的操作,驗證程式的輸出是否符合預期。然而,當功能測試失敗時,開發者往往難以直接定位問題所在,因為它涵蓋了程式的多個組成部分。

舉例來說,在一個 CLI(Command-Line Interface)程式中,功能測試可能會檢查輸出是否包含預期的資訊。但是,如果輸出不正確,開發者需要進一步除錯才能確定問題是出在資料擷取、格式化還是輸出邏輯上。

單元測試的重要性

單元測試(Unit Test)則是針對程式碼中的最小可測試單元進行驗證,通常是一個函式或方法。單元測試的好處在於,它能夠精確地定位問題所在,並且執行速度快,需要的測試設定也較少。

在上述例子中,可以針對 PythonVersion 類別的各個方法撰寫單元測試,分別檢查其值的擷取、格式化邏輯等。這樣一來,當測試失敗時,開發者能夠立即知道哪個具體的方法出了問題。

整合測試的中庸之道

整合測試(Integration Test)則介於功能測試和單元測試之間,它檢查的是多個相關函式或模組之間的互動是否正確。整合測試能夠確保程式碼的某個邏輯元件整體上運作正常。

例如,對於 PythonVersion 類別的 __str__ 方法,可以撰寫整合測試來檢查它是否正確呼叫了值的擷取和格式化方法,並傳回了預期的字串表示。

撰寫有效的測試

要撰寫有效的測試,需要考慮以下幾點:

  1. 明確測試目標:每個測試應該有明確的目標,檢查特定的功能或邏輯。
  2. 保持測試獨立性:不同的測試應該相互獨立,避免一個測試的結果影響其他測試。
  3. 使用適當的測試型別:根據被測試程式碼的特性,選擇適合的測試型別(單元測試、整合測試或功能測試)。
  4. 及時更新測試:當程式碼變更時,相關的測試也應該被更新或新增,以確保新的邏輯被正確驗證。

Pytest Fixtures 的應用與測試結構最佳化

在進行單元測試時,除了最基本的函式測試外,通常還需要針對不同的測試案例編寫多個測試函式。這些測試函式往往需要分享某些設定程式碼,例如例項化類別或匯入函式。為了簡化測試程式碼並提高可維護性,Pytest 提供了「fixtures」的機制,允許開發者靈活地選擇和重用不同的支援程式碼。

測試結構的最佳實踐

一個良好的測試結構是將相關的測試函式組織在一個類別中,並將分享的設定程式碼以 fixtures 的形式定義在該類別中。同時,將更通用的 fixtures 保留在類別外部,以便其他測試可以根據需要重用這些 fixtures。這種方法被稱為「Subject Under Test」(SUT)風格,根據測試的物件不同,也可以使用 FUT(Function Under Test)、MUT(Method Under Test)等命名方式。

在這種結構下,每個測試類別通常包含一個名為 subject()MUT() 的 fixture,該 fixture 傳回待測的函式或方法。這樣做的好處是,個別測試函式可以專注於測試特定的功能,而無需關注如何取得待測物件。

例項:測試 Python 版本號格式化功能

首先,我們建立一個測試類別 TestPythonVersionFormatter,用於測試 Python 版本號感測器的格式化功能。該類別包含一個 subject fixture,傳回 PythonVersion 類別的 format 方法。

# Listing 2-8. Initial version of test_pythonversion.py
from collections import namedtuple
import pytest
from sensors import PythonVersion

@pytest.fixture
def version():
    return namedtuple("sys_versioninfo", ("major", "minor", "micro", "releaselevel", "serial"))

@pytest.fixture
def sensor():
    return PythonVersion()

class TestPythonVersionFormatter:
    @pytest.fixture
    def subject(self, sensor):
        return sensor.format

    def test_format_py38(self, subject, version):
        py38 = version(3, 8, 0, "final", 0)
        assert subject(py38) == "3.8"

    def test_format_large_version(self, subject, version):
        large = version(255, 128, 0, "final", 0)
        assert subject(large) == "255.128"

    def test_alpha_of_minor_is_marked(self, subject, version):
        py39 = version(3, 9, 0, "alpha", 1)
        assert subject(py39) == "3.9.0a1"

    def test_alpha_of_micro_is_unmarked(self, subject, version):
        py39 = version(3, 9, 1, "alpha", 1)
        assert subject(py39) == "3.9"

內容解密:

  1. version fixture:建立一個類別似於 sys.version_info 的 namedtuple 結構,以便在測試中模擬不同的 Python 版本號。
  2. sensor fixture:例項化 PythonVersion 類別,提供待測的版本號感測器物件。
  3. subject fixture:傳回 sensor 物件的 format 方法,即待測的格式化功能。
  4. test_format_py38test_format_large_versiontest_alpha_of_minor_is_markedtest_alpha_of_micro_is_unmarked:分別測試不同的 Python 版本號格式化場景,驗證 subject 方法的輸出是否符合預期。

擴充套件測試案例

為了進一步驗證格式化功能的正確性,我們新增兩個測試案例,檢查預發布版本的處理邏輯。

def test_prerelease_of_minor_is_marked(self, subject, version):
    py39 = version(3, 9, 0, "alpha", 1)
    assert subject(py39) == "3.9.0a1"

def test_prerelease_of_micro_is_unmarked(self, subject, version):
    py39 = version(3, 9, 1, "alpha", 1)
    assert subject(py39) == "3.9"

內容解密:

  1. test_prerelease_of_minor_is_marked:驗證當次版本號為 0 且處於預發布階段時,格式化輸出應包含完整的版本資訊。
  2. test_prerelease_of_micro_is_unmarked:驗證當次版本號不為 0 且處於預發布階段時,格式化輸出應忽略次版本號。

透過執行這些測試,我們可以觀察到其中一個測試失敗,這促使我們改進 PythonVersion 類別的 format 方法,以正確處理預發布版本的格式化邏輯。

Pytest 的錯誤資訊與除錯支援

當測試失敗時,Pytest 自動提供豐富的上下文資訊,包括失敗測試的詳細資訊、相關的程式碼片段和斷言錯誤。這使得除錯變得更加容易,有助於開發者快速定位問題並修復程式碼。

________ TestPythonVersionFormatter.test_alpha_of_minor_is_marked ________

內容解密:

  • Pytest 在測試失敗時提供詳細的錯誤資訊,包括失敗的測試方法名稱和相關的上下文。
  • 這有助於開發者快速理解失敗的原因並進行相應的調整。