本文 AI 產出,尚未審核

Python 單元測試與除錯:unittestpytest 完全攻略


簡介

在軟體開發的生命週期中,測試除錯是不可或缺的環節。即使是最簡單的腳本,隨著功能增長、需求變動,若缺乏可靠的測試基礎,往往會在維護階段付出巨大的時間成本。Python 內建的 unittest 模組提供了類似 JUnit 的測試框架,而社群熱烈推廣的 pytest 則以簡潔、彈性和強大的插件機制著稱。掌握這兩套工具,能讓你在開發過程即時捕捉 bug、確保程式行為符合預期,進而提升程式碼品質與開發效率。

本篇文章將以 繁體中文(台灣) 為主,從核心概念、實作範例、常見陷阱到最佳實踐,完整說明如何在 Python 專案中使用 unittestpytest 進行單元測試與除錯,適合 初學者到中階開發者 參考。


核心概念

1. 為什麼需要單元測試?

  • 早期發現錯誤:測試在開發階段即執行,能在功能實作完成前就捕捉到邏輯缺陷。
  • 防止回歸:當程式碼修改或重構時,既有測試會自動驗證舊功能是否仍正常。
  • 文件化行為:測試本身即是程式行為的「活文件」,讓新加入的團隊成員快速了解 API 用法。

2. unittest 基礎

unittest 為 Python 標準庫的一部分,遵循 xUnit 架構。主要概念如下:

元素 說明
TestCase 繼承自 unittest.TestCase 的類別,裡面放置測試方法。
assert 系列 斷言函式,如 assertEqual, assertTrue, assertRaises 等,用來驗證結果。
Test Suite 可將多個 TestCase 組合成測試套件。
Test Runner 執行測試並產生報告,預設為 unittest.TextTestRunner

範例 1:最簡單的 unittest 測試

# test_calc.py
import unittest

def add(a, b):
    """簡單的加法函式"""
    return a + b

class TestCalc(unittest.TestCase):
    def test_add_positive(self):
        """測試正數相加"""
        self.assertEqual(add(2, 3), 5)

    def test_add_negative(self):
        """測試負數相加"""
        self.assertEqual(add(-1, -4), -5)

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

說明

  • 每個測試方法必須以 test_ 開頭,否則 unittest 不會自動偵測。
  • unittest.main() 會搜尋同一檔案的 TestCase,執行所有測試。

3. pytest 的魅力

pytest 是第三方測試框架,提供以下優勢:

  1. 語法更簡潔:不需要繼承 TestCase,直接寫普通函式即可。
  2. 強大的斷言重寫:失敗時會顯示左/右值的實際內容,方便除錯。
  3. 豐富的插件生態:如 pytest-cov(覆蓋率)、pytest-mock(mock)等。
  4. 自動探索測試:預設會搜尋 test_*.py*_test.py 檔案。

範例 2:使用 pytest 測試同樣的加法函式

# test_calc_pytest.py
def add(a, b):
    return a + b

def test_add_positive():
    assert add(2, 3) == 5

def test_add_negative():
    assert add(-1, -4) == -5

def test_add_type_error():
    # 使用 pytest.raises 斷言例外
    import pytest
    with pytest.raises(TypeError):
        add(1, "a")

說明

  • 只要檔名符合規則,pytest 會自動發現 test_ 開頭的函式。
  • pytest.raises 以 context manager 形式捕捉例外,語意更直觀。

4. 測試前置與後置:setUp / tearDown vs fixture

框架 前置 (setup) 後置 (teardown)
unittest setUp(self)setUpClass(cls) tearDown(self)tearDownClass(cls)
pytest @pytest.fixture(scope 可調) fixture 結束時自動呼叫 yield 後的程式碼

範例 3:unittestsetUp / tearDown

# test_db_unittest.py
import unittest
import sqlite3

class TestDatabase(unittest.TestCase):
    def setUp(self):
        """每個測試前建立暫存資料庫"""
        self.conn = sqlite3.connect(":memory:")
        self.cur = self.conn.cursor()
        self.cur.execute("CREATE TABLE user(id INTEGER PRIMARY KEY, name TEXT)")

    def tearDown(self):
        """測試結束後關閉連線"""
        self.conn.close()

    def test_insert(self):
        self.cur.execute("INSERT INTO user(name) VALUES (?)", ("Alice",))
        self.conn.commit()
        self.cur.execute("SELECT COUNT(*) FROM user")
        count = self.cur.fetchone()[0]
        self.assertEqual(count, 1)

範例 4:pytest 的 fixture

# test_db_pytest.py
import sqlite3
import pytest

@pytest.fixture
def db():
    """提供一個 in-memory SQLite 資料庫的 fixture"""
    conn = sqlite3.connect(":memory:")
    cur = conn.cursor()
    cur.execute("CREATE TABLE user(id INTEGER PRIMARY KEY, name TEXT)")
    yield cur          # 測試函式會收到 cur 物件
    conn.close()      # 測試結束後自動關閉

def test_insert(db):
    db.execute("INSERT INTO user(name) VALUES (?)", ("Bob",))
    db.connection.commit()
    db.execute("SELECT COUNT(*) FROM user")
    count = db.fetchone()[0]
    assert count == 1

要點

  • pytest 的 fixture 可以設定 scope="module"session 等,讓多個測試共享同一資源。
  • 使用 yield 前的程式碼相當於 setUpyield 後的程式碼相當於 tearDown

5. 參數化測試(Parametrized Test)

對於同一個測試邏輯,只是輸入/期望值不同的情況,參數化能大幅減少重複程式碼。

範例 5:pytest@pytest.mark.parametrize

import pytest

def is_even(n):
    return n % 2 == 0

@pytest.mark.parametrize(
    "input,expected",
    [
        (2, True),
        (3, False),
        (0, True),
        (-4, True),
        (7, False),
    ],
)
def test_is_even(input, expected):
    assert is_even(input) == expected

範例 6:unittest 的子測試(subTest)

import unittest

def is_even(n):
    return n % 2 == 0

class TestEven(unittest.TestCase):
    def test_is_even(self):
        cases = [
            (2, True),
            (3, False),
            (0, True),
            (-4, True),
            (7, False),
        ]
        for inp, exp in cases:
            with self.subTest(inp=inp, exp=exp):
                self.assertEqual(is_even(inp), exp)

常見陷阱與最佳實踐

陷阱 說明 解法 / 最佳實踐
測試相依 測試之間共享全域狀態,導致執行順序影響結果。 使用 fixturepytest)或 setUp/tearDownunittest)確保每個測試都有乾淨的環境。
測試過於龐大 單一測試檔案包含太多測試,閱讀與除錯困難。 按功能模組拆分測試檔案,檔名以 test_ 為前綴。
忽略例外測試 只測試「正常」路徑,錯誤路徑未被覆蓋。 使用 assertRaisesunittest)或 pytest.raisespytest)驗證例外行為。
斷言訊息不足 失敗時只能看到「False is not true」之類的訊息。 unittest 中使用 self.assertEqual(a, b, msg="說明訊息")pytest 自動顯示詳盡的差異。
測試速度慢 依賴外部服務(資料庫、API)導致測試耗時。 使用 mockunittest.mockpytest-mock)模擬外部資源,或將慢測試標記為 slow,在 CI 中分層執行。
未使用測試覆蓋率 只寫測試但不知覆蓋率,可能遺漏關鍵路徑。 加入 pytest-cov,在 CI 中生成 coverage 報告,目標 80% 以上。

最佳實踐總結

  1. 小而專注:每個測試只驗證單一行為。
  2. 命名規則:測試函式/類別名稱以 test_ 開頭,讓測試框架自動偵測。
  3. 保持獨立:不依賴執行順序,使用 fixture 清理資源。
  4. 使用 CI:在 GitHub Actions、GitLab CI 等持續整合環境中執行測試,確保每次合併前都通過。
  5. 持續測試:寫測試不應是一次性任務,功能新增或 bug 修正時同步補齊測例。

實際應用場景

1. Web API 開發(Flask / FastAPI)

  • 測試路由回傳:使用 pytest 搭配 TestClient(FastAPI)或 FlaskClient,驗證 HTTP 狀態碼、JSON 結構。
  • 模擬資料庫:透過 pytest.fixture 建立測試用的 SQLite 記憶體資料庫,確保每次測試都有乾淨的資料。
# test_user_api.py
import pytest
from fastapi.testclient import TestClient
from app.main import app  # 假設 FastAPI 應用在 app/main.py

client = TestClient(app)

def test_create_user():
    response = client.post("/users/", json={"name": "Alice"})
    assert response.status_code == 201
    assert response.json()["name"] == "Alice"

2. 資料處理與演算法

  • 參數化測試:對同一演算法的不同輸入組合使用 @pytest.mark.parametrize,快速驗證邊界條件。
  • 效能測試:使用 pytest-benchmark 記錄執行時間,確保優化不會破壞正確性。

3. 套件發佈(Library)

  • 兼容性測試:在 tox 配合 pytest 下,同時於多個 Python 版本(3.8~3.12)執行測試。
  • 自動生成文件:利用 sphinx.ext.autodoc + pytest 的 doctest,確保文件範例可直接執行且正確。

總結

  • 測試是開發的保險:不論是 unittest 的傳統 xUnit 風格,還是 pytest 的輕量化寫法,核心目的都是讓程式碼在變更時保持穩定。
  • 選擇適合的工具:若專案已經使用標準庫且不想額外安裝套件,unittest 完全足夠;若想要更簡潔的語法、強大的插件與斷言訊息,pytest 是更理想的選擇。
  • 養成測試習慣:從一開始就為每個功能撰寫測試、使用 fixture 管理測試資源、在 CI 中執行測試與 coverage,才能真正體驗「測試驅動開發」帶來的效益。

最後提醒:測試不只是找錯,更是一種 程式設計的思考方式。在寫測試的過程中,你會重新審視 API 設計、錯誤處理與邊界條件,讓程式碼自然變得更乾淨、更易維護。祝你在 Python 的測試與除錯旅程中,玩得開心、寫得安心!