iT邦幫忙

2026 iThome 鐵人賽

DAY 28
0
AI Engineering

30天用 Claude Code + LangGraph 實作個人化 AI 學習教練系列 第 28 篇

Day 28:基本單元測試 - 自動化測試確保應用品質

  • 分享至 

  • xImage
  •  

前 27 天,系統的程式碼越來越多:認證、排程、API、資料庫。每次改一行程式碼,都得手動把整個流程測一遍:登入、建計畫、完成任務、看儀表板。這樣很容易漏掉 bug。今天要寫測試,讓程式自動檢查程式,一個指令就能驗證 20 個功能是否正常。

做法是用 Pytest 寫三類測試。單元測試檢查獨立函數,例如密碼驗證、排程邏輯;API 測試檢查端點,例如登入是否回傳 token;資料庫測試檢查資料是否確實保存,例如新用戶是否真的存進資料庫。所有測試統一用 pytest 執行,並且會整合到 GitHub Actions,每次 push 程式碼都自動跑測試。

概念:測試就像安全氣囊

開車時,不必真的出車禍才知道安全氣囊有沒有用。安全氣囊是預防機制,撞車時它會自動展開。

程式測試也是一樣。與其每次都手動打開瀏覽器測一遍,不如寫自動化測試。改了程式碼之後,測試會立刻告訴你有沒有破壞什麼。

三類測試各有用途:

三層測試金字塔:
       API 測試(少量,跑得慢)
         /\
        /  \
       /    \
    集成測試(中等,速度中等)
      /      \
     /        \
    /          \
單元測試(大量,跑得快)
──────────────────
測試類型 測什麼 優點 缺點
單元測試 一個函數 快速、容易寫、好維護 不能發現整合問題
整合測試 函數 + 資料庫 接近實際情況 較慢、較複雜
API 測試 整個 HTTP 流程 最接近真實 最慢、最複雜

今天寫單元測試和整合測試。API 測試則用 FastAPI 內建的 TestClient。


實作步驟

步驟 1:安裝 Pytest

pip install pytest pytest-cov
  • pytest:測試框架
  • pytest-cov:生成測試覆蓋率報告

步驟 2:建立測試資料夾結構

backend/
├── tests/
│   ├── __init__.py
│   ├── conftest.py              # 共用的 fixtures
│   ├── test_auth.py             # 認證測試
│   ├── test_database.py         # 資料庫測試
│   ├── test_scheduler.py        # 排程測試
│   ├── test_api_dashboard.py    # API 端點測試
│   └── test_notifications.py    # 通知測試
├── main.py
├── models.py
└── ...

步驟 3:建立測試用的資料庫 fixture

檔案位置: backend/tests/conftest.py
狀態: 新建檔案
用途: 提供測試共用的資源(測試用資料庫、session 等)

import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from models import Base, User, Profile
from database import get_session
from main import app

# 使用記憶體資料庫進行測試,速度快且不會汙染真實資料
SQLALCHEMY_DATABASE_URL = "sqlite:///:memory:"


@pytest.fixture(scope="function")
def test_db():
    """建立測試用資料庫"""
    engine = create_engine(
        SQLALCHEMY_DATABASE_URL,
        connect_args={"check_same_thread": False}
    )
    Base.metadata.create_all(bind=engine)
    
    TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
    session = TestingSessionLocal()
    
    # 覆蓋 FastAPI 的 get_session
    def override_get_session():
        try:
            yield session
        finally:
            pass  # 測試結束時不關閉,等 engine 清理
    
    app.dependency_overrides[get_session] = override_get_session
    
    yield session
    
    session.close()
    Base.metadata.drop_all(bind=engine)


@pytest.fixture
def test_client(test_db):
    """建立測試用的 FastAPI 客戶端"""
    from fastapi.testclient import TestClient
    return TestClient(app)


@pytest.fixture
def test_user(test_db):
    """建立一個測試用戶"""
    from auth import hash_password
    
    user = User(
        email="test@example.com",
        password_hash=hash_password("password123")
    )
    test_db.add(user)
    test_db.commit()
    test_db.refresh(user)
    return user


@pytest.fixture
def test_user_token(test_user):
    """為測試用戶生成 JWT token"""
    from auth import create_access_token
    token = create_access_token(test_user.id)
    return token

步驟 4:寫認證測試

檔案位置: backend/tests/test_auth.py
狀態: 新建檔案
用途: 測試登入、註冊、密碼驗證等邏輯

import pytest
from fastapi.testclient import TestClient
from auth import hash_password, verify_password, create_access_token, verify_token
from models import User


class TestPasswordUtils:
    """測試密碼工具"""

    def test_hash_password_returns_hash(self):
        """測試密碼雜湊"""
        password = "my_secure_password"
        hashed = hash_password(password)
        
        # 雜湊結果應該與原密碼不同
        assert hashed != password
        # 雜湊結果應該是字串
        assert isinstance(hashed, str)

    def test_verify_password_correct(self):
        """測試密碼驗證 - 正確密碼"""
        password = "my_secure_password"
        hashed = hash_password(password)
        
        # 驗證正確密碼應該回傳 True
        assert verify_password(password, hashed) is True

    def test_verify_password_incorrect(self):
        """測試密碼驗證 - 錯誤密碼"""
        password = "my_secure_password"
        hashed = hash_password(password)
        
        # 驗證錯誤密碼應該回傳 False
        assert verify_password("wrong_password", hashed) is False


class TestTokenUtils:
    """測試 JWT token 工具"""

    def test_create_access_token(self):
        """測試 token 生成"""
        user_id = 42
        token = create_access_token(user_id)
        
        # Token 應該是字串
        assert isinstance(token, str)
        # Token 應該有三個部分(header.payload.signature)
        assert token.count('.') == 2

    def test_verify_token_valid(self):
        """測試 token 驗證 - 有效 token"""
        user_id = 42
        token = create_access_token(user_id)
        
        payload = verify_token(token)
        assert payload.sub == user_id

    def test_verify_token_invalid(self):
        """測試 token 驗證 - 無效 token"""
        from fastapi import HTTPException
        
        with pytest.raises(HTTPException) as exc_info:
            verify_token("invalid.token.here")
        
        assert exc_info.value.status_code == 401


class TestAuthEndpoints:
    """測試認證 API 端點"""

    def test_register_new_user(self, test_client):
        """測試註冊新用戶"""
        response = test_client.post(
            "/auth/register",
            json={"email": "newuser@example.com", "password": "password123"}
        )
        
        assert response.status_code == 200
        data = response.json()
        assert "access_token" in data
        assert data["token_type"] == "bearer"
        assert data["expires_in"] > 0

    def test_register_duplicate_email(self, test_client, test_user):
        """測試用已存在的 email 註冊"""
        response = test_client.post(
            "/auth/register",
            json={"email": "test@example.com", "password": "password123"}
        )
        
        assert response.status_code == 400
        assert "已被使用" in response.json()["detail"]

    def test_register_weak_password(self, test_client):
        """測試密碼過短"""
        response = test_client.post(
            "/auth/register",
            json={"email": "newuser@example.com", "password": "123"}
        )
        
        assert response.status_code == 400
        assert "6 個字元" in response.json()["detail"]

    def test_login_success(self, test_client, test_user):
        """測試登入成功"""
        response = test_client.post(
            "/auth/login",
            json={"email": "test@example.com", "password": "password123"}
        )
        
        assert response.status_code == 200
        data = response.json()
        assert "access_token" in data
        assert data["token_type"] == "bearer"

    def test_login_wrong_password(self, test_client, test_user):
        """測試密碼錯誤"""
        response = test_client.post(
            "/auth/login",
            json={"email": "test@example.com", "password": "wrong"}
        )
        
        assert response.status_code == 401
        assert "錯誤" in response.json()["detail"]

    def test_login_nonexistent_user(self, test_client):
        """測試用戶不存在"""
        response = test_client.post(
            "/auth/login",
            json={"email": "nonexistent@example.com", "password": "password123"}
        )
        
        assert response.status_code == 401

    def test_get_me_authenticated(self, test_client, test_user, test_user_token):
        """測試查詢當前用戶(已認證)"""
        response = test_client.get(
            "/auth/me",
            headers={"Authorization": f"Bearer {test_user_token}"}
        )
        
        assert response.status_code == 200
        data = response.json()
        assert data["id"] == test_user.id
        assert data["email"] == "test@example.com"

    def test_get_me_unauthenticated(self, test_client):
        """測試查詢當前用戶(未認證)"""
        response = test_client.get("/auth/me")
        
        assert response.status_code == 403

步驟 5:寫資料庫測試

檔案位置: backend/tests/test_database.py
狀態: 新建檔案
用途: 測試資料庫模型和 ORM

import pytest
from models import User, Task, Plan, Notification
from auth import hash_password


class TestUserModel:
    """測試 User 模型"""

    def test_create_user(self, test_db):
        """測試建立用戶"""
        user = User(
            email="john@example.com",
            password_hash=hash_password("password123")
        )
        test_db.add(user)
        test_db.commit()
        test_db.refresh(user)
        
        assert user.id is not None
        assert user.email == "john@example.com"
        assert user.password_hash != "password123"  # 應該是雜湊

    def test_user_email_unique(self, test_db, test_user):
        """測試 email 唯一性"""
        from sqlalchemy.exc import IntegrityError
        
        duplicate_user = User(
            email="test@example.com",
            password_hash=hash_password("password123")
        )
        test_db.add(duplicate_user)
        
        with pytest.raises(IntegrityError):
            test_db.commit()

    def test_user_has_notifications(self, test_db, test_user):
        """測試用戶可以有多個通知"""
        notification1 = Notification(
            user_id=test_user.id,
            title="通知 1",
            message="訊息 1"
        )
        notification2 = Notification(
            user_id=test_user.id,
            title="通知 2",
            message="訊息 2"
        )
        test_db.add(notification1)
        test_db.add(notification2)
        test_db.commit()
        
        # 重新查詢用戶,確保通知已關聯
        user = test_db.query(User).get(test_user.id)
        assert len(user.notifications) == 2


class TestNotificationModel:
    """測試 Notification 模型"""

    def test_create_notification(self, test_db, test_user):
        """測試建立通知"""
        notification = Notification(
            user_id=test_user.id,
            title="測試通知",
            message="這是一則測試通知",
            notification_type="info"
        )
        test_db.add(notification)
        test_db.commit()
        test_db.refresh(notification)
        
        assert notification.id is not None
        assert notification.is_read is False

    def test_notification_default_unread(self, test_db, test_user):
        """測試新通知預設為未讀"""
        notification = Notification(
            user_id=test_user.id,
            title="新通知",
            message="訊息"
        )
        test_db.add(notification)
        test_db.commit()
        
        assert notification.is_read is False

步驟 6:寫排程測試

檔案位置: backend/tests/test_scheduler.py
狀態: 新建檔案
用途: 測試排程邏輯

import pytest
from datetime import datetime, timedelta
from models import User, Task, Plan, ProgressLog, Notification
from scheduled_jobs import daily_morning_reminder, deadline_reminder
from auth import hash_password


class TestScheduledJobs:
    """測試定時任務"""

    def test_daily_morning_reminder_creates_notification(self, test_db, test_user):
        """測試早上提醒建立通知"""
        # 建立一個計畫和任務
        plan = Plan(
            user_id=test_user.id,
            title="測試計畫",
            status="approved"
        )
        test_db.add(plan)
        test_db.commit()
        test_db.refresh(plan)
        
        today = datetime.utcnow().date()
        task = Task(
            plan_id=plan.id,
            title="今日任務",
            deadline=today,
            status="pending"
        )
        test_db.add(task)
        test_db.commit()
        
        # 執行早上提醒
        daily_morning_reminder()
        
        # 驗證通知已建立
        notifications = test_db.query(Notification).filter(
            Notification.user_id == test_user.id
        ).all()
        
        assert len(notifications) > 0
        assert "今日任務清單" in notifications[0].title

    def test_deadline_reminder_for_overdue_tasks(self, test_db, test_user):
        """測試逾期提醒"""
        plan = Plan(
            user_id=test_user.id,
            title="測試計畫",
            status="approved"
        )
        test_db.add(plan)
        test_db.commit()
        test_db.refresh(plan)
        
        tomorrow = datetime.utcnow().date() + timedelta(days=1)
        task = Task(
            plan_id=plan.id,
            title="即將逾期的任務",
            deadline=tomorrow,
            status="pending"
        )
        test_db.add(task)
        test_db.commit()
        
        # 執行逾期提醒
        deadline_reminder()
        
        # 驗證通知已建立
        notifications = test_db.query(Notification).filter(
            Notification.user_id == test_user.id
        ).all()
        
        assert len(notifications) > 0
        assert "任務即將逾期" in notifications[0].title

步驟 7:寫 API 端點測試

檔案位置: backend/tests/test_api_dashboard.py
狀態: 新建檔案
用途: 測試 API 端點

import pytest
from datetime import datetime, timedelta, date
from models import Task, Plan, ProgressLog
from auth import hash_password


class TestDashboardAPI:
    """測試儀表板 API"""

    def test_get_dashboard_authenticated(self, test_client, test_user, test_user_token, test_db):
        """測試取得儀表板(已認證)"""
        # 建立測試資料
        plan = Plan(
            user_id=test_user.id,
            title="測試計畫",
            status="approved"
        )
        test_db.add(plan)
        test_db.commit()
        test_db.refresh(plan)
        
        today = date.today()
        task = Task(
            plan_id=plan.id,
            title="完成的任務",
            deadline=today,
            status="completed"
        )
        test_db.add(task)
        test_db.commit()
        test_db.refresh(task)
        
        progress = ProgressLog(
            task_id=task.id,
            actual_hours=1.5,
            difficulty_rating=3
        )
        test_db.add(progress)
        test_db.commit()
        
        response = test_client.get(
            "/dashboard",
            headers={"Authorization": f"Bearer {test_user_token}"}
        )
        
        assert response.status_code == 200
        data = response.json()
        assert "completion_rate" in data
        assert "weekly_hours" in data
        assert "streak_days" in data

    def test_get_dashboard_unauthenticated(self, test_client):
        """測試儀表板(未認證)"""
        response = test_client.get("/dashboard")
        
        assert response.status_code == 403


class TestNotificationsAPI:
    """測試通知 API"""

    def test_get_notifications(self, test_client, test_user, test_user_token, test_db):
        """測試取得通知"""
        from models import Notification
        
        notification = Notification(
            user_id=test_user.id,
            title="測試通知",
            message="訊息內容",
            is_read=False
        )
        test_db.add(notification)
        test_db.commit()
        
        response = test_client.get(
            "/notifications",
            headers={"Authorization": f"Bearer {test_user_token}"}
        )
        
        assert response.status_code == 200
        data = response.json()
        assert len(data) > 0
        assert data[0]["title"] == "測試通知"

    def test_mark_notification_read(self, test_client, test_user, test_user_token, test_db):
        """測試標記通知已讀"""
        from models import Notification
        
        notification = Notification(
            user_id=test_user.id,
            title="測試通知",
            message="訊息內容",
            is_read=False
        )
        test_db.add(notification)
        test_db.commit()
        test_db.refresh(notification)
        
        response = test_client.patch(
            f"/notifications/{notification.id}/read",
            headers={"Authorization": f"Bearer {test_user_token}"}
        )
        
        assert response.status_code == 200
        
        # 驗證通知已讀
        updated_notification = test_db.query(Notification).get(notification.id)
        assert updated_notification.is_read is True

步驟 8:執行測試並生成報告

在終端執行:

# 執行所有測試
pytest backend/tests/

# 執行並顯示詳細輸出
pytest backend/tests/ -v

# 生成覆蓋率報告
pytest backend/tests/ --cov=backend --cov-report=html

# 執行特定測試檔
pytest backend/tests/test_auth.py

# 執行特定測試方法
pytest backend/tests/test_auth.py::TestPasswordUtils::test_hash_password_returns_hash

步驟 9:設定 CI 自動執行測試

檔案位置: .github/workflows/tests.yml
狀態: 新建檔案
用途: 讓 GitHub Actions 自動執行測試

name: Tests

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main, develop ]

jobs:
  test:
    runs-on: ubuntu-latest
    
    steps:
    - uses: actions/checkout@v3
    
    - name: Set up Python 3.11
      uses: actions/setup-python@v4
      with:
        python-version: '3.11'
    
    - name: Install dependencies
      run: |
        python -m pip install --upgrade pip
        pip install -r requirements.txt
        pip install pytest pytest-cov
    
    - name: Run tests
      run: |
        pytest backend/tests/ --cov=backend --cov-report=xml
    
    - name: Upload coverage to Codecov
      uses: codecov/codecov-action@v3
      with:
        file: ./coverage.xml

測試執行與驗證

執行測試

cd backend
pytest tests/ -v --cov=.

輸出應該類似:

tests/test_auth.py::TestPasswordUtils::test_hash_password_returns_hash PASSED
tests/test_auth.py::TestPasswordUtils::test_verify_password_correct PASSED
tests/test_auth.py::TestPasswordUtils::test_verify_password_incorrect PASSED
...
====================== 20 passed in 1.23s ======================

---------- coverage: platform linux -- python 3.11.0 -----------
Name                  Stmts   Miss  Cover
────────────────────────────────────────
auth.py                  45      5    89%
models.py              120     15    88%
main.py                180     30    83%
────────────────────────────────────────
TOTAL                   500     60    88%

查看覆蓋率報告

# 生成 HTML 報告
pytest tests/ --cov=. --cov-report=html

# 用瀏覽器開啟
open htmlcov/index.html  # macOS
start htmlcov/index.html # Windows

常見問題

測試互相污染,一個測試影響另一個

測試 A 建立了用戶,測試 B 卻預期資料庫是空的。測試執行順序是隨機的,所以 B 有時會失敗。

解決:用 fixture 提供隔離的測試環境。每個測試都用新的記憶體資料庫,測完自動清除。見 conftest.py 的 test_db fixture。

測試跑得很慢

每次測試都連真實資料庫,還要等網路。

解決:用記憶體資料庫(SQLite :memory:)跑單元測試,速度會快很多。真實資料庫測試留到 CI 階段。

測試通過,程式卻還是有問題

寫的測試只檢查正常路徑(成功情況),沒有考慮錯誤情況。

解決:要寫反面測試。例如不只測「正確密碼能登入」,還要測「錯誤密碼被拒」。

Mock 過度,測不到實際問題

用了太多 mock,等真實資料庫一跑,才發現 ORM 查詢寫錯。

解決:盡量用真實的測試資料庫(記憶體 SQLite),只在必要時 mock 外部服務,例如郵件或第三方 API。


完成 Day 28 後,系統就有了測試網。明天要把整個系統容器化並部署到雲端,讓任何人都能存取。


上一篇
Day 27:登入與安全(簡化版) - 保護用戶資料
下一篇
Day 29:部署上線(簡化版) - 從本地到生產環境
系列文
30天用 Claude Code + LangGraph 實作個人化 AI 學習教練 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言