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 dbfinally:db.close()Base.metadata.drop_all(bind=engine)@pytest.fixture(scope="function")
def client(db):"""创建测试客户端"""def override_get_db():try:yield dbfinally:passapp.dependency_overrides[get_db] = override_get_dbwith TestClient(app) as test_client:yield test_clientapp.dependency_overrides.clear()@pytest.fixture
def sample_user(db):"""创建测试用户"""from app.models.user import Userfrom app.core.security import get_password_hashuser = 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 jwtclass TestPasswordHashing:"""密码哈希测试"""def test_password_hash_consistency(self):"""同一密码多次哈希结果不同(salt)"""password = "my_secure_password"hash1 = get_password_hash(password)hash2 = get_password_hash(password)# bcrypt会生成不同的saltassert hash1 != hash2# 但验证都能通过assert verify_password(password, hash1) is Trueassert verify_password(password, hash2) is Truedef 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 Falsedef test_empty_password(self):"""空密码处理"""hash_value = get_password_hash("")assert verify_password("", hash_value) is Trueclass 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 payloadassert "login_time" in payloaddef test_token_expiration(self):"""Token过期测试"""from datetime import timedeltadata = {"sub": "123"}# 创建1秒过期的tokentoken = create_access_token(data, expires_delta=timedelta(seconds=1))import timetime.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 DrillPlanCreateclass 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 Noneassert plan.plan_no == "PLAN-TEST-001"assert plan.department == "安全部"assert plan.status == 0def 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 Noneassert 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 DrillPlanUpdateupdate_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 == 1def 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.iddrill_plan_crud.remove(db=db, id=plan_id)deleted_plan = drill_plan_crud.get(db=db, id=plan_id)assert deleted_plan is Nonedef 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 pytestclass 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 == 200data = response.json()assert data["code"] == 200assert 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 == 403def 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 == 200data = response.json()assert data["code"] == 200assert "items" in data["data"]assert "total" in data["data"]assert data["data"]["page"] == 1def 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 == 200data = response.json()assert data["data"]["id"] == plan_idassert 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 == 200assert 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.pyclass 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 == 200data = response.json()assert data["code"] == 200assert 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 == 200assert response.json()["code"] == 400assert "已存在" 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 == 200data = 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 == 200assert response.json()["code"] == 401def 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"]# 刷新Tokenresponse = client.post("/api/auth/refresh-token",params={"refresh_token": refresh_token})assert response.status_code == 200assert "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.statusdb.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.statusallowed = 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 == 200assert response.json()["code"] == 400assert "非法" 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 保证最小为1page_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_keyif not api_key or api_key.strip() == "":# API Key为空,返回None,由调用方处理self._initialized = Truereturn Noneself._llm = ChatOpenAI(...)self._initialized = Truereturn self._llmdef 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% | 达成 |
