跳转至

Web项目工程化规范 — CI/CD与测试

核心知识点速查表

知识点说明
五、自动化部署(CI/CD)见对应章节
六、测试见对应章节
七、日志系统见对应章节

五、自动化部署(CI/CD)

为什么需要

不用 CI/CD 的后果: - 每次上线手动 SSH 到服务器 → git pull → 重启服务,费时易错 - 忘了跑测试就部署,线上直接炸 - "上次部署的是哪个版本?"——没人记得

5.1 GitHub Actions 完整流程 [推荐]

创建 .github/workflows/deploy.yml

# .github/workflows/deploy.yml —— 自动测试 + 部署流程
name: CI/CD Pipeline                         # 工作流名称

on:
  push:
    branches: [main]                         # 推送到 main 时触发
  pull_request:
    branches: [main]                         # PR 到 main 时触发(只跑测试)

jobs:
  # ===== 第一步:测试 =====
  test:
    runs-on: ubuntu-latest                   # 运行环境
    steps:
      - uses: actions/checkout@v4            # 拉取代码

      - name: Set up Python                  # 安装 Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"             # 指定 Python 版本

      - name: Cache pip packages             # 缓存依赖,加速后续构建
        uses: actions/cache@v4
        with:
          path: ~/.cache/pip
          key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}

      - name: Install dependencies           # 安装依赖
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt
          pip install -r requirements-dev.txt  # 开发/测试依赖

      - name: Lint with Ruff                 # 代码检查
        run: ruff check .                    # 快速检查代码规范

      - name: Run tests with coverage        # 运行测试并统计覆盖率
        run: pytest --cov=app --cov-report=xml --cov-fail-under=80
        # --cov=app          统计 app 目录的覆盖率
        # --cov-report=xml   生成 XML 报告(可上传到 Codecov)
        # --cov-fail-under=80  覆盖率低于 80% 则失败

      - name: Upload coverage to Codecov     # 上传覆盖率报告(可选)
        uses: codecov/codecov-action@v4
        if: github.event_name == 'push'      # 只在 push 时上传
        with:
          file: ./coverage.xml

  # ===== 第二步:部署(仅 push 到 main 时执行) =====
  deploy:
    needs: test                              # 测试通过才部署
    runs-on: ubuntu-latest
    if: github.event_name == 'push'          # PR 不触发部署

    steps:
      - name: Deploy to server via SSH       # SSH 到服务器执行部署
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_HOST }}    # 服务器 IP(在 GitHub Secrets 配置)
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }} # SSH 私钥
          script: |
            cd /opt/t2d-api                  # 进入项目目录
            git pull origin main             # 拉最新代码
            source .venv/bin/activate        # 激活虚拟环境
            pip install -r requirements.txt  # 安装/更新依赖
            sudo systemctl reload t2d-api    # 优雅重载服务(不断连接)

5.2 配置 GitHub Secrets [必须]

在 GitHub 仓库 → Settings → Secrets and variables → Actions,添加:

Secret 名
SERVER_HOST你的服务器公网 IP
SERVER_USERSSH 登录用户名(不要用 root)
SSH_PRIVATE_KEY服务器对应的 SSH 私钥内容

5.3 手动部署脚本(GitHub Actions 的简化替代)[必须]

如果暂时不用 GitHub Actions,至少写个部署脚本:

#!/bin/bash
# deploy.sh —— 手动部署脚本,SSH 到服务器后执行
set -e                                       # 任何命令失败立即停止

echo "=== 拉取最新代码 ==="
cd /opt/t2d-api
git pull origin main

echo "=== 安装依赖 ==="
source .venv/bin/activate
pip install -r requirements.txt

echo "=== 运行数据库迁移 ==="
# alembic upgrade head                       # 如果用了 Alembic

echo "=== 重启服务 ==="
sudo systemctl reload t2d-api

echo "=== 检查服务状态 ==="
sudo systemctl status t2d-api --no-pager

echo "=== 部署完成 ==="

六、测试

为什么需要

不写测试的后果: - 改了一个函数,另一个功能悄悄坏了——你不知道 - 上线前手动测一遍要 2 小时——你懒得测,直接上 - 出了 bug,回归测试全靠人工——效率极低

6.1 测试分类

类型测什么数量速度
单元测试单个函数/方法最多最快
集成测试API 接口、数据库操作适量中等
E2E 测试完整用户流程少量最慢

6.2 pytest 基础用法 [必须]

项目结构:

tests/
├── __init__.py
├── conftest.py              # 共享的 fixtures(测试夹具)
├── test_species.py          # 物种相关测试
├── test_samples.py          # 样本相关测试
└── test_auth.py             # 认证相关测试

conftest.py(共享测试配置):

# tests/conftest.py —— pytest 共享夹具,所有测试文件自动可用
import pytest
from fastapi.testclient import TestClient  # FastAPI 自带的测试客户端
from app.main import app                   # 导入 FastAPI 应用实例

@pytest.fixture                            # 装饰器,声明这是一个夹具
def client():
    """创建测试用的 HTTP 客户端"""
    with TestClient(app) as c:             # TestClient 模拟 HTTP 请求
        yield c                            # yield 让测试函数使用这个客户端
    # with 块结束后自动清理资源

单元测试示例:

# tests/test_species.py —— 物种查询相关测试
def test_calculate_diversity_index():
    """测试多样性指数计算函数"""
    # Arrange(准备)
    abundances = [0.5, 0.3, 0.2]           # 三个物种的相对丰度

    # Act(执行)
    from app.services.diversity import shannon_index
    result = shannon_index(abundances)      # 调用被测函数

    # Assert(断言)
    assert round(result, 4) == 1.0297       # 检查结果是否正确

API 接口测试示例(同步方式,适合简单场景):

# tests/test_api.py —— API 接口集成测试(同步 TestClient)
def test_get_species_list(client):
    """测试获取物种列表接口"""
    response = client.get("/api/v1/species")  # 发送 GET 请求
    assert response.status_code == 200        # 状态码应该是 200
    data = response.json()                    # 解析 JSON 响应
    assert "items" in data                    # 响应中应该有 items 字段
    assert isinstance(data["items"], list)    # items 应该是列表

def test_create_sample_without_auth(client):
    """测试未认证时创建样本应该被拒绝"""
    response = client.post(
        "/api/v1/samples",
        json={"name": "test_sample"}          # 提交数据
    )
    assert response.status_code == 401        # 应该返回 401 未认证

def test_upload_invalid_file(client):
    """测试上传无效文件格式应该报错"""
    response = client.post(
        "/api/v1/upload",
        files={"file": ("test.exe", b"fake", "application/octet-stream")}
    )
    assert response.status_code == 422        # 422 验证错误
    assert "不支持的文件格式" in response.json()["detail"]

6.2.1 异步测试(httpx.AsyncClient)[推荐]

如果你的 FastAPI 路由用了 async def,推荐用 httpx.AsyncClient 替代 TestClient,完整走异步链路:

# tests/conftest.py —— 异步测试夹具(2025+ 推荐方式)
import pytest
from httpx import AsyncClient, ASGITransport  # httpx 异步客户端
from app.main import app

@pytest.fixture
async def async_client():
    """创建异步测试客户端"""
    transport = ASGITransport(app=app)        # 直接对接 ASGI 应用,不走网络
    async with AsyncClient(
        transport=transport,
        base_url="http://test"                # 基础 URL(不会真的发网络请求)
    ) as client:
        yield client

# tests/test_api_async.py —— 异步 API 测试
import pytest

@pytest.mark.asyncio                          # 声明这是异步测试
async def test_get_species_async(async_client):
    """异步版本的接口测试"""
    response = await async_client.get("/api/v1/species")
    assert response.status_code == 200
    data = response.json()
    assert "items" in data
# 安装异步测试依赖
pip install pytest-asyncio httpx              # 或 uv add --dev pytest-asyncio httpx

何时用哪个? 同步路由 (def) 用 TestClient 就够了;异步路由 (async def) 用 httpx.AsyncClient 确保完整的异步生命周期。混用 sync/async 客户端容易导致事件循环冲突。

6.3 测试覆盖率 [推荐]

# 安装覆盖率工具
pip install pytest-cov                       # pytest 的覆盖率插件

# 运行测试并查看覆盖率
pytest --cov=app --cov-report=term-missing   # 终端显示,标出未覆盖的行号
# --cov=app             统计 app 目录的代码覆盖率
# --cov-report=term-missing  显示哪些行没被测试覆盖

# 生成 HTML 覆盖率报告(浏览器打开更直观)
pytest --cov=app --cov-report=html           # 生成 htmlcov/ 目录
# 用浏览器打开 htmlcov/index.html 查看

pyproject.toml 中统一配置 pytest:

# pyproject.toml 中的 pytest 配置
[tool.pytest.ini_options]
testpaths = ["tests"]                        # 测试文件所在目录
python_files = ["test_*.py"]                 # 测试文件命名模式
addopts = "-v --cov=app --cov-report=term-missing"  # 默认参数
asyncio_mode = "auto"                        # 自动处理异步测试

[tool.coverage.run]
source = ["app"]                             # 统计哪个目录的覆盖率
omit = ["app/core/config.py"]               # 排除配置文件

[tool.coverage.report]
fail_under = 80                              # 覆盖率低于 80% 视为失败
show_missing = true                          # 显示未覆盖行号

七、日志系统

为什么需要

不写日志的后果: - 线上出 bug,print() 早就被你删了——无法定位问题 - 用户反馈"打不开",你看不到任何错误记录 - 要查上周三的一个异常,日志文件 2GB——打不开

7.1 日志工具对比

工具特点推荐场景
logging(标准库)内置、无依赖、生态最好大型项目、和第三方库兼容
loguru零配置、开箱即用、语法优美小中型项目(本文推荐)
structlog结构化日志、处理器链、最快大型项目、需要日志聚合

7.2 loguru 配置(推荐方案)[必须]

# app/core/logging.py —— 日志配置
import sys
from loguru import logger                    # loguru 的 logger 是全局单例

from app.core.config import settings         # 读取配置

def setup_logging():
    """配置日志系统"""
    # 移除默认处理器
    logger.remove()                          # 清掉 loguru 默认的 stderr 输出

    # 开发环境:彩色输出到控制台(人类可读)
    if settings.debug:
        logger.add(
            sys.stderr,                      # 输出到终端
            level="DEBUG",                   # 显示 DEBUG 及以上级别
            format="<green>{time:HH:mm:ss}</green> | "  # 绿色时间
                   "<level>{level: <8}</level> | "       # 彩色级别
                   "<cyan>{name}:{function}:{line}</cyan> | "  # 来源
                   "{message}",              # 消息内容
            colorize=True,                   # 启用彩色
        )
    else:
        # 生产环境:JSON 格式输出到文件(机器可读,方便日志聚合工具解析)
        logger.add(
            "logs/app.log",                  # 日志文件路径
            level="INFO",                    # 生产环境只记录 INFO 及以上
            format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {name}:{line} | {message}",
            rotation="100 MB",              # 单个文件超过 100MB 自动轮转
            retention="30 days",            # 保留最近 30 天的日志
            compression="gz",               # 旧日志自动压缩为 .gz
            serialize=True,                 # 输出 JSON 格式(结构化日志)
            encoding="utf-8",               # 编码
        )

        # 错误日志单独存一份(方便快速定位严重问题)
        logger.add(
            "logs/error.log",
            level="ERROR",                   # 只记录 ERROR 和 CRITICAL
            rotation="50 MB",
            retention="90 days",             # 错误日志保留更久
            compression="gz",
        )

在 FastAPI 中使用:

# app/main.py
from loguru import logger
from app.core.logging import setup_logging

setup_logging()                              # 启动时初始化日志

@app.get("/api/v1/species/{species_id}")
async def get_species(species_id: int):
    logger.info(f"查询物种 ID: {species_id}")  # 记录正常操作
    try:
        result = await species_service.get(species_id)
        return result
    except Exception as e:
        logger.error(f"查询物种失败: {e}")     # 记录错误
        raise

7.3 日志级别说明

级别用途示例
DEBUG开发调试细节SQL 查询: SELECT * FROM species WHERE id=1
INFO正常业务操作用户 admin 登录成功
WARNING值得注意但不影响运行API 调用频率接近限制: 95/100
ERROR出错但系统仍运行上传文件解析失败: invalid FASTA format
CRITICAL系统级严重错误数据库连接断开

7.4 请求级日志追踪(中间件模式)[推荐]

生产环境最关键的需求:每条日志都能追溯到是哪个请求产生的

# app/middleware/logging.py —— 请求日志中间件
import uuid
from loguru import logger
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request

class RequestLoggingMiddleware(BaseHTTPMiddleware):
    """给每个请求分配唯一 ID,自动记录请求/响应日志"""
    async def dispatch(self, request: Request, call_next):
        # 生成唯一请求 ID
        request_id = str(uuid.uuid4())[:8]    # 取前 8 位够用了

        # 绑定上下文(loguru 的 contextualize 会自动附加到后续所有日志)
        with logger.contextualize(request_id=request_id):
            logger.info(
                f"{request.method} {request.url.path}",
                extra={"client_ip": request.client.host}
            )
            response = await call_next(request)
            logger.info(
                f"响应 {response.status_code}",
                extra={"status_code": response.status_code}
            )

        # 把 request_id 也返回给客户端(方便排查问题)
        response.headers["X-Request-ID"] = request_id
        return response

为什么重要? 用户报告 "500 错误",你只要拿到 X-Request-ID 就能在日志里精准定位,不用在几千行日志里大海捞针。structlog 和 loguru 都支持这种模式。

7.5 systemd + logrotate(系统级日志管理)[进阶]

如果不用 loguru 的内置轮转,可以用 Linux 系统的 logrotate:

# /etc/logrotate.d/t2d-api —— 系统级日志轮转配置
/opt/t2d-api/logs/*.log {
    daily                                    # 每天轮转一次
    rotate 30                                # 保留 30 份
    compress                                 # 压缩旧日志
    delaycompress                            # 延迟一天再压缩(方便查看昨天的)
    missingok                                # 日志文件不存在也不报错
    notifempty                               # 空文件不轮转
    create 0640 www-data www-data            # 新文件的权限和所有者
    postrotate
        systemctl reload t2d-api             # 轮转后通知应用重新打开文件
    endscript
}