本文 AI 產出,尚未審核
Python 單元測試與除錯:unittest 與 pytest 完全攻略
簡介
在軟體開發的生命週期中,測試與除錯是不可或缺的環節。即使是最簡單的腳本,隨著功能增長、需求變動,若缺乏可靠的測試基礎,往往會在維護階段付出巨大的時間成本。Python 內建的 unittest 模組提供了類似 JUnit 的測試框架,而社群熱烈推廣的 pytest 則以簡潔、彈性和強大的插件機制著稱。掌握這兩套工具,能讓你在開發過程即時捕捉 bug、確保程式行為符合預期,進而提升程式碼品質與開發效率。
本篇文章將以 繁體中文(台灣) 為主,從核心概念、實作範例、常見陷阱到最佳實踐,完整說明如何在 Python 專案中使用 unittest 與 pytest 進行單元測試與除錯,適合 初學者到中階開發者 參考。
核心概念
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 是第三方測試框架,提供以下優勢:
- 語法更簡潔:不需要繼承
TestCase,直接寫普通函式即可。 - 強大的斷言重寫:失敗時會顯示左/右值的實際內容,方便除錯。
- 豐富的插件生態:如
pytest-cov(覆蓋率)、pytest-mock(mock)等。 - 自動探索測試:預設會搜尋
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:unittest 的 setUp / 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前的程式碼相當於setUp,yield後的程式碼相當於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)
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解法 / 最佳實踐 |
|---|---|---|
| 測試相依 | 測試之間共享全域狀態,導致執行順序影響結果。 | 使用 fixture(pytest)或 setUp/tearDown(unittest)確保每個測試都有乾淨的環境。 |
| 測試過於龐大 | 單一測試檔案包含太多測試,閱讀與除錯困難。 | 按功能模組拆分測試檔案,檔名以 test_ 為前綴。 |
| 忽略例外測試 | 只測試「正常」路徑,錯誤路徑未被覆蓋。 | 使用 assertRaises(unittest)或 pytest.raises(pytest)驗證例外行為。 |
| 斷言訊息不足 | 失敗時只能看到「False is not true」之類的訊息。 | 在 unittest 中使用 self.assertEqual(a, b, msg="說明訊息"),pytest 自動顯示詳盡的差異。 |
| 測試速度慢 | 依賴外部服務(資料庫、API)導致測試耗時。 | 使用 mock(unittest.mock 或 pytest-mock)模擬外部資源,或將慢測試標記為 slow,在 CI 中分層執行。 |
| 未使用測試覆蓋率 | 只寫測試但不知覆蓋率,可能遺漏關鍵路徑。 | 加入 pytest-cov,在 CI 中生成 coverage 報告,目標 80% 以上。 |
最佳實踐總結
- 小而專注:每個測試只驗證單一行為。
- 命名規則:測試函式/類別名稱以
test_開頭,讓測試框架自動偵測。 - 保持獨立:不依賴執行順序,使用 fixture 清理資源。
- 使用 CI:在 GitHub Actions、GitLab CI 等持續整合環境中執行測試,確保每次合併前都通過。
- 持續測試:寫測試不應是一次性任務,功能新增或 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 的測試與除錯旅程中,玩得開心、寫得安心!