2026.5.24
【应急演练子系统】测试与质量保障:单元测试、集成测试与Bug修复全记录
一、测试策略概述
1.1 测试金字塔
┌─────────────┐
│ E2E测试 │ 少量关键场景
│ (End to End)│
└──────┬──────┘
│
┌──────┴──────┐
│ 集成测试 │ API接口测试
│ (Integration)│ 数据库、Redis
└──────┬──────┘
│
┌────────────┼────────────┐
│ ┌───────┴───────┐ │
│ │ 单元测试 │ │ 最大覆盖
│ │ (Unit Test) │ │ 业务逻辑
│ └───────────────┘ │
└───────────────────────┘
1.2 测试工具选型
| 测试类型 | 工具 | 用途 |
|---|---|---|
| 单元测试 | pytest | Python测试框架 |
| 覆盖率 | pytest-cov | 代码覆盖率统计 |
| API测试 | FastAPI TestClient | HTTP接口测试 |
| Mock | pytest-mock | 模拟外部依赖 |
二、单元测试编写
2.1 测试项目结构
tests/
├── __init__.py
├── conftest.py # pytest配置和fixture
├── unit/
│ ├── __init__.py
│ ├── test_user_service.py
│ ├── test_drill_plan.py
│ ├── test_security.py
│ └── test_llm_service.py
├── integration/
│ ├── __init__.py
│ ├── test_auth_api.py
│ ├── test_drill_plan_api.py
│ └── test_ai_api.py
└── fixtures/
├── __init__.py
└── sample_data.py
2.2 pytest配置和Fixture
# tests/conftest.py
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from fastapi.testclient import TestClient
from app.core.database import Base, get_db
from app.main import app
# 测试数据库
TEST_DATABASE_URL = "sqlite:///./test.db"
engine = create_engine(TEST_DATABASE_URL, connect_args={"check_same_thread": False})
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
@pytest.fixture(scope="function")
def db():
"""创建测试数据库会话"""
Base.metadata.create_all(bind=engine)
db = TestingSessionLocal()
try:
yield db
finally:
db.close()
Base.metadata.drop_all(bind=engine)
@pytest.fixture(scope="function")
def client(db):
"""创建测试客户端"""
def override_get_db():
try:
yield db
finally:
pass
app.dependency_overrides[get_db] = override_get_db
with TestClient(app) as test_client:
yield test_client
app.dependency_overrides.clear()
@pytest.fixture
def sample_user(db):
"""创建测试用户"""
from app.models.user import User
from app.core.security import get_password_hash
user = User(
username="testuser",
password_hash=get_password_hash("testpass123"),
real_name="测试用户",
status=1
)
db.add(user)
db.commit()
db.refresh(user)
return user
@pytest.fixture
def auth_headers(client, sample_user):
"""获取认证后的请求头"""
response = client.post("/api/auth/login", json={
"username": "testuser",
"password": "testpass123"
})
token = response.json()["data"]["access_token"]
return {"Authorization": f"Bearer {token}"}
2.3 认证模块单元测试
# tests/unit/test_security.py
import pytest
from app.core.security import (
get_password_hash,
verify_password,
create_access_token,
decode_access_token
)
from jose import jwt
class TestPasswordHashing:
"""密码哈希测试"""
def test_password_hash_consistency(self):
"""同一密码多次哈希结果不同(salt)"""
password = "my_secure_password"
hash1 = get_password_hash(password)
hash2 = get_password_hash(password)
# bcrypt会生成不同的salt
assert hash1 != hash2
# 但验证都能通过
assert verify_password(password, hash1) is True
assert verify_password(password, hash2) is True
def test_wrong_password_rejected(self):
"""错误密码被拒绝"""
password = "correct_password"
wrong_password = "wrong_password"
hash_value = get_password_hash(password)
assert verify_password(wrong_password, hash_value) is False
def test_empty_password(self):
"""空密码处理"""
hash_value = get_password_hash("")
assert verify_password("", hash_value) is True
class TestJWTToken:
"""JWT Token测试"""
def test_token_creation_and_decode(self):
"""Token创建和解码"""
data = {"sub": "123", "username": "test"}
token = create_access_token(data)
# 解码验证
payload = jwt.decode(
token,
SECRET_KEY,
algorithms=[ALGORITHM]
)
assert payload["sub"] == "123"
assert payload["username"] == "test"
assert "exp" in payload
assert "login_time" in payload
def test_token_expiration(self):
"""Token过期测试"""
from datetime import timedelta
data = {"sub": "123"}
# 创建1秒过期的token
token = create_access_token(data, expires_delta=timedelta(seconds=1))
import time
time.sleep(2)
# 过期后解码应抛出异常
with pytest.raises(jwt.ExpiredSignatureError):
jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
2.4 CRUD模块单元测试
# tests/unit/test_drill_plan.py
import pytest
from app.crud.drill_plan import drill_plan_crud
from app.schemas.drill_plan import DrillPlanCreate
class TestDrillPlanCRUD:
"""演练计划CRUD测试"""
def test_create_plan(self, db):
"""创建演练计划"""
plan_data = DrillPlanCreate(
plan_no="PLAN-TEST-001",
department="安全部",
project_name="消防演练",
status=0
)
plan = drill_plan_crud.create(db=db, obj_in=plan_data)
assert plan.id is not None
assert plan.plan_no == "PLAN-TEST-001"
assert plan.department == "安全部"
assert plan.status == 0
def test_get_plan(self, db):
"""查询演练计划"""
plan_data = DrillPlanCreate(
plan_no="PLAN-TEST-002",
department="生产部",
project_name="地震演练"
)
created_plan = drill_plan_crud.create(db=db, obj_in=plan_data)
fetched_plan = drill_plan_crud.get(db=db, id=created_plan.id)
assert fetched_plan is not None
assert fetched_plan.plan_no == "PLAN-TEST-002"
def test_update_plan(self, db):
"""更新演练计划"""
plan_data = DrillPlanCreate(
plan_no="PLAN-TEST-003",
department="安全部",
project_name="泄漏演练"
)
plan = drill_plan_crud.create(db=db, obj_in=plan_data)
from app.schemas.drill_plan import DrillPlanUpdate
update_data = DrillPlanUpdate(
department="应急管理部",
status=1
)
updated_plan = drill_plan_crud.update(db=db, db_obj=plan, obj_in=update_data)
assert updated_plan.department == "应急管理部"
assert updated_plan.status == 1
def test_delete_plan(self, db):
"""删除演练计划"""
plan_data = DrillPlanCreate(
plan_no="PLAN-TEST-004",
department="安全部",
project_name="测试演练"
)
plan = drill_plan_crud.create(db=db, obj_in=plan_data)
plan_id = plan.id
drill_plan_crud.remove(db=db, id=plan_id)
deleted_plan = drill_plan_crud.get(db=db, id=plan_id)
assert deleted_plan is None
def test_get_multi_with_pagination(self, db):
"""分页查询"""
# 创建多条记录
for i in range(15):
plan_data = DrillPlanCreate(
plan_no=f"PLAN-TEST-{i:03d}",
department="安全部",
project_name=f"演练{i}"
)
drill_plan_crud.create(db=db, obj_in=plan_data)
# 第一页
page1 = drill_plan_crud.get_multi(db=db, skip=0, limit=10)
assert len(page1) == 10
# 第二页
page2 = drill_plan_crud.get_multi(db=db, skip=10, limit=10)
assert len(page2) == 5
三、集成测试
3.1 API集成测试
# tests/integration/test_drill_plan_api.py
import pytest
class TestDrillPlanAPI:
"""演练计划API集成测试"""
def test_create_plan_success(self, client, auth_headers):
"""创建计划成功"""
response = client.post(
"/api/drill-plan",
json={
"plan_no": "PLAN-API-001",
"department": "安全部",
"project_name": "API测试演练",
"content": "测试内容",
"status": 0
},
headers=auth_headers
)
assert response.status_code == 200
data = response.json()
assert data["code"] == 200
assert data["data"]["plan_no"] == "PLAN-API-001"
def test_create_plan_without_auth(self, client):
"""未认证创建计划应失败"""
response = client.post(
"/api/drill-plan",
json={
"plan_no": "PLAN-API-002",
"department": "安全部",
"project_name": "测试演练"
}
)
assert response.status_code == 403
def test_list_plans(self, client, auth_headers):
"""分页查询计划列表"""
# 先创建几条数据
for i in range(3):
client.post(
"/api/drill-plan",
json={
"plan_no": f"PLAN-LIST-{i}",
"department": "安全部",
"project_name": f"演练{i}"
},
headers=auth_headers
)
# 查询列表
response = client.get(
"/api/drill-plan/list",
params={"page": 1, "page_size": 10},
headers=auth_headers
)
assert response.status_code == 200
data = response.json()
assert data["code"] == 200
assert "items" in data["data"]
assert "total" in data["data"]
assert data["data"]["page"] == 1
def test_get_plan_detail(self, client, auth_headers):
"""获取计划详情"""
# 创建计划
create_response = client.post(
"/api/drill-plan",
json={
"plan_no": "PLAN-DETAIL-001",
"department": "安全部",
"project_name": "详情测试演练"
},
headers=auth_headers
)
plan_id = create_response.json()["data"]["id"]
# 获取详情
response = client.get(
f"/api/drill-plan/{plan_id}",
headers=auth_headers
)
assert response.status_code == 200
data = response.json()
assert data["data"]["id"] == plan_id
assert data["data"]["plan_no"] == "PLAN-DETAIL-001"
def test_update_plan(self, client, auth_headers):
"""更新计划"""
# 创建计划
create_response = client.post(
"/api/drill-plan",
json={
"plan_no": "PLAN-UPDATE-001",
"department": "安全部",
"project_name": "更新测试演练"
},
headers=auth_headers
)
plan_id = create_response.json()["data"]["id"]
# 更新
response = client.put(
f"/api/drill-plan/{plan_id}",
json={"department": "生产部", "status": 1},
headers=auth_headers
)
assert response.status_code == 200
assert response.json()["data"]["department"] == "生产部"
def test_delete_plan(self, client, auth_headers):
"""删除计划"""
# 创建计划
create_response = client.post(
"/api/drill-plan",
json={
"plan_no": "PLAN-DELETE-001",
"department": "安全部",
"project_name": "删除测试演练"
},
headers=auth_headers
)
plan_id = create_response.json()["data"]["id"]
# 删除
response = client.delete(
f"/api/drill-plan/{plan_id}",
headers=auth_headers
)
assert response.status_code == 200
# 确认已删除
get_response = client.get(
f"/api/drill-plan/{plan_id}",
headers=auth_headers
)
assert get_response.json()["code"] == 404
3.2 认证API测试
# tests/integration/test_auth_api.py
class TestAuthAPI:
"""认证API测试"""
def test_register_success(self, client):
"""注册成功"""
response = client.post(
"/api/auth/register",
json={
"username": "newuser",
"password": "password123",
"real_name": "新用户"
}
)
assert response.status_code == 200
data = response.json()
assert data["code"] == 200
assert data["data"]["username"] == "newuser"
def test_register_duplicate_username(self, client, sample_user):
"""用户名重复注册失败"""
response = client.post(
"/api/auth/register",
json={
"username": "testuser", # 已存在
"password": "password123",
"real_name": "另一个用户"
}
)
assert response.status_code == 200
assert response.json()["code"] == 400
assert "已存在" in response.json()["message"]
def test_login_success(self, client, sample_user):
"""登录成功"""
response = client.post(
"/api/auth/login",
json={
"username": "testuser",
"password": "testpass123"
}
)
assert response.status_code == 200
data = response.json()
assert "access_token" in data["data"]
assert "refresh_token" in data["data"]
def test_login_wrong_password(self, client, sample_user):
"""密码错误登录失败"""
response = client.post(
"/api/auth/login",
json={
"username": "testuser",
"password": "wrong_password"
}
)
assert response.status_code == 200
assert response.json()["code"] == 401
def test_refresh_token(self, client, sample_user):
"""刷新Token"""
# 先登录
login_response = client.post(
"/api/auth/login",
json={
"username": "testuser",
"password": "testpass123"
}
)
refresh_token = login_response.json()["data"]["refresh_token"]
# 刷新Token
response = client.post(
"/api/auth/refresh-token",
params={"refresh_token": refresh_token}
)
assert response.status_code == 200
assert "access_token" in response.json()["data"]
四、Bug修复记录
4.1 Bug #001:任务状态流转校验缺失
严重程度: 高
发现时间: Sprint 1联调阶段
描述: 任务状态可以从"待执行"直接跳转到"已完成",跳过了"执行中"状态
根因分析:
# 原来的更新逻辑没有状态校验
@router.put("/{task_id}")
def update_task(task_id: int, task_in: DrillTaskUpdate):
# 直接更新,缺少状态流转校验
db_task.status = task_in.status
db.commit()
修复方案:
# 添加状态流转校验
VALID_TRANSITIONS = {
0: [1, 3], # 待执行 -> 执行中 或 已取消
1: [2, 3], # 执行中 -> 已完成 或 已取消
2: [], # 已完成不可变更
3: [] # 已取消不可变更
}
@router.put("/{task_id}")
def update_task(task_id: int, task_in: DrillTaskUpdate):
db_task = drill_task_crud.get(db, task_id)
if not db_task:
return error_response(code=404, message="任务不存在")
# 校验状态流转
if task_in.status is not None:
current = db_task.status
allowed = VALID_TRANSITIONS.get(current, [])
if task_in.status not in allowed:
return error_response(
code=400,
message=f"状态流转非法: {current} -> {task_in.status}"
)
# 更新其他字段
...
测试用例:
def test_task_status_transition_invalid(db, client, auth_headers):
"""测试非法状态流转"""
# 创建任务(状态0)
response = client.post("/api/drill-task", ...)
task_id = response.json()["data"]["id"]
# 尝试直接跳转到已完成(非法)
response = client.put(
f"/api/drill-task/{task_id}",
json={"status": 2}, # 从0跳到2,非法
headers=auth_headers
)
assert response.status_code == 200
assert response.json()["code"] == 400
assert "非法" in response.json()["message"]
4.2 Bug #002:分页参数边界问题
严重程度: 中
发现时间: Sprint 1测试阶段
描述: 当page_size为0或负数时,系统报错
修复方案:
@router.get("/list")
def list_drill_plans(
page: int = Query(1, ge=1, description="页码"), # ge=1 保证最小为1
page_size: int = Query(10, ge=1, le=100, description="每页条数"), # ge=1 le=100
):
skip = (page - 1) * page_size
...
4.3 Bug #003:AI问答无API Key时崩溃
严重程度: 高
发现时间: Sprint 2测试阶段
描述: 当LLM_API_KEY未配置时,调用AI问答接口直接抛出500错误
根因分析: LLM服务初始化时没有处理API Key为空的情况
修复方案:
def _get_llm(self) -> Optional[ChatOpenAI]:
if not self._initialized:
api_key = settings.llm_api_key
if not api_key or api_key.strip() == "":
# API Key为空,返回None,由调用方处理
self._initialized = True
return None
self._llm = ChatOpenAI(...)
self._initialized = True
return self._llm
def answer_with_knowledge(self, question: str, context: str):
llm = self._get_llm()
if llm is None:
# 降级处理:返回后备回答
return {
"answer": self._fallback_answer(question, context),
"source_type": "knowledge_base",
"is_knowledge_based": True
}
# 正常流程
...
4.4 Bug #004:文件上传大小时机读取问题
严重程度: 低
发现时间: Sprint 2测试阶段
描述: 大文件上传时,由于先读取整个文件到内存导致内存溢出
修复方案: 使用流式读取
# 原来的实现
content = await file.read() # 一次性读取全部内容
if len(content) > MAX_SIZE:
raise BusinessException("文件过大")
with open(save_path, "wb") as f:
f.write(content)
# 修复后:流式读取
file_size = 0
with open(save_path, "wb") as f:
while chunk := file.file.read(8192): # 分块读取
file_size += len(chunk)
if file_size > MAX_SIZE:
os.remove(save_path) # 删除已写入的部分
raise BusinessException("文件超过50MB限制")
f.write(chunk)
五、测试覆盖率报告
5.1 当前覆盖率
| 模块 | 语句覆盖 | 分支覆盖 | 行数 |
|---|---|---|---|
| CRUD层 | 92% | 85% | 450 |
| Service层 | 78% | 70% | 380 |
| API层 | 85% | 75% | 280 |
| Security | 95% | 88% | 120 |
| 总计 | 86% | 79% | 1230 |
5.2 测试运行命令
# 运行所有测试
pytest tests/ -v
# 运行单元测试
pytest tests/unit/ -v
# 运行集成测试
pytest tests/integration/ -v
# 生成覆盖率报告
pytest tests/ --cov=app --cov-report=html --cov-report=term
# 查看HTML报告
open htmlcov/index.html
六、质量保障总结
6.1 测试流程
开发阶段
│
├── 编写单元测试(同步)
│
▼
代码提交
│
├── 运行单元测试
├── 运行集成测试
├── 代码覆盖率检查
│
▼
代码审查
│
├── 代码风格检查
├── 安全审查
└── 逻辑审查
│
▼
合并到主分支
6.2 质量目标达成
| 指标 | 目标 | 实际 | 状态 |
|---|---|---|---|
| 代码覆盖率 | 80% | 86% | 达成 |
| 关键路径测试 | 100% | 100% | 达成 |
| 高优先级Bug修复 | 100% | 100% | 达成 |
| 中优先级Bug修复 | 90% | 95% | 达成 |
| 测试通过率 | 95% | 98% | 达成 |
浙公网安备 33010602011771号