← 返回大綱
第十五章

專案實作
與單元測試

Project Implementation & Unit Testing

專案結構

一個良好的專案結構

my-project/
├── main.py            # 程式進入點
├── requirements.txt   # 套件依賴清單
├── .gitignore         # Git 排除清單
├── README.md          # 說明文件
├── src/               # 主要程式碼
│   ├── __init__.py
│   ├── calculator.py
│   └── utils.py
└── tests/             # 測試程式碼
    ├── __init__.py
    └── test_calculator.py
為什麼要分 src/ 和 tests/?

程式碼與測試分開,讓專案更易維護;__init__.py 讓資料夾成為 Python 套件,可以 import。

專案主題

本章專案:簡易成績計算器

功能需求

  • 讀取 CSV 學生成績檔
  • 計算平均、最高、最低分
  • 依分數給予等級(A/B/C/D)
  • 輸出統計報告

技術運用

  • Pandas 讀寫 CSV
  • 函式與模組拆分
  • 例外處理
  • 單元測試(unittest)
核心模組

src/calculator.py — 核心邏輯

def get_grade(score: float) -> str:
    """將分數轉換為等級"""
    if score >= 90:
        return "A"
    elif score >= 80:
        return "B"
    elif score >= 70:
        return "C"
    else:
        return "D"

def calculate_stats(scores: list[float]) -> dict:
    """計算成績統計"""
    if not scores:
        raise ValueError("成績清單不能為空")
    return {
        "count":   len(scores),
        "average": round(sum(scores) / len(scores), 2),
        "highest": max(scores),
        "lowest":  min(scores),
        "passing": sum(1 for s in scores if s >= 60)
    }
主程式

main.py — 讀取 CSV 並輸出報告

import pandas as pd
from src.calculator import get_grade, calculate_stats

def main():
    df = pd.read_csv("students.csv", encoding="utf-8")

    # 加入等級欄位
    df["grade"] = df["score"].apply(get_grade)

    # 計算統計
    stats = calculate_stats(df["score"].tolist())

    print("=" * 30)
    print("  成績報告")
    print("=" * 30)
    print(f"人數:{stats['count']}")
    print(f"平均:{stats['average']}")
    print(f"最高:{stats['highest']}")
    print(f"最低:{stats['lowest']}")
    print(f"及格:{stats['passing']} 人")
    print()
    print(df[["name", "score", "grade"]].to_string(index=False))

    df.to_csv("output.csv", index=False, encoding="utf-8")

if __name__ == "__main__":
    main()
unittest

單元測試是什麼?

對程式中最小的邏輯單位(函式)進行自動化測試,確保每個功能都符合預期:

沒有測試

  • 手動執行程式看看對不對
  • 修改後不確定有沒有壞掉
  • Bug 容易被遺漏

有單元測試

  • 一行指令跑完所有測試
  • 修改後立即知道有沒有問題
  • 放心重構程式碼
Python 內建 unittest

不需要安裝,直接 import unittest 即可使用。

unittest

tests/test_calculator.py — 撰寫測試

import unittest
from src.calculator import get_grade, calculate_stats

class TestGetGrade(unittest.TestCase):

    def test_grade_A(self):
        self.assertEqual(get_grade(95), "A")
        self.assertEqual(get_grade(90), "A")   # 邊界值

    def test_grade_B(self):
        self.assertEqual(get_grade(85), "B")

    def test_grade_D(self):
        self.assertEqual(get_grade(59), "D")

class TestCalculateStats(unittest.TestCase):

    def test_basic(self):
        stats = calculate_stats([80, 90, 70])
        self.assertEqual(stats["count"], 3)
        self.assertEqual(stats["average"], 80.0)
        self.assertEqual(stats["highest"], 90)

    def test_empty_raises(self):
        with self.assertRaises(ValueError):
            calculate_stats([])

if __name__ == "__main__":
    unittest.main()
執行測試

執行與解讀測試結果

# 執行全部測試
python -m unittest discover tests

# 或執行單一測試檔案
python -m unittest tests.test_calculator
.. (成功)
----------------------------------------------------------------------
Ran 5 tests in 0.002s
OK

F (失敗)
FAIL: test_grade_A (tests.test_calculator.TestGetGrade)
AssertionError: 'B' != 'A'
每個 . 代表一個通過的測試,F 代表失敗

失敗時會告訴你哪個測試失敗、期望值是什麼、實際得到什麼。

實作練習

動手試試看

import unittest

# 要被測試的函式
def fizzbuzz(n: int) -> str:
    """
    n 能被 15 整除 → "FizzBuzz"
    n 能被 3 整除  → "Fizz"
    n 能被 5 整除  → "Buzz"
    其他           → str(n)
    """
    if n % 15 == 0:
        return "FizzBuzz"
    elif n % 3 == 0:
        return "Fizz"
    elif n % 5 == 0:
        return "Buzz"
    return str(n)

class TestFizzBuzz(unittest.TestCase):
    def test_fizz(self):       self.assertEqual(fizzbuzz(3),  "Fizz")
    def test_buzz(self):       self.assertEqual(fizzbuzz(5),  "Buzz")
    def test_fizzbuzz(self):   self.assertEqual(fizzbuzz(15), "FizzBuzz")
    def test_number(self):     self.assertEqual(fizzbuzz(7),  "7")

unittest.main(argv=[""], exit=False, verbosity=2)

第十五章完成!

學會了專案結構規劃、模組拆分與 unittest 單元測試。