Skip to content

Python 测试

测试是用代码验证代码。它不是为了“写给测试人员看”,而是为了让开发者在修改、重构、上线前知道核心行为有没有被破坏。

零基础阶段可以先理解成:你写一个函数,再写另一个函数验证它在不同输入下是否返回正确结果。进阶以后,测试要覆盖业务规则、异常分支、数据库访问、Web API、外部依赖 Mock、持续集成和回归场景。

学习目标

学完本页你应该能回答:

  1. 为什么要写测试,不写测试会怎样。
  2. 单元测试、集成测试、接口测试、端到端测试有什么区别。
  3. pytest 如何发现和执行测试。
  4. assert、参数化、fixture、mock 分别解决什么问题。
  5. 如何测试异常分支、边界条件和业务规则。
  6. 如何测试 FastAPI 接口和数据库逻辑。
  7. 覆盖率怎么看,为什么不能只追求覆盖率。
  8. CI 中如何让测试成为发布门禁。

为什么要写测试

没有测试时,项目会越来越不敢改:

  1. 修一个 bug,可能引入另一个 bug。
  2. 重构后不知道原功能有没有坏。
  3. 只能靠手动点击页面验证,效率低且容易漏。
  4. 团队协作时别人不敢改你的代码。
  5. 上线前没有自动化门禁,只能靠经验。

测试的价值不是保证永远没有 bug,而是让核心行为可验证、可回归、可交接。

测试金字塔

mermaid
flowchart TD
    A["测试体系"] --> B["单元测试:最多"]
    A --> C["集成测试:适量"]
    A --> D["接口测试:关键接口"]
    A --> E["端到端测试:少量核心流程"]
    B --> F["函数 类 Service 业务规则"]
    C --> G["数据库 缓存 消息队列"]
    D --> H["HTTP API 入参 出参 权限"]
    E --> I["从用户操作到系统完成"]

为什么单元测试最多?因为它快、稳定、定位清楚。端到端测试虽然接近真实用户,但慢、不稳定、失败后定位成本高,所以只覆盖最核心流程。

类型测什么优点缺点
单元测试函数、类、Service快、稳定、定位准不能证明依赖真实可用
集成测试数据库、缓存、外部组件更接近真实环境准备环境成本高
接口测试HTTP API验证路由、校验、响应仍可能 Mock 部分依赖
端到端测试完整用户流程最贴近用户慢、脆弱、维护成本高

pytest 如何运行

安装:

bash
pip install pytest

pytest 默认会寻找:

  1. test_*.py 文件。
  2. *_test.py 文件。
  3. test_ 开头的函数。
  4. Test 开头的测试类。

最小示例:

calculator.py

python
def add(a: int, b: int) -> int:
    return a + b


def divide(a: int, b: int) -> float:
    if b == 0:
        raise ValueError("除数不能为 0")
    return a / b

test_calculator.py

python
import pytest
from calculator import add, divide


def test_add():
    assert add(1, 2) == 3


def test_divide():
    assert divide(6, 2) == 3


def test_divide_by_zero():
    with pytest.raises(ValueError):
        divide(1, 0)

运行:

bash
pytest

运行更详细输出:

bash
pytest -v

只运行某个文件:

bash
pytest tests/test_calculator.py

只运行某个测试:

bash
pytest tests/test_calculator.py::test_add

assert 怎么写

assert 的意思是“我期望这个条件为真”。

推荐:

python
assert result == expected
assert len(users) == 3
assert "admin" in roles
assert user is not None

不推荐:

python
assert result

因为失败时不知道期望值是什么。好的测试应该让失败信息尽量清楚。

异常测试:

python
import pytest


def test_invalid_age():
    with pytest.raises(ValueError, match="年龄不能小于 0"):
        create_user(name="Tom", age=-1)

这里不仅验证抛异常,还验证异常信息包含预期内容。

参数化测试

同一个函数有多组输入时,不要复制很多测试函数。

python
import pytest


def is_even(value: int) -> bool:
    return value % 2 == 0


@pytest.mark.parametrize(
    "value, expected",
    [
        (0, True),
        (1, False),
        (2, True),
        (-2, True),
    ],
)
def test_is_even(value, expected):
    assert is_even(value) == expected

参数化适合:

  1. 边界值测试。
  2. 多种状态转换。
  3. 多种错误输入。
  4. 金额、折扣、权限等规则矩阵。

fixture 原理和用法

fixture 用来准备测试依赖。pytest 在测试函数需要某个参数时,会找同名 fixture 执行,并把返回值注入进去。

mermaid
flowchart TD
    A["pytest 发现 test_user_name 需要 user 参数"] --> B["查找 user fixture"]
    B --> C["执行 fixture 准备数据"]
    C --> D["把返回值传给测试函数"]
    D --> E["执行测试断言"]

示例:

python
import pytest


@pytest.fixture
def user():
    return {"id": 1, "name": "张三", "active": True}


def test_user_name(user):
    assert user["name"] == "张三"

带清理逻辑的 fixture:

python
import tempfile
from pathlib import Path
import pytest


@pytest.fixture
def temp_file():
    with tempfile.TemporaryDirectory() as temp_dir:
        path = Path(temp_dir) / "demo.txt"
        path.write_text("hello", encoding="utf-8")
        yield path


def test_read_temp_file(temp_file):
    assert temp_file.read_text(encoding="utf-8") == "hello"

yield 前是准备,yield 后是清理。即使测试失败,pytest 也会执行清理逻辑。

fixture 作用域

fixture 可以设置作用域:

scope生命周期适合
function每个测试函数一次默认,隔离最好
class每个测试类一次类内共享对象
module每个测试文件一次初始化较重资源
session整个测试会话一次测试数据库、容器资源

示例:

python
@pytest.fixture(scope="session")
def app_config():
    return {"env": "test"}

作用域越大,性能可能越好,但隔离越弱。初学者优先用默认 function,避免测试之间互相污染。

mock 外部依赖

单元测试不要真的发短信、扣款、调用第三方接口、请求大模型。应该 Mock 外部依赖。

业务代码:

python
def register_user(user, email_client):
    if not user["email"]:
        raise ValueError("邮箱不能为空")
    email_client.send(user["email"], "欢迎注册")
    return True

测试:

python
from unittest.mock import Mock


def test_register_user_send_email():
    email_client = Mock()
    user = {"email": "test@example.com"}

    result = register_user(user, email_client)

    assert result is True
    email_client.send.assert_called_once_with("test@example.com", "欢迎注册")

Mock 的边界:Mock 是为了隔离外部依赖,不是为了把所有真实逻辑都替换掉。如果一个测试全是 Mock,可能只能证明“Mock 被调用”,证明不了业务真的对。

monkeypatch 修改环境

测试配置读取时,可以用 monkeypatch 临时修改环境变量。

python
import os


def get_database_url():
    return os.environ["DATABASE_URL"]


def test_get_database_url(monkeypatch):
    monkeypatch.setenv("DATABASE_URL", "sqlite:///test.db")
    assert get_database_url() == "sqlite:///test.db"

测试结束后,pytest 会自动恢复环境,避免污染其他测试。

业务规则测试 Demo

以资产导入为例:来源系统只允许 HISLISPACSEMR

业务代码:

python
class AssetService:
    allowed_sources = {"HIS", "LIS", "PACS", "EMR"}

    def create_asset(self, name: str, source_system: str) -> dict:
        if not name.strip():
            raise ValueError("资产名称不能为空")
        if source_system not in self.allowed_sources:
            raise ValueError("来源系统不支持")
        return {"name": name.strip(), "source_system": source_system}

测试:

python
import pytest


def test_create_asset_success():
    service = AssetService()
    asset = service.create_asset(" 检验报告 ", "LIS")
    assert asset["name"] == "检验报告"
    assert asset["source_system"] == "LIS"


@pytest.mark.parametrize("source", ["UNKNOWN", "", "CRM"])
def test_create_asset_invalid_source(source):
    service = AssetService()
    with pytest.raises(ValueError, match="来源系统不支持"):
        service.create_asset("检验报告", source)


def test_create_asset_empty_name():
    service = AssetService()
    with pytest.raises(ValueError, match="资产名称不能为空"):
        service.create_asset("   ", "LIS")

这个测试覆盖了成功、非法来源、空名称三个关键分支。

FastAPI 接口测试

接口测试验证路由、参数校验、状态码、响应结构。

main.py

python
from fastapi import FastAPI
from pydantic import BaseModel, Field

app = FastAPI()


class AssetCreate(BaseModel):
    name: str = Field(min_length=1)
    source_system: str


@app.post("/assets")
def create_asset(request: AssetCreate):
    return {"id": 1, "name": request.name, "source_system": request.source_system}

test_api.py

python
from fastapi.testclient import TestClient
from main import app

client = TestClient(app)


def test_create_asset_success():
    response = client.post(
        "/assets",
        json={"name": "检验报告", "source_system": "LIS"},
    )
    assert response.status_code == 200
    assert response.json()["name"] == "检验报告"


def test_create_asset_validation_error():
    response = client.post("/assets", json={"name": "", "source_system": "LIS"})
    assert response.status_code == 422

接口测试不要只测成功,也要测参数缺失、类型错误、权限失败、业务异常。

数据库测试

数据库测试要解决两个问题:

  1. 数据从哪里来。
  2. 测试结束后怎么清理。

SQLite 示例:

python
import sqlite3
import pytest


@pytest.fixture
def db_conn():
    conn = sqlite3.connect(":memory:")
    conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)")
    yield conn
    conn.close()


def test_insert_user(db_conn):
    db_conn.execute("INSERT INTO users (name) VALUES (?)", ("张三",))
    db_conn.commit()

    row = db_conn.execute("SELECT name FROM users WHERE id = 1").fetchone()
    assert row[0] == "张三"

生产中如果使用 MySQL/PostgreSQL,集成测试可以使用测试库或容器。原则是:

  1. 不连接生产数据库。
  2. 测试数据可重复创建。
  3. 测试后清理或事务回滚。
  4. 测试之间相互隔离。

测试数据设计

测试数据不要只写“正常值”。要覆盖:

类型示例
正常值合法资产名称、合法来源系统
边界值空字符串、最大长度、0、负数
异常值不存在的 ID、非法状态
重复值重复导入同一资产
权限场景无 Token、普通用户、管理员
并发相关重复提交、幂等键冲突

常见错误是只测一个成功场景,然后认为功能没问题。真正容易出 bug 的往往是边界和异常分支。

覆盖率怎么看

安装:

bash
pip install pytest-cov

运行:

bash
pytest --cov=app --cov-report=term-missing

覆盖率告诉你哪些代码被测试执行过,但不代表断言有效。

坏测试:

python
def test_create_user():
    create_user("Tom")

它执行了代码,但没有断言结果。

好测试:

python
def test_create_user():
    user = create_user("Tom")
    assert user.name == "Tom"
    assert user.status == "ACTIVE"

覆盖率是参考指标,不是最终目标。商业项目更关注关键业务规则是否被测到。

CI 中运行测试

CI 是持续集成。每次提交代码或合并分支前自动运行测试,可以阻止明显错误进入主分支。

简单流程:

mermaid
flowchart TD
    A["提交代码"] --> B["安装依赖"]
    B --> C["运行格式检查"]
    C --> D["运行单元测试"]
    D --> E["运行接口或集成测试"]
    E --> F{"是否通过"}
    F -- "通过" --> G["允许合并或部署"]
    F -- "失败" --> H["阻止合并并修复"]

GitHub Actions 示例:

yaml
name: python-test

on:
  pull_request:
  push:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.11"
      - run: pip install -r requirements.txt
      - run: pytest --cov=app

如果没有 CI,测试很容易变成“我本地记得跑一下”,最后总会有人忘。

测试失败怎么定位

mermaid
flowchart TD
    A["测试失败"] --> B["看失败用例名称"]
    B --> C["看断言差异"]
    C --> D["确认输入数据"]
    D --> E{"是代码错还是测试错"}
    E -- "代码错" --> F["修业务代码"]
    E -- "测试错" --> G["修测试期望或测试数据"]
    F --> H["重新运行相关测试"]
    G --> H
    H --> I["补充回归用例"]

不要一看到测试失败就改测试让它通过。测试失败有两种可能:

  1. 业务代码真的错了。
  2. 需求变了,测试期望需要更新。

如果是需求变了,要先确认新规则,再更新测试。不能为了通过而删除测试。

商业场景:资产导入测试策略

医疗数据资产导入功能至少要测:

场景测试类型重点
来源系统校验单元测试非法来源要失败
字段类型转换单元测试日期、数字、枚举边界
CSV 解析单元测试空行、缺列、中文编码
写入资产和字段集成测试事务一致性
提交导入任务接口接口测试参数校验、状态码
重复导入单元/集成幂等或重复提示
外部 AI 分析失败Mock 测试不影响主流程或进入补偿

测试不是“所有东西都测一遍”,而是围绕最容易出事故的业务规则建立保护网。

常见坑

问题后果正确做法
只测成功场景异常分支上线才暴露成功、失败、边界都测
测试依赖执行顺序单独运行会失败测试之间相互独立
测试连接生产库可能污染真实数据使用测试库或内存库
Mock 过度测试失真只 Mock 外部依赖
没有断言覆盖率虚高明确断言结果和副作用
随机数据不固定测试偶发失败固定随机种子或明确数据
为了通过删除测试回归风险变大找原因,修代码或确认需求
CI 不跑测试本地遗漏合并前自动跑

面试标准回答

Python 测试怎么分层?
通常按单元测试、集成测试、接口测试、端到端测试分层。单元测试最多,覆盖函数、类和 Service 业务规则;集成测试验证数据库、缓存等组件;接口测试验证 HTTP 入参出参和状态码;端到端测试只覆盖少量核心流程。

pytest 的 fixture 是什么?
fixture 用来准备测试依赖,例如测试数据、临时文件、数据库连接、Mock 对象。pytest 根据测试函数参数名自动执行同名 fixture,并把结果注入测试函数。使用 yield 可以在测试后执行清理逻辑。

Mock 应该怎么用?
Mock 用于隔离外部依赖,例如短信、邮件、支付、第三方接口、AI 模型调用。单元测试应该测试自己的业务规则,不依赖真实外部服务。但不能过度 Mock,否则测试只能证明 Mock 被调用,证明不了业务逻辑正确。

覆盖率高就代表测试好吗?
不一定。覆盖率只说明代码被执行过,不说明断言有效。好的测试要覆盖关键业务规则、边界条件和异常分支,并且有明确断言。商业项目更关注核心风险是否被测试保护。

关联知识点

小结

测试的本质是把经验变成自动化验证。零基础先掌握 pytest、assert、参数化、fixture 和 mock;进阶要会测试业务规则、接口、数据库和异常分支;商业项目要把测试放进 CI,让它成为合并和发布前的门禁。测试写得好,代码才敢改、敢重构、敢上线。