回文章列表
Python13 分鐘閱讀2026年9月3日

pytest 入門:幫銷售腳本寫第一個測試,把手動測不到的情況抓出來

#Python#pytest#單元測試#測試#進階

文章目錄

折扣函式改了一版:滿 10 件打 85 折、滿 5 件打 95 折。改完,手動丟了三筆資料進去看結果——3 件、7 件、12 件,三筆算出來都對,直接推上線。

上線第二天客服來問:「為什麼有一張『買 0 件』的訂單,金額算出來是 0 元,但系統還是把它當正常訂單處理了?」追下去才發現,那張訂單本來就不該存在——是另一個模組傳資料過來時,數量欄位沒填、預設值是 0。折扣函式沒有做任何檢查,0 乘上任何單價都是 0,程式沒有報錯,只是安安靜靜地算出一個錯誤的答案,讓一張根本不該成立的訂單混進了報表。

問題不是這次改壞了什麼,是手動測試永遠只測得到你想得到的情況。你會很自然地丟 3、7、12 這種「看起來正常」的數字,但不會主動想到 0、負數、空清單這些邊界——偏偏這些邊界正是最容易被忽略、也最容易出包的地方。而且下次改這個函式的時候,你還得重新手動測一次那三筆數字,才敢確定自己沒改壞。

這篇要做的事,是把「手動跑一次看有沒有壞」換成「寫測試,讓程式自己確認有沒有壞」——用 pytest。

為什麼是 pytest

Python 內建的測試工具是 unittest,但寫起來要繼承一個 class、每個測試方法都要用 self.assertEqual(...) 這類方法。pytest 是另一套更常用的第三方測試框架,寫起來就是普通函式配上 Python 原生的 assert,不用繼承任何東西,門檻低很多。這篇就直接用 pytest。

安裝與檔案結構

先裝套件(還不熟悉 pip 和虛擬環境的話,可以先看《Python 安裝套件與虛擬環境入門》):

pip install pytest

裝完確認版本:

pytest --version

pytest 認測試靠的是檔名與函式名稱的慣例,不用另外設定:

  • 測試檔案要叫 test_*.py 或 *_test.py(例如 test_sales.py)
  • 測試函式要叫 test_* 開頭(例如 def test_calc_discount_...)
  • 通常放在跟原始程式同一層,或另外開一個 tests/ 資料夾,這篇為了簡單,兩個檔案放同一層

專案結構長這樣:

sales_project/
├── sales.py
└── test_sales.py

在 sales_project/ 這一層執行:

pytest        # 跑完只印簡短結果
pytest -v     # -v 是 verbose,逐一列出每個測試的名稱與結果,平常開發建議用這個

第一個測試:只要 assert 就好

先寫 sales.py,放一個折扣計算函式:

def calc_discount(quantity: int, unit_price: float) -> float:
    """計算折扣後的訂單金額。數量 10 件以上打 85 折、5 件以上打 95 折,其餘原價。"""
    subtotal = quantity * unit_price
    if quantity >= 10:
        return round(subtotal * 0.85, 2)
    if quantity >= 5:
        return round(subtotal * 0.95, 2)
    return round(subtotal, 2)

再寫 test_sales.py,測試函式就是「呼叫函式,用 assert 檢查結果對不對」:

from sales import calc_discount


def test_calc_discount_no_discount_below_five():
    assert calc_discount(3, 100) == 300


def test_calc_discount_ten_or_more_gets_85_percent():
    assert calc_discount(10, 100) == 850

沒有繼承、沒有 self,就是把「手動驗算」寫成一個會自動比對的函式。執行 pytest -v:

============================= test session starts =============================
platform win32 -- Python 3.14.3, pytest-9.1.1, pluggy-1.6.0
rootdir: C:\Users\jerry\sales_project
collected 2 items

test_sales.py::test_calc_discount_no_discount_below_five PASSED   [ 50%]
test_sales.py::test_calc_discount_ten_or_more_gets_85_percent PASSED [100%]

============================== 2 passed in 0.02s ==============================

每一行測試後面的 PASSED 就是一個綠燈。以後只要跑 pytest,這兩筆手動驗算過的資料就永遠有人幫你重新算一次,不用再自己動手。

pytest 真正好用的地方在測試失敗的時候。故意把 85 折的係數改錯,從 0.85 改成 0.8:

    if quantity >= 10:
        return round(subtotal * 0.8, 2)   # 打錯成 0.8

再跑一次 pytest -v:

test_sales.py::test_calc_discount_no_discount_below_five PASSED   [ 50%]
test_sales.py::test_calc_discount_ten_or_more_gets_85_percent FAILED [100%]

================================== FAILURES ===================================
_______________ test_calc_discount_ten_or_more_gets_85_percent ________________

    def test_calc_discount_ten_or_more_gets_85_percent():
>       assert calc_discount(10, 100) == 850
E       assert 800.0 == 850
E        +  where 800.0 = calc_discount(10, 100)

test_sales.py:9: AssertionError
=========================== short test summary info ===========================
FAILED test_sales.py::test_calc_discount_ten_or_more_gets_85_percent
========================= 1 failed, 1 passed in 0.04s =========================

這就是 pytest 最值得學的地方:assert calc_discount(10, 100) == 850 這一行,pytest 自動把等號兩邊實際算出來的值印出來——assert 800.0 == 850,下面還補一行 where 800.0 = calc_discount(10, 100),直接告訴你是哪個呼叫算出了這個錯誤的值。你不用自己加 print() 除錯,光看這幾行就知道問題出在哪。把係數改回 0.85 就會恢復全綠。

邊界條件:測那些你不會主動手動測的情況

回到開場的事故。折扣函式現在完全沒防「數量是 0」這種情況,先幫它補上檢查:

def calc_discount(quantity: int, unit_price: float) -> float:
    """計算折扣後的訂單金額。quantity 為 0 或負數會丟出 ValueError。"""
    if quantity <= 0:
        raise ValueError(f"購買數量必須大於 0,收到 {quantity}")
    subtotal = quantity * unit_price
    if quantity >= 10:
        return round(subtotal * 0.85, 2)
    if quantity >= 5:
        return round(subtotal * 0.95, 2)
    return round(subtotal, 2)

現在要測的不是「算出正確的數字」,是「該丟例外的時候有沒有丟」——這正好呼應《Python try/except 入門》那篇:那篇教你怎麼接住例外,這裡則是反過來,確認你的函式有沒有在該丟例外的時候,真的丟出來。pytest 提供 pytest.raises 專門做這件事:

import pytest

from sales import calc_discount


def test_calc_discount_zero_quantity_raises():
    with pytest.raises(ValueError):
        calc_discount(0, 100)


def test_calc_discount_negative_quantity_raises():
    with pytest.raises(ValueError, match="必須大於 0"):
        calc_discount(-3, 100)

with pytest.raises(ValueError): 底下這段程式碼,pytest 會盯著它執行——如果真的丟出 ValueError,測試算通過;如果沒丟出來,測試算失敗,這跟一般的 assert 邏輯是反過來的。第二個測試多加了 match 參數,用正規表示式比對例外訊息的內容,確認丟出來的不只是隨便一個 ValueError,訊息也要對得上。跑起來兩個都過。

這個測試的價值要等到有人「改壞」guard 才看得出來。假設有天有人手滑把 if quantity <= 0 改成 if quantity < 0(少了等於),漏放行了數量 0:

test_sales.py::test_calc_discount_zero_quantity_raises FAILED     [ 50%]
test_sales.py::test_calc_discount_negative_quantity_raises PASSED [100%]

================================== FAILURES ===================================
___________________ test_calc_discount_zero_quantity_raises ___________________

    def test_calc_discount_zero_quantity_raises():
>       with pytest.raises(ValueError):
             ^^^^^^^^^^^^^^^^^^^^^^^^^
E       Failed: DID NOT RAISE ValueError

test_sales.py:7: Failed
=========================== short test summary info ===========================
FAILED test_sales.py::test_calc_discount_zero_quantity_raises - Failed...
========================= 1 failed, 1 passed in 0.04s =========================

DID NOT RAISE ValueError——白話講就是「我在等你丟出 ValueError,結果你什麼都沒丟」。開場那個「買 0 件」的事故,如果當時有這個測試,這行紅字會在你 push 之前就先擋下來,而不是等客服來問。

@pytest.mark.parametrize:一次測多組輸入,不用複製貼上

折扣函式有兩個門檻:5 件、10 件。門檻附近的邊界值最容易寫錯(例如把 >= 手滑成 >),值得逐一測:4 件(門檻前)、5 件(門檻上)、9 件(門檻前)、10 件(門檻上),外加一組不同單價的組合。如果一個一個寫測試函式,會變成五個長得幾乎一樣的函式,只有數字不同。@pytest.mark.parametrize 讓你把「輸入」跟「預期結果」列成一份清單,一次餵給同一個測試函式跑完:

import pytest

from sales import calc_discount


@pytest.mark.parametrize(
    "quantity, unit_price, expected",
    [
        (4, 100, 400),      # 5 件門檻之前,原價
        (5, 100, 475),      # 剛好 5 件,開始打 95 折
        (9, 100, 855),      # 9 件,還是 95 折
        (10, 100, 850),     # 剛好 10 件,變成 85 折
        (20, 50, 850),      # 數量多、單價低,一樣算 85 折
    ],
)
def test_calc_discount_tiers(quantity, unit_price, expected):
    assert calc_discount(quantity, unit_price) == expected

第一個字串參數是欄位名稱,逗號分隔要對應到測試函式的參數;後面的清單每一個 tuple 就是一組完整的輸入加預期值。跑 pytest -v:

test_sales.py::test_calc_discount_tiers[4-100-400] PASSED   [ 20%]
test_sales.py::test_calc_discount_tiers[5-100-475] PASSED   [ 40%]
test_sales.py::test_calc_discount_tiers[9-100-855] PASSED   [ 60%]
test_sales.py::test_calc_discount_tiers[10-100-850] PASSED  [ 80%]
test_sales.py::test_calc_discount_tiers[20-50-850] PASSED   [100%]

============================== 5 passed in 0.02s ==============================

同一個測試函式跑了 5 次,pytest 自動用參數值幫每一次組出獨立的名稱,例如 test_calc_discount_tiers[5-100-475]。如果之後某一組壞了,測試結果會直接指名是哪一組輸入出的問題,不用自己再回頭猜。

fixture:準備測試共用的資料

sales.py 再加一個統計函式,把一批訂單的折扣後金額加總起來:

def total_revenue(orders: list[tuple[int, float]]) -> float:
    """加總一批訂單折扣後的總營收。orders 是 (數量, 單價) 的清單。"""
    return round(sum(calc_discount(q, p) for q, p in orders), 2)

測試這個函式需要先準備一份「訂單清單」當輸入。如果之後還有別的測試也要用同一份資料,每個測試函式裡都各自寫一次清單,資料改一次就要到處改。fixture 就是用來解決這件事的:把「準備測試資料」寫成一個獨立函式,加上 @pytest.fixture,測試函式只要把它的名稱寫進參數,pytest 就會自動呼叫、把回傳值傳進來:

@pytest.fixture
def sample_orders():
    """三筆訂單:(數量, 單價),涵蓋原價、95 折、85 折三種情況"""
    return [(3, 100), (5, 100), (10, 100)]


def test_total_revenue_sums_discounted_orders(sample_orders):
    assert total_revenue(sample_orders) == 1625

sample_orders 這個名字在測試函式的參數列裡出現,pytest 就會去找同名的 fixture 函式,執行它,把回傳的清單傳進來,測試函式裡直接當一般參數用。fixture 目前先知道這個基本用法就夠——它還有像「多個測試檔共用」這類進階設定,之後遇到再學也不遲。

別忘了補一個邊界條件:空清單。

def test_total_revenue_empty_list_is_zero():
    assert total_revenue([]) == 0

沒有訂單,加總結果應該是 0,而不是報錯或回傳 None——這也是「手動測」很容易漏掉的情況,因為你手動驗算時通常不會拿一份空清單去測。

再多一句:先寫測試再寫實作

前面都是「先有函式,再補測試」。有一種做法是反過來,先把測試寫好(這時候函式甚至還不存在,跑起來一定是紅的),再照著測試的期待去寫實作,寫到測試轉綠為止——這個做法叫「測試驅動開發」(TDD)。這篇不深入展開,先知道這個做法存在,等你對寫測試更熟之後再研究。

完整成品:sales.py + test_sales.py

把前面所有片段組起來,這是完整的兩個檔案。

sales.py:

"""銷售資料整理腳本 —— 折扣計算與統計"""


def calc_discount(quantity: int, unit_price: float) -> float:
    """計算折扣後的訂單金額。

    數量 10 件以上打 85 折、5 件以上打 95 折,其餘原價。
    quantity 為 0 或負數會丟出 ValueError(不是合法的訂單)。
    """
    if quantity <= 0:
        raise ValueError(f"購買數量必須大於 0,收到 {quantity}")
    subtotal = quantity * unit_price
    if quantity >= 10:
        return round(subtotal * 0.85, 2)
    if quantity >= 5:
        return round(subtotal * 0.95, 2)
    return round(subtotal, 2)


def total_revenue(orders: list[tuple[int, float]]) -> float:
    """加總一批訂單折扣後的總營收。orders 是 (數量, 單價) 的清單。"""
    return round(sum(calc_discount(q, p) for q, p in orders), 2)

test_sales.py:

import pytest

from sales import calc_discount, total_revenue


def test_calc_discount_no_discount_below_five():
    assert calc_discount(3, 100) == 300


def test_calc_discount_ten_or_more_gets_85_percent():
    assert calc_discount(10, 100) == 850


@pytest.mark.parametrize(
    "quantity, unit_price, expected",
    [
        (4, 100, 400),      # 5 件門檻之前,原價
        (5, 100, 475),      # 剛好 5 件,開始打 95 折
        (9, 100, 855),      # 9 件,還是 95 折
        (10, 100, 850),     # 剛好 10 件,變成 85 折
        (20, 50, 850),      # 數量多、單價低,一樣算 85 折
    ],
)
def test_calc_discount_tiers(quantity, unit_price, expected):
    assert calc_discount(quantity, unit_price) == expected


def test_calc_discount_zero_quantity_raises():
    with pytest.raises(ValueError):
        calc_discount(0, 100)


def test_calc_discount_negative_quantity_raises():
    with pytest.raises(ValueError, match="必須大於 0"):
        calc_discount(-3, 100)


@pytest.fixture
def sample_orders():
    """三筆訂單:(數量, 單價),涵蓋原價、95 折、85 折三種情況"""
    return [(3, 100), (5, 100), (10, 100)]


def test_total_revenue_sums_discounted_orders(sample_orders):
    assert total_revenue(sample_orders) == 1625


def test_total_revenue_empty_list_is_zero():
    assert total_revenue([]) == 0

全部跑一次 pytest -v:

============================= test session starts =============================
platform win32 -- Python 3.14.3, pytest-9.1.1, pluggy-1.6.0
rootdir: C:\Users\jerry\sales_project
collected 11 items

test_sales.py::test_calc_discount_no_discount_below_five PASSED          [  9%]
test_sales.py::test_calc_discount_ten_or_more_gets_85_percent PASSED     [ 18%]
test_sales.py::test_calc_discount_tiers[4-100-400] PASSED                [ 27%]
test_sales.py::test_calc_discount_tiers[5-100-475] PASSED                [ 36%]
test_sales.py::test_calc_discount_tiers[9-100-855] PASSED                [ 45%]
test_sales.py::test_calc_discount_tiers[10-100-850] PASSED               [ 54%]
test_sales.py::test_calc_discount_tiers[20-50-850] PASSED                [ 63%]
test_sales.py::test_calc_discount_zero_quantity_raises PASSED            [ 72%]
test_sales.py::test_calc_discount_negative_quantity_raises PASSED        [ 81%]
test_sales.py::test_total_revenue_sums_discounted_orders PASSED          [ 90%]
test_sales.py::test_total_revenue_empty_list_is_zero PASSED              [100%]

============================= 11 passed in 0.03s ==============================

11 個綠燈,涵蓋了正常情況、兩個折扣門檻的邊界、該丟例外的邊界,還有加總的邊界(空清單)。這份清單就是你以後改這支腳本的安全網——不管改的是折扣邏輯、加總邏輯,還是哪個門檻的數字,改完跑一次 pytest,11 個綠燈全過才敢說沒改壞。

最後示範一次「安全網真的接住東西」的樣子。假設有人手滑把 95 折的係數從 0.95 改成 0.9:

    if quantity >= 5:
        return round(subtotal * 0.9, 2)   # 手滑改錯

再跑一次 pytest -v:

test_sales.py::test_calc_discount_tiers[4-100-400] PASSED                [ 27%]
test_sales.py::test_calc_discount_tiers[5-100-475] FAILED                [ 36%]
test_sales.py::test_calc_discount_tiers[9-100-855] FAILED                [ 45%]
test_sales.py::test_calc_discount_tiers[10-100-850] PASSED               [ 54%]
...
test_sales.py::test_total_revenue_sums_discounted_orders FAILED          [ 90%]
...
========================= 3 failed, 8 passed in 0.05s =========================

一個小數點改錯,直接炸出 3 個失敗:兩個門檻測試(5 件、9 件),加上用到同一個函式的 total_revenue 測試也跟著壞掉。這就是測試的複利效果——你只寫了 11 個測試,但它們會在你意想不到的地方(例如另一個函式間接用到壞掉的邏輯)幫你多抓一層。

這篇也是「把入門 Python 寫成能維護的程式」系列的最後一篇。從用 pathlib 讀檔案、collections 做統計、dataclass 表示訂單、型別註解讓 mypy 幫你抓型別錯誤,到這篇用 pytest 幫函式寫測試——這五步走完,你手上的腳本已經不只是「跑得動」,而是改了也有把握還是對的。這些內容在「Python 基礎入門」課程裡也有對應的練習可以動手寫。

參考資料: - pytest — Get Started - pytest — How to write and report assertions in tests - pytest — How to parametrize fixtures and test functions - pytest — How to use fixtures

*本文程式碼與輸出實際以 Python 3.14.3、pytest 9.1.1(2026 年 6 月發布的目前最新版)跑過確認。*

相關課程

Python 基礎入門

從零開始學習 Python 程式語言,掌握變數、流程控制、函式與模組化設計,建立扎實的程式設計基礎。

查看課程內容