2491 lines
73 KiB
Plaintext
2491 lines
73 KiB
Plaintext
# 夥伴對齊系統 - 測試驅動開發文件 (TDD)
|
||
|
||
## 1. 文件資訊
|
||
|
||
**版本**: 1.0
|
||
**最後更新**: 2025年10月
|
||
**關聯文件**: SDD v1.0
|
||
**測試框架**: pytest + unittest
|
||
|
||
---
|
||
|
||
## 2. 測試策略概覽
|
||
|
||
### 2.1 核心測試理念
|
||
|
||
**TDD循環**:
|
||
```
|
||
┌──────────────────────────────────────┐
|
||
│ RED (紅燈) → GREEN (綠燈) → REFACTOR (重構) │
|
||
│ ↓ ↓ ↓ │
|
||
│ 寫測試失敗 實作最小程式碼 優化程式碼 │
|
||
└──────────────────────────────────────┘
|
||
```
|
||
|
||
**測試金字塔**:
|
||
```
|
||
┌─────────┐
|
||
│ E2E測試 │ 10%
|
||
│ (5) │
|
||
┌──┴─────────┴──┐
|
||
│ 整合測試 │ 30%
|
||
│ (50) │
|
||
┌──┴──────────────┴──┐
|
||
│ 單元測試 │ 60%
|
||
│ (200+) │
|
||
└────────────────────┘
|
||
```
|
||
|
||
### 2.2 測試覆蓋率目標
|
||
|
||
| 測試類型 | 目標覆蓋率 | 優先級 |
|
||
|---------|-----------|--------|
|
||
| 單元測試 | 80%+ | 🔴 最高 |
|
||
| 整合測試 | 70%+ | 🟡 高 |
|
||
| API測試 | 100% | 🔴 最高 |
|
||
| 端到端測試 | 核心流程100% | 🟡 高 |
|
||
|
||
### 2.3 測試環境配置
|
||
|
||
```python
|
||
# 測試環境設定檔 (test_config.py)
|
||
TEST_DATABASE_URI = 'mysql://test_user:test_pass@localhost/test_db'
|
||
TEST_SECRET_KEY = 'test_secret_key_for_testing_only'
|
||
TESTING = True
|
||
DEBUG = False
|
||
SQLALCHEMY_ECHO = False # 關閉SQL日誌
|
||
```
|
||
|
||
---
|
||
|
||
## 3. 單元測試設計
|
||
|
||
### 3.1 測試檔案結構
|
||
|
||
```
|
||
tests/
|
||
├── __init__.py
|
||
├── conftest.py # pytest配置與fixtures
|
||
├── unit/ # 單元測試
|
||
│ ├── __init__.py
|
||
│ ├── test_models.py # 資料模型測試
|
||
│ ├── test_validators.py # 驗證邏輯測試
|
||
│ ├── test_utils.py # 工具函數測試
|
||
│ └── test_calculations.py # 計算邏輯測試
|
||
├── integration/ # 整合測試
|
||
│ ├── __init__.py
|
||
│ ├── test_api_assessments.py
|
||
│ ├── test_api_star.py
|
||
│ ├── test_api_rankings.py
|
||
│ └── test_database.py
|
||
├── e2e/ # 端到端測試
|
||
│ ├── __init__.py
|
||
│ └── test_workflows.py
|
||
└── fixtures/ # 測試資料
|
||
├── sample_assessments.json
|
||
└── sample_star_feedbacks.json
|
||
```
|
||
|
||
### 3.2 測試基礎設施 (conftest.py)
|
||
|
||
```python
|
||
import pytest
|
||
from app import create_app, db
|
||
from app.models import Assessment, Capability, StarFeedback, EmployeePoints
|
||
|
||
@pytest.fixture(scope='session')
|
||
def app():
|
||
"""建立測試用Flask應用程式"""
|
||
app = create_app('testing')
|
||
return app
|
||
|
||
@pytest.fixture(scope='session')
|
||
def _db(app):
|
||
"""建立測試資料庫"""
|
||
with app.app_context():
|
||
db.create_all()
|
||
yield db
|
||
db.drop_all()
|
||
|
||
@pytest.fixture(scope='function')
|
||
def client(app):
|
||
"""建立測試客戶端"""
|
||
return app.test_client()
|
||
|
||
@pytest.fixture(scope='function')
|
||
def db_session(_db):
|
||
"""提供獨立的資料庫session"""
|
||
connection = _db.engine.connect()
|
||
transaction = connection.begin()
|
||
|
||
session = _db.create_scoped_session(
|
||
options={"bind": connection, "binds": {}}
|
||
)
|
||
_db.session = session
|
||
|
||
yield session
|
||
|
||
transaction.rollback()
|
||
connection.close()
|
||
session.remove()
|
||
|
||
@pytest.fixture
|
||
def sample_capability():
|
||
"""範例能力項目"""
|
||
return Capability(
|
||
name="程式設計與開發",
|
||
l1_description="基礎程式撰寫能力",
|
||
l2_description="獨立完成功能開發",
|
||
l3_description="架構設計與優化",
|
||
l4_description="技術領導與指導",
|
||
l5_description="技術策略制定"
|
||
)
|
||
|
||
@pytest.fixture
|
||
def sample_assessment_data():
|
||
"""範例評估資料"""
|
||
return {
|
||
"department": "技術部",
|
||
"position": "資深工程師",
|
||
"employee_name": "測試員工A",
|
||
"assessment_data": {
|
||
"L1": ["程式設計與開發"],
|
||
"L2": ["系統分析與設計"],
|
||
"L3": ["專案管理"],
|
||
"L4": [],
|
||
"L5": []
|
||
}
|
||
}
|
||
|
||
@pytest.fixture
|
||
def sample_star_feedback():
|
||
"""範例STAR回饋"""
|
||
return {
|
||
"evaluator_name": "主管A",
|
||
"evaluatee_name": "員工B",
|
||
"evaluatee_department": "技術部",
|
||
"evaluatee_position": "工程師",
|
||
"situation": "專案遇到緊急技術問題,系統出現嚴重效能瓶頸",
|
||
"task": "需在24小時內找出問題根源並提出解決方案",
|
||
"action": "透過profiling工具分析,發現資料庫查詢N+1問題,重構查詢邏輯",
|
||
"result": "系統回應時間從3秒降至0.5秒,客戶滿意度提升",
|
||
"score": 4,
|
||
"feedback_date": "2025-10-15"
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 4. 資料模型測試
|
||
|
||
### 4.1 Assessment Model 測試
|
||
|
||
**測試檔案**: `tests/unit/test_models.py`
|
||
|
||
#### 測試案例 1: 建立評估記錄
|
||
|
||
```python
|
||
class TestAssessmentModel:
|
||
"""Assessment模型測試"""
|
||
|
||
def test_create_assessment_success(self, db_session, sample_assessment_data):
|
||
"""
|
||
測試目標: 驗證評估記錄能正確建立
|
||
|
||
測試步驟:
|
||
1. 建立Assessment物件
|
||
2. 儲存至資料庫
|
||
3. 驗證資料正確性
|
||
|
||
預期結果:
|
||
- 記錄成功建立
|
||
- 自動生成ID
|
||
- created_at自動設定
|
||
- assessment_data正確儲存為JSON
|
||
"""
|
||
# RED: 先寫測試
|
||
assessment = Assessment(
|
||
department=sample_assessment_data['department'],
|
||
position=sample_assessment_data['position'],
|
||
employee_name=sample_assessment_data['employee_name'],
|
||
assessment_data=sample_assessment_data['assessment_data']
|
||
)
|
||
|
||
db_session.add(assessment)
|
||
db_session.commit()
|
||
|
||
# 驗證
|
||
assert assessment.id is not None
|
||
assert assessment.department == "技術部"
|
||
assert assessment.position == "資深工程師"
|
||
assert assessment.created_at is not None
|
||
assert isinstance(assessment.assessment_data, dict)
|
||
assert "L1" in assessment.assessment_data
|
||
|
||
def test_assessment_requires_department(self, db_session):
|
||
"""
|
||
測試目標: 驗證必填欄位驗證
|
||
|
||
預期結果: 缺少department時拋出IntegrityError
|
||
"""
|
||
from sqlalchemy.exc import IntegrityError
|
||
|
||
assessment = Assessment(
|
||
position="工程師",
|
||
assessment_data={}
|
||
)
|
||
|
||
db_session.add(assessment)
|
||
|
||
with pytest.raises(IntegrityError):
|
||
db_session.commit()
|
||
|
||
def test_assessment_json_serialization(self, db_session, sample_assessment_data):
|
||
"""
|
||
測試目標: 驗證JSON序列化/反序列化
|
||
|
||
預期結果: assessment_data能正確轉換為JSON並還原
|
||
"""
|
||
assessment = Assessment(**sample_assessment_data)
|
||
db_session.add(assessment)
|
||
db_session.commit()
|
||
|
||
# 重新查詢
|
||
retrieved = db_session.query(Assessment).filter_by(
|
||
id=assessment.id
|
||
).first()
|
||
|
||
assert retrieved.assessment_data == sample_assessment_data['assessment_data']
|
||
assert isinstance(retrieved.assessment_data['L1'], list)
|
||
```
|
||
|
||
#### 測試案例 2: 查詢與篩選
|
||
|
||
```python
|
||
def test_filter_by_department(self, db_session):
|
||
"""
|
||
測試目標: 驗證按部門篩選功能
|
||
|
||
測試資料:
|
||
- 技術部: 3筆
|
||
- 業務部: 2筆
|
||
|
||
預期結果: 正確篩選出對應部門的資料
|
||
"""
|
||
# 建立測試資料
|
||
departments = ["技術部"] * 3 + ["業務部"] * 2
|
||
for i, dept in enumerate(departments):
|
||
assessment = Assessment(
|
||
department=dept,
|
||
position="職位" + str(i),
|
||
assessment_data={}
|
||
)
|
||
db_session.add(assessment)
|
||
db_session.commit()
|
||
|
||
# 測試篩選
|
||
tech_assessments = db_session.query(Assessment).filter_by(
|
||
department="技術部"
|
||
).all()
|
||
|
||
assert len(tech_assessments) == 3
|
||
assert all(a.department == "技術部" for a in tech_assessments)
|
||
|
||
def test_pagination(self, db_session):
|
||
"""
|
||
測試目標: 驗證分頁功能
|
||
|
||
測試資料: 25筆記錄
|
||
分頁設定: 每頁10筆
|
||
|
||
預期結果:
|
||
- 第1頁: 10筆
|
||
- 第2頁: 10筆
|
||
- 第3頁: 5筆
|
||
"""
|
||
# 建立25筆測試資料
|
||
for i in range(25):
|
||
assessment = Assessment(
|
||
department="部門" + str(i % 3),
|
||
position="職位",
|
||
assessment_data={}
|
||
)
|
||
db_session.add(assessment)
|
||
db_session.commit()
|
||
|
||
# 測試分頁
|
||
page1 = db_session.query(Assessment).limit(10).offset(0).all()
|
||
page2 = db_session.query(Assessment).limit(10).offset(10).all()
|
||
page3 = db_session.query(Assessment).limit(10).offset(20).all()
|
||
|
||
assert len(page1) == 10
|
||
assert len(page2) == 10
|
||
assert len(page3) == 5
|
||
```
|
||
|
||
### 4.2 StarFeedback Model 測試
|
||
|
||
```python
|
||
class TestStarFeedbackModel:
|
||
"""STAR回饋模型測試"""
|
||
|
||
def test_create_star_feedback_success(self, db_session, sample_star_feedback):
|
||
"""
|
||
測試目標: 驗證STAR回饋記錄建立
|
||
|
||
預期結果:
|
||
- 所有欄位正確儲存
|
||
- points_earned自動計算 (score * 10)
|
||
"""
|
||
feedback = StarFeedback(**sample_star_feedback)
|
||
|
||
db_session.add(feedback)
|
||
db_session.commit()
|
||
|
||
assert feedback.id is not None
|
||
assert feedback.score == 4
|
||
assert feedback.points_earned == 40 # 自動計算
|
||
assert feedback.evaluator_name == "主管A"
|
||
assert len(feedback.situation) > 0
|
||
|
||
def test_score_validation(self, db_session):
|
||
"""
|
||
測試目標: 驗證評分範圍限制
|
||
|
||
測試案例:
|
||
- score = 0: 應失敗
|
||
- score = 1-5: 應成功
|
||
- score = 6: 應失敗
|
||
|
||
預期結果: 只接受1-5的評分
|
||
"""
|
||
from sqlalchemy.exc import IntegrityError
|
||
|
||
# 測試無效評分 (0)
|
||
invalid_feedback = StarFeedback(
|
||
evaluator_name="主管",
|
||
evaluatee_name="員工",
|
||
evaluatee_department="部門",
|
||
evaluatee_position="職位",
|
||
situation="S", task="T", action="A", result="R",
|
||
score=0,
|
||
feedback_date="2025-10-15"
|
||
)
|
||
|
||
db_session.add(invalid_feedback)
|
||
with pytest.raises(IntegrityError):
|
||
db_session.commit()
|
||
db_session.rollback()
|
||
|
||
# 測試有效評分 (1-5)
|
||
for score in range(1, 6):
|
||
valid_feedback = StarFeedback(
|
||
evaluator_name="主管",
|
||
evaluatee_name=f"員工{score}",
|
||
evaluatee_department="部門",
|
||
evaluatee_position="職位",
|
||
situation="S", task="T", action="A", result="R",
|
||
score=score,
|
||
feedback_date="2025-10-15"
|
||
)
|
||
db_session.add(valid_feedback)
|
||
db_session.commit()
|
||
|
||
assert valid_feedback.points_earned == score * 10
|
||
|
||
def test_star_required_fields(self, db_session):
|
||
"""
|
||
測試目標: 驗證STAR四個欄位為必填
|
||
|
||
預期結果: 缺少任一STAR欄位時應失敗
|
||
"""
|
||
from sqlalchemy.exc import IntegrityError
|
||
|
||
base_data = {
|
||
"evaluator_name": "主管",
|
||
"evaluatee_name": "員工",
|
||
"evaluatee_department": "部門",
|
||
"evaluatee_position": "職位",
|
||
"score": 3,
|
||
"feedback_date": "2025-10-15"
|
||
}
|
||
|
||
# 測試缺少每個STAR欄位
|
||
star_fields = ['situation', 'task', 'action', 'result']
|
||
|
||
for missing_field in star_fields:
|
||
data = base_data.copy()
|
||
# 給予其他三個欄位值
|
||
for field in star_fields:
|
||
if field != missing_field:
|
||
data[field] = "測試內容"
|
||
|
||
feedback = StarFeedback(**data)
|
||
db_session.add(feedback)
|
||
|
||
with pytest.raises(IntegrityError):
|
||
db_session.commit()
|
||
db_session.rollback()
|
||
```
|
||
|
||
### 4.3 EmployeePoints Model 測試
|
||
|
||
```python
|
||
class TestEmployeePointsModel:
|
||
"""員工積分模型測試"""
|
||
|
||
def test_create_employee_points(self, db_session):
|
||
"""
|
||
測試目標: 建立員工積分記錄
|
||
|
||
預期結果: 預設積分為0
|
||
"""
|
||
employee = EmployeePoints(
|
||
employee_name="員工A",
|
||
department="技術部",
|
||
position="工程師"
|
||
)
|
||
|
||
db_session.add(employee)
|
||
db_session.commit()
|
||
|
||
assert employee.total_points == 0
|
||
assert employee.monthly_points == 0
|
||
assert employee.last_updated is not None
|
||
|
||
def test_update_points(self, db_session):
|
||
"""
|
||
測試目標: 驗證積分更新邏輯
|
||
|
||
測試步驟:
|
||
1. 建立員工記錄
|
||
2. 新增30積分
|
||
3. 再新增20積分
|
||
|
||
預期結果:
|
||
- total_points = 50
|
||
- monthly_points = 50
|
||
- last_updated已更新
|
||
"""
|
||
employee = EmployeePoints(
|
||
employee_name="員工B",
|
||
department="技術部",
|
||
position="工程師"
|
||
)
|
||
db_session.add(employee)
|
||
db_session.commit()
|
||
|
||
original_time = employee.last_updated
|
||
|
||
# 第一次新增積分
|
||
employee.total_points += 30
|
||
employee.monthly_points += 30
|
||
db_session.commit()
|
||
|
||
assert employee.total_points == 30
|
||
assert employee.last_updated > original_time
|
||
|
||
# 第二次新增積分
|
||
employee.total_points += 20
|
||
employee.monthly_points += 20
|
||
db_session.commit()
|
||
|
||
assert employee.total_points == 50
|
||
assert employee.monthly_points == 50
|
||
|
||
def test_unique_employee_name(self, db_session):
|
||
"""
|
||
測試目標: 驗證員工姓名唯一性約束
|
||
|
||
預期結果: 重複員工姓名應失敗
|
||
"""
|
||
from sqlalchemy.exc import IntegrityError
|
||
|
||
# 第一個員工
|
||
employee1 = EmployeePoints(
|
||
employee_name="重複姓名",
|
||
department="技術部",
|
||
position="工程師"
|
||
)
|
||
db_session.add(employee1)
|
||
db_session.commit()
|
||
|
||
# 嘗試建立同名員工
|
||
employee2 = EmployeePoints(
|
||
employee_name="重複姓名",
|
||
department="業務部",
|
||
position="專員"
|
||
)
|
||
db_session.add(employee2)
|
||
|
||
with pytest.raises(IntegrityError):
|
||
db_session.commit()
|
||
```
|
||
|
||
---
|
||
|
||
## 5. 業務邏輯測試
|
||
|
||
### 5.1 積分計算邏輯測試
|
||
|
||
**測試檔案**: `tests/unit/test_calculations.py`
|
||
|
||
```python
|
||
class TestPointsCalculation:
|
||
"""積分計算邏輯測試"""
|
||
|
||
@pytest.mark.parametrize("score,expected_points", [
|
||
(1, 10),
|
||
(2, 20),
|
||
(3, 30),
|
||
(4, 40),
|
||
(5, 50),
|
||
])
|
||
def test_calculate_points_from_score(self, score, expected_points):
|
||
"""
|
||
測試目標: 驗證積分計算公式
|
||
|
||
計算規則: points = score × 10
|
||
|
||
測試案例: 所有有效評分 (1-5)
|
||
"""
|
||
from app.utils import calculate_points
|
||
|
||
points = calculate_points(score)
|
||
assert points == expected_points
|
||
|
||
def test_invalid_score_raises_error(self):
|
||
"""
|
||
測試目標: 驗證無效評分處理
|
||
|
||
測試案例:
|
||
- score < 1
|
||
- score > 5
|
||
- score 非整數
|
||
|
||
預期結果: 拋出ValueError
|
||
"""
|
||
from app.utils import calculate_points
|
||
|
||
with pytest.raises(ValueError):
|
||
calculate_points(0)
|
||
|
||
with pytest.raises(ValueError):
|
||
calculate_points(6)
|
||
|
||
with pytest.raises(ValueError):
|
||
calculate_points(3.5)
|
||
|
||
def test_accumulate_employee_points(self, db_session):
|
||
"""
|
||
測試目標: 驗證員工積分累積邏輯
|
||
|
||
測試情境:
|
||
- 員工收到3次回饋: 評分3, 4, 5
|
||
- 預期總積分: 120分
|
||
|
||
預期結果:
|
||
- total_points正確累加
|
||
- monthly_points正確累加
|
||
"""
|
||
from app.services import accumulate_points
|
||
|
||
employee = EmployeePoints(
|
||
employee_name="測試員工",
|
||
department="技術部",
|
||
position="工程師"
|
||
)
|
||
db_session.add(employee)
|
||
db_session.commit()
|
||
|
||
# 累加積分
|
||
scores = [3, 4, 5]
|
||
for score in scores:
|
||
points = calculate_points(score)
|
||
accumulate_points(db_session, "測試員工", points)
|
||
|
||
# 驗證
|
||
updated_employee = db_session.query(EmployeePoints).filter_by(
|
||
employee_name="測試員工"
|
||
).first()
|
||
|
||
assert updated_employee.total_points == 120
|
||
assert updated_employee.monthly_points == 120
|
||
```
|
||
|
||
### 5.2 排名計算邏輯測試
|
||
|
||
```python
|
||
class TestRankingCalculation:
|
||
"""排名計算邏輯測試"""
|
||
|
||
def test_calculate_rankings_basic(self, db_session):
|
||
"""
|
||
測試目標: 基本排名計算
|
||
|
||
測試資料:
|
||
- 員工A: 100分 → 排名1
|
||
- 員工B: 80分 → 排名2
|
||
- 員工C: 60分 → 排名3
|
||
|
||
預期結果: 排名正確且按積分降序
|
||
"""
|
||
from app.services import calculate_monthly_rankings
|
||
from datetime import date
|
||
|
||
# 建立測試資料
|
||
employees = [
|
||
EmployeePoints(employee_name="員工A", department="技術部",
|
||
position="工程師", total_points=100, monthly_points=100),
|
||
EmployeePoints(employee_name="員工B", department="技術部",
|
||
position="工程師", total_points=80, monthly_points=80),
|
||
EmployeePoints(employee_name="員工C", department="業務部",
|
||
position="專員", total_points=60, monthly_points=60),
|
||
]
|
||
|
||
for emp in employees:
|
||
db_session.add(emp)
|
||
db_session.commit()
|
||
|
||
# 計算排名
|
||
ranking_month = date(2025, 10, 1)
|
||
calculate_monthly_rankings(db_session, ranking_month)
|
||
|
||
# 驗證排名
|
||
rankings = db_session.query(MonthlyRanking).filter_by(
|
||
ranking_month=ranking_month
|
||
).order_by(MonthlyRanking.ranking).all()
|
||
|
||
assert len(rankings) == 3
|
||
assert rankings[0].employee_name == "員工A"
|
||
assert rankings[0].ranking == 1
|
||
assert rankings[1].employee_name == "員工B"
|
||
assert rankings[1].ranking == 2
|
||
assert rankings[2].employee_name == "員工C"
|
||
assert rankings[2].ranking == 3
|
||
|
||
def test_calculate_rankings_with_ties(self, db_session):
|
||
"""
|
||
測試目標: 並列排名處理
|
||
|
||
測試資料:
|
||
- 員工A: 100分 → 排名1
|
||
- 員工B: 80分 → 排名2
|
||
- 員工C: 80分 → 排名2 (並列)
|
||
- 員工D: 60分 → 排名4 (跳號)
|
||
|
||
預期結果: 並列者同排名,下一名次跳號
|
||
"""
|
||
from app.services import calculate_monthly_rankings
|
||
from datetime import date
|
||
|
||
employees = [
|
||
EmployeePoints(employee_name="員工A", department="技術部",
|
||
position="工程師", total_points=100, monthly_points=100),
|
||
EmployeePoints(employee_name="員工B", department="技術部",
|
||
position="工程師", total_points=80, monthly_points=80),
|
||
EmployeePoints(employee_name="員工C", department="業務部",
|
||
position="專員", total_points=80, monthly_points=80),
|
||
EmployeePoints(employee_name="員工D", department="業務部",
|
||
position="專員", total_points=60, monthly_points=60),
|
||
]
|
||
|
||
for emp in employees:
|
||
db_session.add(emp)
|
||
db_session.commit()
|
||
|
||
ranking_month = date(2025, 10, 1)
|
||
calculate_monthly_rankings(db_session, ranking_month)
|
||
|
||
rankings = db_session.query(MonthlyRanking).filter_by(
|
||
ranking_month=ranking_month
|
||
).order_by(MonthlyRanking.ranking, MonthlyRanking.employee_name).all()
|
||
|
||
assert rankings[0].ranking == 1
|
||
assert rankings[1].ranking == 2
|
||
assert rankings[2].ranking == 2 # 並列
|
||
assert rankings[3].ranking == 4 # 跳號
|
||
|
||
def test_exclude_zero_points_from_ranking(self, db_session):
|
||
"""
|
||
測試目標: 零積分員工不列入排名
|
||
|
||
測試資料:
|
||
- 員工A: 50分
|
||
- 員工B: 0分
|
||
|
||
預期結果: 只有員工A出現在排名中
|
||
"""
|
||
from app.services import calculate_monthly_rankings
|
||
from datetime import date
|
||
|
||
employees = [
|
||
EmployeePoints(employee_name="員工A", department="技術部",
|
||
position="工程師", total_points=50, monthly_points=50),
|
||
EmployeePoints(employee_name="員工B", department="技術部",
|
||
position="工程師", total_points=0, monthly_points=0),
|
||
]
|
||
|
||
for emp in employees:
|
||
db_session.add(emp)
|
||
db_session.commit()
|
||
|
||
ranking_month = date(2025, 10, 1)
|
||
calculate_monthly_rankings(db_session, ranking_month)
|
||
|
||
rankings = db_session.query(MonthlyRanking).filter_by(
|
||
ranking_month=ranking_month
|
||
).all()
|
||
|
||
assert len(rankings) == 1
|
||
assert rankings[0].employee_name == "員工A"
|
||
|
||
def test_reset_monthly_points_after_calculation(self, db_session):
|
||
"""
|
||
測試目標: 計算排名後重置月度積分
|
||
|
||
預期結果:
|
||
- total_points保持不變
|
||
- monthly_points重置為0
|
||
"""
|
||
from app.services import calculate_monthly_rankings
|
||
from datetime import date
|
||
|
||
employee = EmployeePoints(
|
||
employee_name="員工A",
|
||
department="技術部",
|
||
position="工程師",
|
||
total_points=100,
|
||
monthly_points=100
|
||
)
|
||
db_session.add(employee)
|
||
db_session.commit()
|
||
|
||
ranking_month = date(2025, 10, 1)
|
||
calculate_monthly_rankings(db_session, ranking_month)
|
||
|
||
updated_employee = db_session.query(EmployeePoints).filter_by(
|
||
employee_name="員工A"
|
||
).first()
|
||
|
||
assert updated_employee.total_points == 100 # 不變
|
||
assert updated_employee.monthly_points == 0 # 重置
|
||
```
|
||
|
||
### 5.3 資料驗證測試
|
||
|
||
```python
|
||
class TestDataValidation:
|
||
"""資料驗證邏輯測試"""
|
||
|
||
def test_validate_assessment_data_valid(self):
|
||
"""
|
||
測試目標: 驗證有效的評估資料
|
||
|
||
預期結果: 驗證通過,回傳True
|
||
"""
|
||
from app.validators import validate_assessment_data
|
||
|
||
valid_data = {
|
||
"department": "技術部",
|
||
"position": "工程師",
|
||
"assessment_data": {
|
||
"L1": ["能力1"],
|
||
"L2": ["能力2"],
|
||
"L3": [],
|
||
"L4": [],
|
||
"L5": []
|
||
}
|
||
}
|
||
|
||
is_valid, errors = validate_assessment_data(valid_data)
|
||
|
||
assert is_valid is True
|
||
assert len(errors) == 0
|
||
|
||
def test_validate_assessment_data_missing_required_fields(self):
|
||
"""
|
||
測試目標: 驗證缺少必填欄位
|
||
|
||
測試案例:
|
||
- 缺少department
|
||
- 缺少position
|
||
- 缺少assessment_data
|
||
|
||
預期結果: 驗證失敗,回傳錯誤訊息
|
||
"""
|
||
from app.validators import validate_assessment_data
|
||
|
||
# 缺少department
|
||
data1 = {
|
||
"position": "工程師",
|
||
"assessment_data": {"L1": []}
|
||
}
|
||
is_valid1, errors1 = validate_assessment_data(data1)
|
||
assert is_valid1 is False
|
||
assert "department" in str(errors1).lower()
|
||
|
||
# 缺少assessment_data
|
||
data2 = {
|
||
"department": "技術部",
|
||
"position": "工程師"
|
||
}
|
||
is_valid2, errors2 = validate_assessment_data(data2)
|
||
assert is_valid2 is False
|
||
assert "assessment_data" in str(errors2).lower()
|
||
|
||
def test_validate_star_feedback_valid(self):
|
||
"""
|
||
測試目標: 驗證有效的STAR回饋
|
||
|
||
預期結果: 所有必填欄位驗證通過
|
||
"""
|
||
from app.validators import validate_star_feedback
|
||
|
||
valid_feedback = {
|
||
"evaluator_name": "主管A",
|
||
"evaluatee_name": "員工B",
|
||
"evaluatee_department": "技術部",
|
||
"evaluatee_position": "工程師",
|
||
"situation": "專案遇到緊急問題需要處理" * 2, # > 10字
|
||
"task": "需要在限定時間內解決問題" * 2,
|
||
"action": "採取了具體的行動步驟處理" * 2,
|
||
"result": "成功解決問題並達成目標" * 2,
|
||
"score": 4,
|
||
"feedback_date": "2025-10-15"
|
||
}
|
||
|
||
is_valid, errors = validate_star_feedback(valid_feedback)
|
||
|
||
assert is_valid is True
|
||
assert len(errors) == 0
|
||
|
||
def test_validate_star_content_minimum_length(self):
|
||
"""
|
||
測試目標: 驗證STAR內容最小長度要求
|
||
|
||
要求: 每個STAR欄位至少10字元
|
||
|
||
預期結果: 少於10字元應驗證失敗
|
||
"""
|
||
from app.validators import validate_star_feedback
|
||
|
||
short_feedback = {
|
||
"evaluator_name": "主管A",
|
||
"evaluatee_name": "員工B",
|
||
"evaluatee_department": "技術部",
|
||
"evaluatee_position": "工程師",
|
||
"situation": "太短", # < 10字
|
||
"task": "需要在限定時間內解決問題" * 2,
|
||
"action": "採取了具體的行動步驟處理" * 2,
|
||
"result": "成功解決問題並達成目標" * 2,
|
||
"score": 4,
|
||
"feedback_date": "2025-10-15"
|
||
}
|
||
|
||
is_valid, errors = validate_star_feedback(short_feedback)
|
||
|
||
assert is_valid is False
|
||
assert "situation" in str(errors).lower()
|
||
assert "10" in str(errors)
|
||
|
||
def test_validate_score_range(self):
|
||
"""
|
||
測試目標: 驗證評分範圍
|
||
|
||
有效範圍: 1-5
|
||
|
||
測試案例:
|
||
- score = 0: 失敗
|
||
- score = 3: 成功
|
||
- score = 6: 失敗
|
||
- score = "abc": 失敗
|
||
"""
|
||
from app.validators import validate_score
|
||
|
||
assert validate_score(0)[0] is False
|
||
assert validate_score(1)[0] is True
|
||
assert validate_score(3)[0] is True
|
||
assert validate_score(5)[0] is True
|
||
assert validate_score(6)[0] is False
|
||
assert validate_score("abc")[0] is False
|
||
|
||
def test_validate_feedback_date_not_future(self):
|
||
"""
|
||
測試目標: 驗證回饋日期不可為未來日期
|
||
|
||
預期結果: 未來日期應驗證失敗
|
||
"""
|
||
from app.validators import validate_feedback_date
|
||
from datetime import date, timedelta
|
||
|
||
today = date.today()
|
||
yesterday = today - timedelta(days=1)
|
||
tomorrow = today + timedelta(days=1)
|
||
|
||
assert validate_feedback_date(yesterday)[0] is True
|
||
assert validate_feedback_date(today)[0] is True
|
||
assert validate_feedback_date(tomorrow)[0] is False
|
||
```
|
||
|
||
---
|
||
|
||
## 6. API整合測試
|
||
|
||
### 6.1 Assessment API 測試
|
||
|
||
**測試檔案**: `tests/integration/test_api_assessments.py`
|
||
|
||
```python
|
||
class TestAssessmentAPI:
|
||
"""能力評估API整合測試"""
|
||
|
||
def test_get_capabilities_success(self, client, db_session, sample_capability):
|
||
"""
|
||
測試目標: GET /api/capabilities
|
||
|
||
測試步驟:
|
||
1. 建立測試能力項目
|
||
2. 呼叫API
|
||
3. 驗證回應
|
||
|
||
預期結果:
|
||
- 狀態碼: 200
|
||
- 回傳所有啟用的能力項目
|
||
- JSON格式正確
|
||
"""
|
||
# 準備資料
|
||
db_session.add(sample_capability)
|
||
db_session.commit()
|
||
|
||
# 呼叫API
|
||
response = client.get('/api/capabilities')
|
||
|
||
# 驗證
|
||
assert response.status_code == 200
|
||
|
||
data = response.get_json()
|
||
assert 'capabilities' in data
|
||
assert len(data['capabilities']) > 0
|
||
|
||
capability = data['capabilities'][0]
|
||
assert 'id' in capability
|
||
assert 'name' in capability
|
||
assert 'l1_description' in capability
|
||
|
||
def test_post_assessment_success(self, client, db_session, sample_assessment_data):
|
||
"""
|
||
測試目標: POST /api/assessments
|
||
|
||
測試步驟:
|
||
1. 提交有效的評估資料
|
||
2. 驗證回應
|
||
3. 確認資料已儲存至資料庫
|
||
|
||
預期結果:
|
||
- 狀態碼: 201
|
||
- 回傳成功訊息
|
||
- 資料庫有新記錄
|
||
"""
|
||
response = client.post(
|
||
'/api/assessments',
|
||
json=sample_assessment_data,
|
||
content_type='application/json'
|
||
)
|
||
|
||
# 驗證回應
|
||
assert response.status_code == 201
|
||
|
||
data = response.get_json()
|
||
assert data['success'] is True
|
||
assert 'assessment_id' in data
|
||
assert data['message'] == '評估提交成功'
|
||
|
||
# 驗證資料庫
|
||
assessment = db_session.query(Assessment).filter_by(
|
||
id=data['assessment_id']
|
||
).first()
|
||
|
||
assert assessment is not None
|
||
assert assessment.department == "技術部"
|
||
assert assessment.position == "資深工程師"
|
||
|
||
def test_post_assessment_missing_required_field(self, client):
|
||
"""
|
||
測試目標: POST /api/assessments with missing field
|
||
|
||
測試案例: 缺少必填欄位department
|
||
|
||
預期結果:
|
||
- 狀態碼: 400
|
||
- 錯誤訊息明確
|
||
"""
|
||
invalid_data = {
|
||
"position": "工程師",
|
||
"assessment_data": {"L1": []}
|
||
}
|
||
|
||
response = client.post(
|
||
'/api/assessments',
|
||
json=invalid_data,
|
||
content_type='application/json'
|
||
)
|
||
|
||
assert response.status_code == 400
|
||
|
||
data = response.get_json()
|
||
assert data['success'] is False
|
||
assert 'department' in data['message'].lower()
|
||
|
||
def test_post_assessment_invalid_json(self, client):
|
||
"""
|
||
測試目標: POST /api/assessments with invalid JSON
|
||
|
||
預期結果:
|
||
- 狀態碼: 400
|
||
- 錯誤訊息
|
||
"""
|
||
response = client.post(
|
||
'/api/assessments',
|
||
data='invalid json string',
|
||
content_type='application/json'
|
||
)
|
||
|
||
assert response.status_code == 400
|
||
|
||
def test_get_assessments_with_pagination(self, client, db_session):
|
||
"""
|
||
測試目標: GET /api/assessments with pagination
|
||
|
||
測試資料: 25筆評估記錄
|
||
請求參數: page=2, per_page=10
|
||
|
||
預期結果:
|
||
- 狀態碼: 200
|
||
- 回傳第2頁的10筆資料
|
||
- 包含分頁資訊
|
||
"""
|
||
# 建立25筆測試資料
|
||
for i in range(25):
|
||
assessment = Assessment(
|
||
department="部門" + str(i),
|
||
position="職位" + str(i),
|
||
assessment_data={}
|
||
)
|
||
db_session.add(assessment)
|
||
db_session.commit()
|
||
|
||
# 請求第2頁
|
||
response = client.get('/api/assessments?page=2&per_page=10')
|
||
|
||
assert response.status_code == 200
|
||
|
||
data = response.get_json()
|
||
assert len(data['assessments']) == 10
|
||
assert data['page'] == 2
|
||
assert data['per_page'] == 10
|
||
assert data['total'] == 25
|
||
assert data['pages'] == 3
|
||
|
||
def test_get_assessments_with_filters(self, client, db_session):
|
||
"""
|
||
測試目標: GET /api/assessments with filters
|
||
|
||
測試資料:
|
||
- 技術部-工程師: 3筆
|
||
- 技術部-專員: 2筆
|
||
- 業務部-工程師: 2筆
|
||
|
||
篩選條件: department=技術部, position=工程師
|
||
|
||
預期結果: 只回傳3筆符合條件的資料
|
||
"""
|
||
test_data = [
|
||
("技術部", "工程師"),
|
||
("技術部", "工程師"),
|
||
("技術部", "工程師"),
|
||
("技術部", "專員"),
|
||
("技術部", "專員"),
|
||
("業務部", "工程師"),
|
||
("業務部", "工程師"),
|
||
]
|
||
|
||
for dept, pos in test_data:
|
||
assessment = Assessment(
|
||
department=dept,
|
||
position=pos,
|
||
assessment_data={}
|
||
)
|
||
db_session.add(assessment)
|
||
db_session.commit()
|
||
|
||
response = client.get('/api/assessments?department=技術部&position=工程師')
|
||
|
||
assert response.status_code == 200
|
||
|
||
data = response.get_json()
|
||
assert data['total'] == 3
|
||
|
||
# 驗證所有資料都符合篩選條件
|
||
for assessment in data['assessments']:
|
||
assert assessment['department'] == '技術部'
|
||
assert assessment['position'] == '工程師'
|
||
```
|
||
|
||
### 6.2 STAR Feedback API 測試
|
||
|
||
```python
|
||
class TestStarFeedbackAPI:
|
||
"""STAR回饋API整合測試"""
|
||
|
||
def test_post_star_feedback_success(self, client, db_session, sample_star_feedback):
|
||
"""
|
||
測試目標: POST /api/star-feedbacks
|
||
|
||
測試步驟:
|
||
1. 提交有效的STAR回饋
|
||
2. 驗證回應
|
||
3. 確認積分已更新
|
||
|
||
預期結果:
|
||
- 狀態碼: 201
|
||
- 回傳成功訊息與積分
|
||
- employee_points表已更新
|
||
"""
|
||
response = client.post(
|
||
'/api/star-feedbacks',
|
||
json=sample_star_feedback,
|
||
content_type='application/json'
|
||
)
|
||
|
||
# 驗證回應
|
||
assert response.status_code == 201
|
||
|
||
data = response.get_json()
|
||
assert data['success'] is True
|
||
assert data['points_earned'] == 40 # score 4 * 10
|
||
|
||
# 驗證資料庫 - 回饋記錄
|
||
feedback = db_session.query(StarFeedback).filter_by(
|
||
evaluatee_name="員工B"
|
||
).first()
|
||
|
||
assert feedback is not None
|
||
assert feedback.score == 4
|
||
assert feedback.points_earned == 40
|
||
|
||
# 驗證資料庫 - 積分更新
|
||
employee = db_session.query(EmployeePoints).filter_by(
|
||
employee_name="員工B"
|
||
).first()
|
||
|
||
assert employee is not None
|
||
assert employee.total_points == 40
|
||
assert employee.monthly_points == 40
|
||
|
||
def test_post_star_feedback_accumulate_points(self, client, db_session):
|
||
"""
|
||
測試目標: 多次回饋的積分累積
|
||
|
||
測試步驟:
|
||
1. 提交第一次回饋(評分3)
|
||
2. 提交第二次回饋(評分5)
|
||
3. 驗證積分正確累加
|
||
|
||
預期結果: total_points = 80 (30 + 50)
|
||
"""
|
||
feedback1 = {
|
||
"evaluator_name": "主管A",
|
||
"evaluatee_name": "員工C",
|
||
"evaluatee_department": "技術部",
|
||
"evaluatee_position": "工程師",
|
||
"situation": "情境描述內容足夠長度測試",
|
||
"task": "任務描述內容足夠長度測試",
|
||
"action": "行動描述內容足夠長度測試",
|
||
"result": "結果描述內容足夠長度測試",
|
||
"score": 3,
|
||
"feedback_date": "2025-10-15"
|
||
}
|
||
|
||
feedback2 = feedback1.copy()
|
||
feedback2["score"] = 5
|
||
feedback2["evaluator_name"] = "主管B"
|
||
|
||
# 第一次回饋
|
||
response1 = client.post('/api/star-feedbacks', json=feedback1)
|
||
assert response1.status_code == 201
|
||
|
||
# 第二次回饋
|
||
response2 = client.post('/api/star-feedbacks', json=feedback2)
|
||
assert response2.status_code == 201
|
||
|
||
# 驗證積分累積
|
||
employee = db_session.query(EmployeePoints).filter_by(
|
||
employee_name="員工C"
|
||
).first()
|
||
|
||
assert employee.total_points == 80
|
||
assert employee.monthly_points == 80
|
||
|
||
def test_post_star_feedback_invalid_score(self, client, sample_star_feedback):
|
||
"""
|
||
測試目標: 無效評分處理
|
||
|
||
測試案例: score = 6 (超出範圍)
|
||
|
||
預期結果:
|
||
- 狀態碼: 400
|
||
- 錯誤訊息
|
||
"""
|
||
invalid_feedback = sample_star_feedback.copy()
|
||
invalid_feedback["score"] = 6
|
||
|
||
response = client.post('/api/star-feedbacks', json=invalid_feedback)
|
||
|
||
assert response.status_code == 400
|
||
data = response.get_json()
|
||
assert data['success'] is False
|
||
assert 'score' in data['message'].lower()
|
||
|
||
def test_post_star_feedback_short_content(self, client, sample_star_feedback):
|
||
"""
|
||
測試目標: STAR內容長度驗證
|
||
|
||
測試案例: situation欄位少於10字元
|
||
|
||
預期結果:
|
||
- 狀態碼: 400
|
||
- 錯誤訊息指出內容太短
|
||
"""
|
||
invalid_feedback = sample_star_feedback.copy()
|
||
invalid_feedback["situation"] = "太短"
|
||
|
||
response = client.post('/api/star-feedbacks', json=invalid_feedback)
|
||
|
||
assert response.status_code == 400
|
||
data = response.get_json()
|
||
assert 'situation' in data['message'].lower()
|
||
```
|
||
|
||
### 6.3 Rankings API 測試
|
||
|
||
```python
|
||
class TestRankingsAPI:
|
||
"""排名API整合測試"""
|
||
|
||
def test_get_total_rankings_success(self, client, db_session):
|
||
"""
|
||
測試目標: GET /api/rankings/total
|
||
|
||
測試資料: 5名員工,不同積分
|
||
|
||
預期結果:
|
||
- 狀態碼: 200
|
||
- 按積分降序排列
|
||
- 包含所有必要資訊
|
||
"""
|
||
# 建立測試資料
|
||
employees = [
|
||
("員工A", 150),
|
||
("員工B", 120),
|
||
("員工C", 100),
|
||
("員工D", 80),
|
||
("員工E", 50),
|
||
]
|
||
|
||
for name, points in employees:
|
||
emp = EmployeePoints(
|
||
employee_name=name,
|
||
department="技術部",
|
||
position="工程師",
|
||
total_points=points,
|
||
monthly_points=points
|
||
)
|
||
db_session.add(emp)
|
||
db_session.commit()
|
||
|
||
response = client.get('/api/rankings/total')
|
||
|
||
assert response.status_code == 200
|
||
|
||
data = response.get_json()
|
||
rankings = data['rankings']
|
||
|
||
assert len(rankings) == 5
|
||
assert rankings[0]['employee_name'] == '員工A'
|
||
assert rankings[0]['rank'] == 1
|
||
assert rankings[0]['total_points'] == 150
|
||
|
||
# 驗證排序
|
||
for i in range(len(rankings) - 1):
|
||
assert rankings[i]['total_points'] >= rankings[i+1]['total_points']
|
||
|
||
def test_get_total_rankings_with_department_filter(self, client, db_session):
|
||
"""
|
||
測試目標: GET /api/rankings/total?department=技術部
|
||
|
||
測試資料:
|
||
- 技術部: 3名員工
|
||
- 業務部: 2名員工
|
||
|
||
預期結果: 只回傳技術部的3名員工
|
||
"""
|
||
employees = [
|
||
("員工A", "技術部", 100),
|
||
("員工B", "技術部", 80),
|
||
("員工C", "技術部", 60),
|
||
("員工D", "業務部", 90),
|
||
("員工E", "業務部", 70),
|
||
]
|
||
|
||
for name, dept, points in employees:
|
||
emp = EmployeePoints(
|
||
employee_name=name,
|
||
department=dept,
|
||
position="工程師",
|
||
total_points=points,
|
||
monthly_points=points
|
||
)
|
||
db_session.add(emp)
|
||
db_session.commit()
|
||
|
||
response = client.get('/api/rankings/total?department=技術部')
|
||
|
||
assert response.status_code == 200
|
||
|
||
data = response.get_json()
|
||
rankings = data['rankings']
|
||
|
||
assert len(rankings) == 3
|
||
for ranking in rankings:
|
||
assert ranking['department'] == '技術部'
|
||
|
||
def test_get_monthly_rankings_success(self, client, db_session):
|
||
"""
|
||
測試目標: GET /api/rankings/monthly?year=2025&month=10
|
||
|
||
測試步驟:
|
||
1. 建立月度排名資料
|
||
2. 查詢特定月份排名
|
||
|
||
預期結果:
|
||
- 狀態碼: 200
|
||
- 回傳該月份的排名資料
|
||
"""
|
||
from datetime import date
|
||
|
||
ranking_month = date(2025, 10, 1)
|
||
|
||
rankings_data = [
|
||
("員工A", 100, 1),
|
||
("員工B", 80, 2),
|
||
("員工C", 60, 3),
|
||
]
|
||
|
||
for name, points, rank in rankings_data:
|
||
ranking = MonthlyRanking(
|
||
ranking_month=ranking_month,
|
||
employee_name=name,
|
||
department="技術部",
|
||
position="工程師",
|
||
total_points=points,
|
||
ranking=rank
|
||
)
|
||
db_session.add(ranking)
|
||
db_session.commit()
|
||
|
||
response = client.get('/api/rankings/monthly?year=2025&month=10')
|
||
|
||
assert response.status_code == 200
|
||
|
||
data = response.get_json()
|
||
assert data['year'] == 2025
|
||
assert data['month'] == 10
|
||
assert len(data['rankings']) == 3
|
||
|
||
def test_post_calculate_rankings_success(self, client, db_session):
|
||
"""
|
||
測試目標: POST /api/rankings/calculate
|
||
|
||
測試步驟:
|
||
1. 建立員工積分資料
|
||
2. 呼叫排名計算API
|
||
3. 驗證排名已生成
|
||
|
||
預期結果:
|
||
- 狀態碼: 200
|
||
- monthly_rankings表有新資料
|
||
- monthly_points已重置
|
||
"""
|
||
# 建立測試資料
|
||
employees = [
|
||
("員工A", 100),
|
||
("員工B", 80),
|
||
]
|
||
|
||
for name, points in employees:
|
||
emp = EmployeePoints(
|
||
employee_name=name,
|
||
department="技術部",
|
||
position="工程師",
|
||
total_points=points,
|
||
monthly_points=points
|
||
)
|
||
db_session.add(emp)
|
||
db_session.commit()
|
||
|
||
# 呼叫計算API
|
||
response = client.post(
|
||
'/api/rankings/calculate',
|
||
json={"year": 2025, "month": 10}
|
||
)
|
||
|
||
assert response.status_code == 200
|
||
|
||
data = response.get_json()
|
||
assert data['success'] is True
|
||
|
||
# 驗證排名已生成
|
||
from datetime import date
|
||
rankings = db_session.query(MonthlyRanking).filter_by(
|
||
ranking_month=date(2025, 10, 1)
|
||
).all()
|
||
|
||
assert len(rankings) == 2
|
||
|
||
# 驗證monthly_points已重置
|
||
for emp_name, _ in employees:
|
||
emp = db_session.query(EmployeePoints).filter_by(
|
||
employee_name=emp_name
|
||
).first()
|
||
assert emp.monthly_points == 0
|
||
```
|
||
|
||
---
|
||
|
||
## 7. 資料匯出測試
|
||
|
||
**測試檔案**: `tests/integration/test_export.py`
|
||
|
||
```python
|
||
class TestExportFunctionality:
|
||
"""資料匯出功能測試"""
|
||
|
||
def test_export_assessments_to_excel(self, client, db_session):
|
||
"""
|
||
測試目標: GET /api/export/assessments?format=excel
|
||
|
||
測試步驟:
|
||
1. 建立測試資料
|
||
2. 請求Excel匯出
|
||
3. 驗證檔案格式與內容
|
||
|
||
預期結果:
|
||
- 狀態碼: 200
|
||
- Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
|
||
- 檔案可被openpyxl讀取
|
||
"""
|
||
# 建立測試資料
|
||
for i in range(5):
|
||
assessment = Assessment(
|
||
department="技術部",
|
||
position="工程師" + str(i),
|
||
employee_name="員工" + str(i),
|
||
assessment_data={"L1": ["能力1"]}
|
||
)
|
||
db_session.add(assessment)
|
||
db_session.commit()
|
||
|
||
response = client.get('/api/export/assessments?format=excel')
|
||
|
||
assert response.status_code == 200
|
||
assert 'application/vnd.openxmlformats' in response.content_type
|
||
|
||
# 驗證Excel檔案可讀取
|
||
import io
|
||
from openpyxl import load_workbook
|
||
|
||
excel_file = io.BytesIO(response.data)
|
||
workbook = load_workbook(excel_file)
|
||
sheet = workbook.active
|
||
|
||
# 驗證有表頭
|
||
assert sheet.cell(1, 1).value is not None
|
||
|
||
# 驗證有資料(5筆 + 1筆表頭)
|
||
assert sheet.max_row == 6
|
||
|
||
def test_export_assessments_to_csv(self, client, db_session):
|
||
"""
|
||
測試目標: GET /api/export/assessments?format=csv
|
||
|
||
預期結果:
|
||
- 狀態碼: 200
|
||
- Content-Type: text/csv
|
||
- UTF-8 BOM編碼(Excel相容)
|
||
"""
|
||
# 建立測試資料
|
||
assessment = Assessment(
|
||
department="技術部",
|
||
position="工程師",
|
||
employee_name="測試員工",
|
||
assessment_data={}
|
||
)
|
||
db_session.add(assessment)
|
||
db_session.commit()
|
||
|
||
response = client.get('/api/export/assessments?format=csv')
|
||
|
||
assert response.status_code == 200
|
||
assert 'text/csv' in response.content_type
|
||
|
||
# 驗證UTF-8 BOM
|
||
assert response.data.startswith(b'\xef\xbb\xbf')
|
||
|
||
def test_export_star_feedbacks_with_filters(self, client, db_session):
|
||
"""
|
||
測試目標: 匯出時套用篩選條件
|
||
|
||
測試資料:
|
||
- 技術部: 3筆
|
||
- 業務部: 2筆
|
||
|
||
篩選條件: department=技術部
|
||
|
||
預期結果: 只匯出技術部的3筆資料
|
||
"""
|
||
# 建立測試資料
|
||
for i in range(3):
|
||
feedback = StarFeedback(
|
||
evaluator_name="主管",
|
||
evaluatee_name=f"員工{i}",
|
||
evaluatee_department="技術部",
|
||
evaluatee_position="工程師",
|
||
situation="S", task="T", action="A", result="R",
|
||
score=3,
|
||
feedback_date="2025-10-15"
|
||
)
|
||
db_session.add(feedback)
|
||
|
||
for i in range(2):
|
||
feedback = StarFeedback(
|
||
evaluator_name="主管",
|
||
evaluatee_name=f"員工{i+10}",
|
||
evaluatee_department="業務部",
|
||
evaluatee_position="專員",
|
||
situation="S", task="T", action="A", result="R",
|
||
score=3,
|
||
feedback_date="2025-10-15"
|
||
)
|
||
db_session.add(feedback)
|
||
db_session.commit()
|
||
|
||
response = client.get(
|
||
'/api/export/star-feedbacks?format=excel&department=技術部'
|
||
)
|
||
|
||
assert response.status_code == 200
|
||
|
||
# 驗證只有技術部資料
|
||
import io
|
||
from openpyxl import load_workbook
|
||
|
||
excel_file = io.BytesIO(response.data)
|
||
workbook = load_workbook(excel_file)
|
||
sheet = workbook.active
|
||
|
||
# 3筆資料 + 1筆表頭
|
||
assert sheet.max_row == 4
|
||
|
||
def test_export_invalid_format(self, client):
|
||
"""
|
||
測試目標: 無效的匯出格式
|
||
|
||
測試案例: format=pdf (不支援)
|
||
|
||
預期結果:
|
||
- 狀態碼: 400
|
||
- 錯誤訊息
|
||
"""
|
||
response = client.get('/api/export/assessments?format=pdf')
|
||
|
||
assert response.status_code == 400
|
||
data = response.get_json()
|
||
assert data['success'] is False
|
||
assert 'format' in data['message'].lower()
|
||
```
|
||
|
||
---
|
||
|
||
## 8. 端到端測試 (E2E)
|
||
|
||
**測試檔案**: `tests/e2e/test_workflows.py`
|
||
|
||
```python
|
||
class TestCompleteWorkflows:
|
||
"""完整業務流程端到端測試"""
|
||
|
||
def test_complete_assessment_workflow(self, client, db_session, sample_capability):
|
||
"""
|
||
測試目標: 完整評估流程
|
||
|
||
流程步驟:
|
||
1. 取得能力項目清單
|
||
2. 提交評估
|
||
3. 查詢評估記錄
|
||
4. 驗證資料正確
|
||
|
||
預期結果: 整個流程順利完成
|
||
"""
|
||
# Step 1: 建立能力項目
|
||
db_session.add(sample_capability)
|
||
db_session.commit()
|
||
|
||
# Step 2: 取得能力清單
|
||
response1 = client.get('/api/capabilities')
|
||
assert response1.status_code == 200
|
||
capabilities = response1.get_json()['capabilities']
|
||
assert len(capabilities) > 0
|
||
|
||
# Step 3: 提交評估
|
||
assessment_data = {
|
||
"department": "技術部",
|
||
"position": "工程師",
|
||
"employee_name": "測試員工",
|
||
"assessment_data": {
|
||
"L1": [capabilities[0]['name']],
|
||
"L2": [],
|
||
"L3": [],
|
||
"L4": [],
|
||
"L5": []
|
||
}
|
||
}
|
||
|
||
response2 = client.post('/api/assessments', json=assessment_data)
|
||
assert response2.status_code == 201
|
||
assessment_id = response2.get_json()['assessment_id']
|
||
|
||
# Step 4: 查詢評估記錄
|
||
response3 = client.get('/api/assessments')
|
||
assert response3.status_code == 200
|
||
assessments = response3.get_json()['assessments']
|
||
|
||
# Step 5: 驗證資料
|
||
found = False
|
||
for assessment in assessments:
|
||
if assessment['id'] == assessment_id:
|
||
found = True
|
||
assert assessment['department'] == "技術部"
|
||
assert assessment['employee_name'] == "測試員工"
|
||
break
|
||
|
||
assert found is True
|
||
|
||
def test_complete_star_feedback_and_ranking_workflow(self, client, db_session):
|
||
"""
|
||
測試目標: STAR回饋到排名的完整流程
|
||
|
||
流程步驟:
|
||
1. 提交多筆STAR回饋給不同員工
|
||
2. 驗證積分更新
|
||
3. 計算月度排名
|
||
4. 查詢排行榜
|
||
5. 驗證排名正確
|
||
|
||
預期結果: 積分與排名完整運作
|
||
"""
|
||
# Step 1: 提交回饋給員工A (評分5)
|
||
feedback_a = {
|
||
"evaluator_name": "主管1",
|
||
"evaluatee_name": "員工A",
|
||
"evaluatee_department": "技術部",
|
||
"evaluatee_position": "工程師",
|
||
"situation": "情境描述需要足夠長度才能通過驗證測試",
|
||
"task": "任務描述需要足夠長度才能通過驗證測試",
|
||
"action": "行動描述需要足夠長度才能通過驗證測試",
|
||
"result": "結果描述需要足夠長度才能通過驗證測試",
|
||
"score": 5,
|
||
"feedback_date": "2025-10-15"
|
||
}
|
||
|
||
response1 = client.post('/api/star-feedbacks', json=feedback_a)
|
||
assert response1.status_code == 201
|
||
assert response1.get_json()['points_earned'] == 50
|
||
|
||
# Step 2: 提交回饋給員工B (評分3)
|
||
feedback_b = feedback_a.copy()
|
||
feedback_b['evaluatee_name'] = "員工B"
|
||
feedback_b['score'] = 3
|
||
|
||
response2 = client.post('/api/star-feedbacks', json=feedback_b)
|
||
assert response2.status_code == 201
|
||
assert response2.get_json()['points_earned'] == 30
|
||
|
||
# Step 3: 驗證積分
|
||
emp_a = db_session.query(EmployeePoints).filter_by(
|
||
employee_name="員工A"
|
||
).first()
|
||
emp_b = db_session.query(EmployeePoints).filter_by(
|
||
employee_name="員工B"
|
||
).first()
|
||
|
||
assert emp_a.total_points == 50
|
||
assert emp_b.total_points == 30
|
||
|
||
# Step 4: 計算排名
|
||
response3 = client.post(
|
||
'/api/rankings/calculate',
|
||
json={"year": 2025, "month": 10}
|
||
)
|
||
assert response3.status_code == 200
|
||
|
||
# Step 5: 查詢排行榜
|
||
response4 = client.get('/api/rankings/monthly?year=2025&month=10')
|
||
assert response4.status_code == 200
|
||
|
||
rankings = response4.get_json()['rankings']
|
||
assert len(rankings) == 2
|
||
|
||
# Step 6: 驗證排名順序
|
||
assert rankings[0]['employee_name'] == "員工A"
|
||
assert rankings[0]['ranking'] == 1
|
||
assert rankings[0]['total_points'] == 50
|
||
|
||
assert rankings[1]['employee_name'] == "員工B"
|
||
assert rankings[1]['ranking'] == 2
|
||
assert rankings[1]['total_points'] == 30
|
||
|
||
# Step 7: 驗證月度積分已重置
|
||
emp_a_updated = db_session.query(EmployeePoints).filter_by(
|
||
employee_name="員工A"
|
||
).first()
|
||
|
||
assert emp_a_updated.total_points == 50 # 保持
|
||
assert emp_a_updated.monthly_points == 0 # 重置
|
||
|
||
def test_export_after_data_creation(self, client, db_session):
|
||
"""
|
||
測試目標: 建立資料後匯出
|
||
|
||
流程步驟:
|
||
1. 建立評估與回饋資料
|
||
2. 匯出Excel
|
||
3. 匯出CSV
|
||
4. 驗證檔案內容
|
||
|
||
預期結果: 匯出檔案包含所有建立的資料
|
||
"""
|
||
# Step 1: 建立評估資料
|
||
assessment = Assessment(
|
||
department="技術部",
|
||
position="工程師",
|
||
employee_name="匯出測試員工",
|
||
assessment_data={"L1": ["能力1"]}
|
||
)
|
||
db_session.add(assessment)
|
||
db_session.commit()
|
||
|
||
# Step 2: 匯出Excel
|
||
response1 = client.get('/api/export/assessments?format=excel')
|
||
assert response1.status_code == 200
|
||
|
||
import io
|
||
from openpyxl import load_workbook
|
||
|
||
excel_file = io.BytesIO(response1.data)
|
||
workbook = load_workbook(excel_file)
|
||
sheet = workbook.active
|
||
|
||
# 驗證資料存在
|
||
found = False
|
||
for row in sheet.iter_rows(min_row=2, values_only=True):
|
||
if "匯出測試員工" in str(row):
|
||
found = True
|
||
break
|
||
|
||
assert found is True
|
||
|
||
# Step 3: 匯出CSV
|
||
response2 = client.get('/api/export/assessments?format=csv')
|
||
assert response2.status_code == 200
|
||
assert "匯出測試員工" in response2.data.decode('utf-8-sig')
|
||
```
|
||
|
||
---
|
||
|
||
## 9. 效能測試
|
||
|
||
**測試檔案**: `tests/performance/test_performance.py`
|
||
|
||
```python
|
||
import time
|
||
import pytest
|
||
|
||
class TestPerformance:
|
||
"""效能測試"""
|
||
|
||
def test_api_response_time_under_500ms(self, client, db_session):
|
||
"""
|
||
測試目標: API回應時間 < 500ms
|
||
|
||
測試API: GET /api/capabilities
|
||
|
||
預期結果: 95%請求在500ms內完成
|
||
"""
|
||
# 準備資料
|
||
for i in range(10):
|
||
capability = Capability(
|
||
name=f"能力{i}",
|
||
l1_description="描述",
|
||
l2_description="描述",
|
||
l3_description="描述",
|
||
l4_description="描述",
|
||
l5_description="描述"
|
||
)
|
||
db_session.add(capability)
|
||
db_session.commit()
|
||
|
||
# 執行100次請求
|
||
response_times = []
|
||
for _ in range(100):
|
||
start_time = time.time()
|
||
response = client.get('/api/capabilities')
|
||
end_time = time.time()
|
||
|
||
assert response.status_code == 200
|
||
response_times.append((end_time - start_time) * 1000) # 轉為毫秒
|
||
|
||
# 計算95百分位數
|
||
response_times.sort()
|
||
p95_index = int(len(response_times) * 0.95)
|
||
p95_time = response_times[p95_index]
|
||
|
||
print(f"\n95th percentile response time: {p95_time:.2f}ms")
|
||
assert p95_time < 500, f"95th percentile ({p95_time:.2f}ms) exceeds 500ms"
|
||
|
||
def test_database_query_optimization(self, db_session):
|
||
"""
|
||
測試目標: 資料庫查詢效能
|
||
|
||
測試情境: 查詢1000筆資料並分頁
|
||
|
||
預期結果: 單次查詢 < 100ms
|
||
"""
|
||
# 建立1000筆測試資料
|
||
for i in range(1000):
|
||
assessment = Assessment(
|
||
department=f"部門{i % 10}",
|
||
position=f"職位{i % 5}",
|
||
assessment_data={}
|
||
)
|
||
db_session.add(assessment)
|
||
db_session.commit()
|
||
|
||
# 測試分頁查詢
|
||
start_time = time.time()
|
||
|
||
results = db_session.query(Assessment)\
|
||
.filter_by(department="部門1")\
|
||
.limit(20)\
|
||
.offset(0)\
|
||
.all()
|
||
|
||
end_time = time.time()
|
||
query_time = (end_time - start_time) * 1000
|
||
|
||
print(f"\nQuery time: {query_time:.2f}ms")
|
||
assert query_time < 100, f"Query time ({query_time:.2f}ms) exceeds 100ms"
|
||
|
||
def test_concurrent_requests(self, client):
|
||
"""
|
||
測試目標: 並發請求處理
|
||
|
||
測試情境: 50個並發請求
|
||
|
||
預期結果: 所有請求成功完成
|
||
"""
|
||
import concurrent.futures
|
||
|
||
def make_request():
|
||
response = client.get('/api/capabilities')
|
||
return response.status_code
|
||
|
||
# 執行50個並發請求
|
||
with concurrent.futures.ThreadPoolExecutor(max_workers=50) as executor:
|
||
futures = [executor.submit(make_request) for _ in range(50)]
|
||
results = [f.result() for f in concurrent.futures.as_completed(futures)]
|
||
|
||
# 驗證所有請求成功
|
||
assert all(status == 200 for status in results)
|
||
assert len(results) == 50
|
||
```
|
||
|
||
---
|
||
|
||
## 10. 測試執行與CI/CD
|
||
|
||
### 10.1 本地測試執行
|
||
|
||
```bash
|
||
# 安裝測試依賴
|
||
pip install pytest pytest-cov pytest-mock
|
||
|
||
# 執行所有測試
|
||
pytest
|
||
|
||
# 執行特定測試檔案
|
||
pytest tests/unit/test_models.py
|
||
|
||
# 執行特定測試類別
|
||
pytest tests/unit/test_models.py::TestAssessmentModel
|
||
|
||
# 執行特定測試案例
|
||
pytest tests/unit/test_models.py::TestAssessmentModel::test_create_assessment_success
|
||
|
||
# 顯示詳細輸出
|
||
pytest -v
|
||
|
||
# 產生覆蓋率報告
|
||
pytest --cov=app --cov-report=html
|
||
|
||
# 只執行失敗的測試
|
||
pytest --lf
|
||
|
||
# 並行執行測試
|
||
pytest -n auto
|
||
```
|
||
|
||
### 10.2 pytest.ini 配置
|
||
|
||
```ini
|
||
[pytest]
|
||
# 測試路徑
|
||
testpaths = tests
|
||
|
||
# Python檔案模式
|
||
python_files = test_*.py
|
||
|
||
# 測試類別模式
|
||
python_classes = Test*
|
||
|
||
# 測試函數模式
|
||
python_functions = test_*
|
||
|
||
# 標記
|
||
markers =
|
||
unit: Unit tests
|
||
integration: Integration tests
|
||
e2e: End-to-end tests
|
||
slow: Slow running tests
|
||
performance: Performance tests
|
||
|
||
# 覆蓋率設定
|
||
addopts =
|
||
--strict-markers
|
||
--cov=app
|
||
--cov-report=term-missing
|
||
--cov-report=html
|
||
--cov-fail-under=70
|
||
-ra
|
||
|
||
# 忽略警告
|
||
filterwarnings =
|
||
ignore::DeprecationWarning
|
||
```
|
||
|
||
### 10.3 GitHub Actions CI/CD
|
||
|
||
**.github/workflows/tests.yml**:
|
||
|
||
```yaml
|
||
name: Tests
|
||
|
||
on:
|
||
push:
|
||
branches: [ main, develop ]
|
||
pull_request:
|
||
branches: [ main, develop ]
|
||
|
||
jobs:
|
||
test:
|
||
runs-on: ubuntu-latest
|
||
|
||
services:
|
||
mysql:
|
||
image: mysql:8.0
|
||
env:
|
||
MYSQL_ROOT_PASSWORD: test_password
|
||
MYSQL_DATABASE: test_db
|
||
ports:
|
||
- 3306:3306
|
||
options: >-
|
||
--health-cmd="mysqladmin ping"
|
||
--health-interval=10s
|
||
--health-timeout=5s
|
||
--health-retries=3
|
||
|
||
steps:
|
||
- uses: actions/checkout@v3
|
||
|
||
- name: Set up Python
|
||
uses: actions/setup-python@v4
|
||
with:
|
||
python-version: '3.10'
|
||
|
||
- name: Install dependencies
|
||
run: |
|
||
python -m pip install --upgrade pip
|
||
pip install -r requirements.txt
|
||
pip install pytest pytest-cov
|
||
|
||
- name: Wait for MySQL
|
||
run: |
|
||
while ! mysqladmin ping -h"127.0.0.1" -P3306 --silent; do
|
||
sleep 1
|
||
done
|
||
|
||
- name: Run unit tests
|
||
env:
|
||
DB_HOST: 127.0.0.1
|
||
DB_PORT: 3306
|
||
DB_NAME: test_db
|
||
DB_USER: root
|
||
DB_PASSWORD: test_password
|
||
SECRET_KEY: test_secret_key_for_ci
|
||
run: |
|
||
pytest tests/unit -v --cov=app --cov-report=xml
|
||
|
||
- name: Run integration tests
|
||
env:
|
||
DB_HOST: 127.0.0.1
|
||
DB_PORT: 3306
|
||
DB_NAME: test_db
|
||
DB_USER: root
|
||
DB_PASSWORD: test_password
|
||
SECRET_KEY: test_secret_key_for_ci
|
||
run: |
|
||
pytest tests/integration -v
|
||
|
||
- name: Upload coverage to Codecov
|
||
uses: codecov/codecov-action@v3
|
||
with:
|
||
file: ./coverage.xml
|
||
fail_ci_if_error: true
|
||
```
|
||
|
||
### 10.4 測試報告範例
|
||
|
||
```
|
||
======================== test session starts =========================
|
||
platform linux -- Python 3.10.0, pytest-7.4.0, pluggy-1.3.0
|
||
rootdir: /app
|
||
plugins: cov-4.1.0
|
||
collected 156 items
|
||
|
||
tests/unit/test_models.py::TestAssessmentModel::test_create_assessment_success PASSED [ 1%]
|
||
tests/unit/test_models.py::TestAssessmentModel::test_assessment_requires_department PASSED [ 2%]
|
||
tests/unit/test_models.py::TestAssessmentModel::test_assessment_json_serialization PASSED [ 3%]
|
||
...
|
||
tests/e2e/test_workflows.py::TestCompleteWorkflows::test_complete_star_feedback_and_ranking_workflow PASSED [100%]
|
||
|
||
---------- coverage: platform linux, python 3.10.0 -----------
|
||
Name Stmts Miss Cover Missing
|
||
------------------------------------------------------------
|
||
app/__init__.py 45 2 96% 78-79
|
||
app/models.py 120 8 93% 45, 89-95
|
||
app/routes.py 230 15 93% 125, 189-195, 256
|
||
app/services.py 145 5 97% 78-82
|
||
app/validators.py 95 3 97% 56-58
|
||
app/utils.py 40 1 98% 23
|
||
------------------------------------------------------------
|
||
TOTAL 675 34 95%
|
||
|
||
===================== 156 passed in 45.23s ======================
|
||
```
|
||
|
||
---
|
||
|
||
## 11. 測試資料管理
|
||
|
||
### 11.1 測試資料Fixtures
|
||
|
||
**tests/fixtures/sample_data.py**:
|
||
|
||
```python
|
||
"""測試資料fixtures"""
|
||
|
||
def get_sample_assessments():
|
||
"""範例評估資料集"""
|
||
return [
|
||
{
|
||
"department": "技術部",
|
||
"position": "資深工程師",
|
||
"employee_name": "張三",
|
||
"assessment_data": {
|
||
"L1": [],
|
||
"L2": ["程式設計與開發"],
|
||
"L3": ["系統分析與設計"],
|
||
"L4": ["專案管理"],
|
||
"L5": []
|
||
}
|
||
},
|
||
{
|
||
"department": "業務部",
|
||
"position": "業務經理",
|
||
"employee_name": "李四",
|
||
"assessment_data": {
|
||
"L1": [],
|
||
"L2": [],
|
||
"L3": ["客戶關係管理"],
|
||
"L4": ["業務策略規劃"],
|
||
"L5": []
|
||
}
|
||
}
|
||
]
|
||
|
||
def get_sample_star_feedbacks():
|
||
"""範例STAR回饋資料集"""
|
||
return [
|
||
{
|
||
"evaluator_name": "王主管",
|
||
"evaluatee_name": "張三",
|
||
"evaluatee_department": "技術部",
|
||
"evaluatee_position": "資深工程師",
|
||
"situation": "專案進度落後,客戶要求提前交付核心功能",
|
||
"task": "需要在一週內完成原本規劃兩週的開發任務",
|
||
"action": "重新評估優先級,與團隊協調資源分配,採用敏捷開發方法快速迭代",
|
||
"result": "成功提前三天交付功能,客戶滿意度提升,專案獲得額外預算",
|
||
"score": 5,
|
||
"feedback_date": "2025-10-01"
|
||
},
|
||
{
|
||
"evaluator_name": "陳主管",
|
||
"evaluatee_name": "李四",
|
||
"evaluatee_department": "業務部",
|
||
"evaluatee_position": "業務經理",
|
||
"situation": "重要客戶考慮終止合作,需要緊急挽回",
|
||
"task": "在一個月內重建客戶信任並續約",
|
||
"action": "深入了解客戶痛點,提出客製化解決方案,每週親自拜訪追蹤",
|
||
"result": "客戶決定不僅續約還擴大合作範圍,年度合約金額增加30%",
|
||
"score": 5,
|
||
"feedback_date": "2025-10-05"
|
||
}
|
||
]
|
||
|
||
def get_sample_capabilities():
|
||
"""範例能力項目資料集"""
|
||
return [
|
||
{
|
||
"name": "程式設計與開發",
|
||
"l1_description": "能撰寫基本程式碼,完成簡單功能",
|
||
"l2_description": "能獨立開發中等複雜度功能,遵循編碼規範",
|
||
"l3_description": "能設計系統架構,優化程式效能",
|
||
"l4_description": "能指導團隊技術方向,制定開發標準",
|
||
"l5_description": "能規劃技術策略,引領技術創新"
|
||
},
|
||
{
|
||
"name": "專案管理",
|
||
"l1_description": "能執行分配的任務,按時回報進度",
|
||
"l2_description": "能獨立管理小型專案,協調資源",
|
||
"l3_description": "能處理複雜專案,解決跨部門協作問題",
|
||
"l4_description": "能領導多個專案,培養專案經理",
|
||
"l5_description": "能制定專案管理策略,建立PMO體系"
|
||
}
|
||
]
|
||
```
|
||
|
||
### 11.2 資料庫Seed腳本
|
||
|
||
**tests/seed_test_data.py**:
|
||
|
||
```python
|
||
"""測試資料庫初始化腳本"""
|
||
|
||
from app import create_app, db
|
||
from app.models import Assessment, Capability, StarFeedback, EmployeePoints
|
||
from tests.fixtures.sample_data import (
|
||
get_sample_assessments,
|
||
get_sample_star_feedbacks,
|
||
get_sample_capabilities
|
||
)
|
||
|
||
def seed_test_database():
|
||
"""初始化測試資料庫"""
|
||
app = create_app('testing')
|
||
|
||
with app.app_context():
|
||
# 清空資料庫
|
||
db.drop_all()
|
||
db.create_all()
|
||
|
||
# 建立能力項目
|
||
for cap_data in get_sample_capabilities():
|
||
capability = Capability(**cap_data)
|
||
db.session.add(capability)
|
||
|
||
# 建立評估資料
|
||
for assess_data in get_sample_assessments():
|
||
assessment = Assessment(**assess_data)
|
||
db.session.add(assessment)
|
||
|
||
# 建立STAR回饋
|
||
for feedback_data in get_sample_star_feedbacks():
|
||
feedback = StarFeedback(**feedback_data)
|
||
db.session.add(feedback)
|
||
|
||
# 更新員工積分
|
||
emp_name = feedback_data['evaluatee_name']
|
||
points = feedback_data['score'] * 10
|
||
|
||
employee = db.session.query(EmployeePoints).filter_by(
|
||
employee_name=emp_name
|
||
).first()
|
||
|
||
if not employee:
|
||
employee = EmployeePoints(
|
||
employee_name=emp_name,
|
||
department=feedback_data['evaluatee_department'],
|
||
position=feedback_data['evaluatee_position'],
|
||
total_points=points,
|
||
monthly_points=points
|
||
)
|
||
db.session.add(employee)
|
||
else:
|
||
employee.total_points += points
|
||
employee.monthly_points += points
|
||
|
||
db.session.commit()
|
||
print("✅ 測試資料初始化完成")
|
||
|
||
if __name__ == '__main__':
|
||
seed_test_database()
|
||
```
|
||
|
||
---
|
||
|
||
## 12. 測試最佳實踐
|
||
|
||
### 12.1 TDD開發流程
|
||
|
||
```
|
||
1. 寫一個失敗的測試 (RED)
|
||
├── 明確定義預期行為
|
||
├── 寫出測試案例
|
||
└── 執行測試,確認失敗
|
||
|
||
2. 寫最小程式碼讓測試通過 (GREEN)
|
||
├── 實作功能邏輯
|
||
├── 執行測試
|
||
└── 確認測試通過
|
||
|
||
3. 重構程式碼 (REFACTOR)
|
||
├── 優化程式結構
|
||
├── 移除重複代碼
|
||
├── 改善可讀性
|
||
└── 確保測試仍然通過
|
||
|
||
4. 重複循環
|
||
└── 繼續下一個功能
|
||
```
|
||
|
||
### 12.2 測試命名規範
|
||
|
||
```python
|
||
# ✅ 好的測試名稱
|
||
def test_create_assessment_with_valid_data_should_succeed():
|
||
"""測試名稱清楚描述: 動作 + 條件 + 預期結果"""
|
||
pass
|
||
|
||
def test_calculate_points_with_score_5_should_return_50():
|
||
"""明確的輸入與輸出"""
|
||
pass
|
||
|
||
# ❌ 不好的測試名稱
|
||
def test_assessment():
|
||
"""太籠統"""
|
||
pass
|
||
|
||
def test_1():
|
||
"""無意義"""
|
||
pass
|
||
```
|
||
|
||
### 12.3 測試獨立性原則
|
||
|
||
```python
|
||
# ✅ 每個測試獨立運作
|
||
class TestIndependent:
|
||
def test_case_1(self, db_session):
|
||
# 測試1有自己的資料
|
||
data = create_test_data()
|
||
# ... 執行測試
|
||
|
||
def test_case_2(self, db_session):
|
||
# 測試2不依賴測試1
|
||
data = create_different_test_data()
|
||
# ... 執行測試
|
||
|
||
# ❌ 測試之間有依賴性
|
||
class TestDependent:
|
||
shared_data = None
|
||
|
||
def test_case_1(self):
|
||
self.shared_data = create_data()
|
||
|
||
def test_case_2(self):
|
||
# 依賴test_case_1的結果 ❌
|
||
use_data(self.shared_data)
|
||
```
|
||
|
||
### 12.4 AAA模式
|
||
|
||
```python
|
||
def test_with_aaa_pattern():
|
||
"""
|
||
AAA模式: Arrange-Act-Assert
|
||
"""
|
||
# Arrange (準備): 設定測試資料與環境
|
||
employee = EmployeePoints(
|
||
employee_name="測試員工",
|
||
department="技術部",
|
||
position="工程師"
|
||
)
|
||
initial_points = 30
|
||
additional_points = 20
|
||
|
||
# Act (執行): 執行要測試的操作
|
||
employee.total_points = initial_points
|
||
employee.total_points += additional_points
|
||
|
||
# Assert (驗證): 驗證結果
|
||
assert employee.total_points == 50
|
||
```
|
||
|
||
### 12.5 測試覆蓋率目標
|
||
|
||
```
|
||
優先級排序:
|
||
1. 🔴 核心業務邏輯: 100% (積分計算、排名邏輯)
|
||
2. 🟡 API端點: 100% (所有路由)
|
||
3. 🟡 資料模型: 90%+ (CRUD操作)
|
||
4. 🟢 工具函數: 80%+ (輔助功能)
|
||
5. 🟢 UI邏輯: 70%+ (前端交互)
|
||
|
||
不追求100%覆蓋率:
|
||
- 簡單的getter/setter
|
||
- 框架自動生成的程式碼
|
||
- 第三方套件整合
|
||
```
|
||
|
||
---
|
||
|
||
## 13. 持續改進
|
||
|
||
### 13.1 測試債務追蹤
|
||
|
||
| 項目 | 當前狀態 | 目標 | 優先級 |
|
||
|-----|---------|------|--------|
|
||
| 單元測試覆蓋率 | 75% | 80%+ | 🔴 高 |
|
||
| 整合測試 | 60% | 70%+ | 🟡 中 |
|
||
| E2E測試 | 核心流程 | 完整流程 | 🟡 中 |
|
||
| 效能測試 | 基礎 | 完整benchmark | 🟢 低 |
|
||
| 安全測試 | 未實作 | SQL注入、XSS測試 | 🔴 高 |
|
||
|
||
### 13.2 測試審查檢查清單
|
||
|
||
```
|
||
□ 測試名稱清晰描述意圖
|
||
□ 使用AAA模式組織測試
|
||
□ 測試獨立且可重複執行
|
||
□ 包含正常與異常情境
|
||
□ 驗證邊界條件
|
||
□ Mock外部依賴
|
||
□ 測試執行速度合理(<10s)
|
||
□ 有適當的測試文件
|
||
```
|
||
|
||
### 13.3 定期測試維護
|
||
|
||
```python
|
||
# 每月審查
|
||
- 移除過時的測試
|
||
- 更新測試資料
|
||
- 檢查測試執行時間
|
||
- 修復不穩定的測試
|
||
|
||
# 每季審查
|
||
- 評估測試覆蓋率
|
||
- 重構重複的測試程式碼
|
||
- 更新測試策略
|
||
- 培訓團隊TDD實踐
|
||
```
|
||
|
||
---
|
||
|
||
## 14. 附錄
|
||
|
||
### 14.1 常用測試指令
|
||
|
||
```bash
|
||
# 快速測試
|
||
pytest -x # 遇到第一個失敗就停止
|
||
pytest --lf # 只執行上次失敗的測試
|
||
pytest -k "assessment" # 執行名稱包含"assessment"的測試
|
||
pytest -m unit # 執行標記為unit的測試
|
||
|
||
# 覆蓋率相關
|
||
pytest --cov=app # 顯示覆蓋率
|
||
pytest --cov-report=html # HTML格式報告
|
||
pytest --cov-report=term-missing # 顯示缺失的行號
|
||
|
||
# 效能分析
|
||
pytest --durations=10 # 顯示最慢的10個測試
|
||
pytest --profile # 執行效能分析
|
||
|
||
# 並行執行
|
||
pytest -n 4 # 使用4個worker並行
|
||
pytest -n auto # 自動決定worker數量
|
||
|
||
# Debug模式
|
||
pytest --pdb # 失敗時進入debugger
|
||
pytest -s # 顯示print輸出
|
||
pytest -vv # 非常詳細的輸出
|
||
```
|
||
|
||
### 14.2 Mock範例
|
||
|
||
```python
|
||
from unittest.mock import Mock, patch
|
||
|
||
def test_with_mock(db_session):
|
||
"""使用Mock測試外部依賴"""
|
||
|
||
# Mock資料庫查詢
|
||
with patch('app.services.db_session.query') as mock_query:
|
||
mock_query.return_value.filter_by.return_value.first.return_value = Mock(
|
||
employee_name="測試員工",
|
||
total_points=100
|
||
)
|
||
|
||
# 執行測試
|
||
result = get_employee_points("測試員工")
|
||
|
||
# 驗證
|
||
assert result == 100
|
||
mock_query.assert_called_once()
|
||
```
|
||
|
||
### 14.3 參考資源
|
||
|
||
- [pytest官方文件](https://docs.pytest.org/)
|
||
- [Test-Driven Development書籍](https://www.oreilly.com/library/view/test-driven-development/0321146530/)
|
||
- [Python Testing with pytest](https://pragprog.com/titles/bopytest/python-testing-with-pytest/)
|
||
- [Flask Testing文件](https://flask.palletsprojects.com/en/latest/testing/)
|
||
|
||
---
|
||
|
||
**文件結束**
|
||
|
||
**版本**: 1.0
|
||
**最後更新**: 2025年10月15日
|
||
**下次審查**: 2025年11月15日 |