前 27 天,系統的程式碼越來越多:認證、排程、API、資料庫。每次改一行程式碼,都得手動把整個流程測一遍:登入、建計畫、完成任務、看儀表板。這樣很容易漏掉 bug。今天要寫測試,讓程式自動檢查程式,一個指令就能驗證 20 個功能是否正常。
做法是用 Pytest 寫三類測試。單元測試檢查獨立函數,例如密碼驗證、排程邏輯;API 測試檢查端點,例如登入是否回傳 token;資料庫測試檢查資料是否確實保存,例如新用戶是否真的存進資料庫。所有測試統一用 pytest 執行,並且會整合到 GitHub Actions,每次 push 程式碼都自動跑測試。
開車時,不必真的出車禍才知道安全氣囊有沒有用。安全氣囊是預防機制,撞車時它會自動展開。
程式測試也是一樣。與其每次都手動打開瀏覽器測一遍,不如寫自動化測試。改了程式碼之後,測試會立刻告訴你有沒有破壞什麼。
三類測試各有用途:
三層測試金字塔:
API 測試(少量,跑得慢)
/\
/ \
/ \
集成測試(中等,速度中等)
/ \
/ \
/ \
單元測試(大量,跑得快)
──────────────────
| 測試類型 | 測什麼 | 優點 | 缺點 |
|---|---|---|---|
| 單元測試 | 一個函數 | 快速、容易寫、好維護 | 不能發現整合問題 |
| 整合測試 | 函數 + 資料庫 | 接近實際情況 | 較慢、較複雜 |
| API 測試 | 整個 HTTP 流程 | 最接近真實 | 最慢、最複雜 |
今天寫單元測試和整合測試。API 測試則用 FastAPI 內建的 TestClient。
pip install pytest pytest-cov
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
└── ...
檔案位置: 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
檔案位置: 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
檔案位置: 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
檔案位置: 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
檔案位置: 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
在終端執行:
# 執行所有測試
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
檔案位置: .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,等真實資料庫一跑,才發現 ORM 查詢寫錯。
解決:盡量用真實的測試資料庫(記憶體 SQLite),只在必要時 mock 外部服務,例如郵件或第三方 API。
完成 Day 28 後,系統就有了測試網。明天要把整個系統容器化並部署到雲端,讓任何人都能存取。