写 Python 项目时,"跑一下没报错"是最常见的验证方式。但项目一旦多人协作、持续迭代,手动验证就不可靠了——测试框架、Mock 策略、代码质量工具、CI 流水线,每一层都影响交付节奏。这篇文章把 unittest、pytest、mock、代码质量工具和 GitHub Actions 串联起来,给出可直接落地的配置和代码。
unittest 还是 pytest:选框架的务实判断
Python 标准库自带 unittest,零依赖就能跑,适合小型脚本或不想引入第三方依赖的场景。但 pytest 在实际项目中优势明显:
- 断言更自然:直接写
assert result == expected,不需要self.assertEqual,失败时 pytest 自动展开变量值。 - fixture 机制:替代 unittest 的
setUp/tearDown,fixture 有明确的作用域(function / class / module / session),依赖注入比继承更干净。 - 参数化测试:一行
@pytest.mark.parametrize覆盖多组输入,不用写循环或多个方法。
一个典型的 pytest 参数化示例:
import pytest
def add(a, b):
return a + b
@pytest.mark.parametrize("a, b, expected", [
(1, 2, 3),
(0, 0, 0),
(-1, 1, 0),
(100, -50, 50),
])
def test_add(a, b, expected):
assert add(a, b) == expected
运行命令:
pytest test_add.py -v
选择建议:新项目直接用 pytest。如果团队已有大量 unittest 用例,pytest 可以无缝运行它们(pytest 命令会自动发现 unittest.TestCase),逐步迁移即可。
Mock:隔离外部依赖的核心手段
测试中调用真实数据库、HTTP API 或文件系统,会让测试变慢且不可控。unittest.mock 提供两条路径:
patch:临时替换模块中的对象,测试结束自动恢复。MagicMock:手动创建模拟对象,注入到被测函数。
下面是一个隔离 HTTP 请求的完整示例:
from unittest.mock import patch
import requests
def fetch_user_name(user_id):
resp = requests.get(f"https://api.example.com/users/{user_id}")
return resp.json()["name"]
@patch("requests.get")
def test_fetch_user_name(mock_get):
# 配置 mock 返回值
mock_get.return_value.json.return_value = {"name": "Alice"}
result = fetch_user_name(42)
assert result == "Alice"
# 验证 requests.get 被正确调用
mock_get.assert_called_once_with("https://api.example.com/users/42")
几个容易踩的坑:
- patch 路径要用被测模块中的引用,而非原始模块。比如被测文件
import requests,patch 目标是"my_module.requests.get",而不是"requests.get"——否则被测代码拿到的仍是原始requests.get。 - 别 mock 自己:只 mock 外部依赖,被测逻辑本身不应被替换,否则测试失去意义。
- 过度 mock 会让测试与实现耦合:重构代码时 mock 全部失效。优先考虑用轻量本地服务或内存替代(如用
sqlite替代远程数据库),只在无法替代时才 mock。
代码质量工具:测试之外的防线
测试覆盖行为正确性,但代码风格、类型安全、复杂度等问题需要额外工具:
| 工具 | 作用 | 推荐配置 |
|---|---|---|
ruff |
替代 flake8 + isort + pyupgrade,极速 lint + 格式化 | ruff check . + ruff format . |
mypy |
静态类型检查 | mypy --strict src/(渐进式可放宽) |
pytest-cov |
测试覆盖率报告 | pytest --cov=src --cov-fail-under=80 |
在项目中一次性配置:
pip install ruff mypy pytest-cov
ruff check . --fix # 自动修复可修复的问题
ruff format . # 格式化
pytest --cov=src # 跑测试并输出覆盖率
mypy src/ # 类型检查
关键决策:覆盖率阈值设多少?80% 是常见起点,但核心业务模块建议 90%+,工具/脚本类可以放宽。不要追求 100%——那会导致为覆盖率写无意义测试。
GitHub Actions CI:让上述检查自动执行
本地跑一遍没问题就提交,是最常见的失误来源。CI 的作用是:每次 push 或 PR 自动执行 lint、类型检查和测试,不合格就不合并。
下面是一个可直接使用的 GitHub Actions 工作流文件,保存为 .github/workflows/ci.yml:
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install ruff mypy pytest-cov
- name: Lint with ruff
run: ruff check .
- name: Type check with mypy
run: mypy src/
continue-on-error: true # 渐进引入时先不阻断
- name: Run tests with coverage
run: pytest --cov=src --cov-fail-under=80
- name: Upload coverage report
if: matrix.python-version == '3.12'
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: .coverage
配置要点说明:
strategy.matrix同时跑多个 Python 版本,提前发现兼容性问题。mypy步骤加了continue-on-error: true——项目刚引入类型检查时,先让 CI 不阻断,逐步修复后再改为严格阻断。- 覆盖率阈值
--cov-fail-under=80直接在 CI 中强制,低于 80% 整个 job 失败。 upload-artifact保存覆盖率数据,可配合 Coverage Badge 或后续分析。
上手清单
把上述内容落地到项目,按这个顺序推进:
- 安装 pytest 和 pytest-cov,把现有 unittest 用例用 pytest 跑一遍,确认兼容。
- 对外部依赖加 mock,优先用本地替代方案,只在必要时
patch。 - 引入 ruff,一条命令解决 lint + 格式化,替代多个旧工具。
- mypy 渐进接入:先
continue-on-error,每周修一批类型问题,修完再收紧。 - 创建
.github/workflows/ci.yml,push 后自动跑全流程。 - 设置覆盖率阈值:从 80% 开始,核心模块逐步提高到 90%+。
测试和 CI 不是一次性配置,而是持续调整的防线。每次加新功能时问自己:这条路径有测试覆盖吗?CI 能拦截到吗?回答"是"的时候,交付才算可靠。