sallyface

  博客园  :: 首页  :: 新随笔  :: 联系 :: 订阅 订阅  :: 管理

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% 达成
posted on 2026-06-19 22:12  Ambersen  阅读(2)  评论(0)    收藏  举报