跳转至

567 pytest 单元测试 — 生信函数测试

适用人群:编程基础薄弱的生信方向毕业生 pytest 版本:9.0.3(2026 年 4 月发布,需 Python >= 3.10) pytest-cov 版本:7.1.0(2026 年 3 月发布) 关键词:pytest、单元测试、fixture、参数化、覆盖率


一、什么是单元测试?为什么生信代码更需要测试?

1.1 白话解释

单元测试 = 给你的代码买保险。

你写了一个计算 Shannon 多样性的函数,今天跑着没问题。过了两周你改了一行代码,结果 Shannon 的计算悄悄变了,你根本不知道——直到论文审稿人指出你的结果有问题。

单元测试就是:提前写好"标准答案",每次改代码后自动核对,一旦结果变了立刻告诉你。

1.2 生信代码为什么特别需要测试?

生信特殊性举例测试能帮什么
数据格式多FASTQ、SAM、VCF、TSV 每种格式都有坑自动检查解析是否正确
边界条件多空文件、只有一个物种、全是 N 碱基用边界数据自动跑一遍
数值精度敏感Shannon 指数小数点后第 4 位不同就可能影响结论pytest.approx() 精确比较
依赖外部工具fastp、kraken2 版本更新可能改变输出格式mock 掉外部依赖单独测试
结果难以肉眼验证1000 个物种的丰度表,你不可能手动算用已知答案的小数据集验证

二、pytest 安装与基本用法

2.1 安装

# 方式 1:conda 安装(推荐生信环境)
conda install -c conda-forge pytest          # 安装 pytest 9.0.3

# 方式 2:pip 安装
pip install pytest                           # 自动安装最新版

# 验证安装
pytest --version                             # 应显示 pytest 9.0.3

2.2 最简用法

# 运行当前目录下所有测试
pytest                                       # 自动查找 test_*.py 文件

# 运行指定文件
pytest tests/test_diversity.py               # 只跑这个文件

# 运行指定函数
pytest tests/test_diversity.py::test_shannon  # 只跑这一个测试

# 显示详细信息
pytest -v                                    # verbose 模式,显示每个测试的结果

# 遇到第一个失败就停
pytest -x                                    # 调试时很有用

# 显示 print 输出
pytest -s                                    # 默认会捕获 stdout,加 -s 放出来

三、第一个测试:Shannon 多样性计算

3.1 先写函数

创建 diversity.py

"""多样性指数计算模块"""
import numpy as np                           # 数值计算库


def shannon_diversity(abundances):
    """
    计算 Shannon 多样性指数

    参数:
        abundances: 物种丰度列表(如 [0.3, 0.25, 0.2, 0.15, 0.1])
    返回:
        float: Shannon 指数 H = -sum(p * ln(p))
    """
    abundances = np.array(abundances, dtype=float)  # 转成 numpy 数组
    if len(abundances) == 0:                         # 空列表
        raise ValueError("丰度列表不能为空")
    if np.any(abundances < 0):                       # 有负数
        raise ValueError("丰度值不能为负")

    total = np.sum(abundances)                       # 总和
    if total == 0:                                   # 全是 0
        return 0.0

    p = abundances[abundances > 0] / total           # 计算相对比例(去掉 0)
    return -np.sum(p * np.log(p))                    # Shannon 公式

3.2 再写测试

创建 test_diversity.py

"""Shannon 多样性计算的单元测试"""
import pytest                                # 导入 pytest
import numpy as np                           # 数值计算
from diversity import shannon_diversity      # 导入被测函数


def test_shannon_equal_abundance():
    """等丰度分布:Shannon 应等于 ln(物种数)"""
    # Arrange(准备)
    abundances = [0.25, 0.25, 0.25, 0.25]   # 4 个物种均匀分布

    # Act(执行)
    result = shannon_diversity(abundances)    # 计算 Shannon

    # Assert(断言)
    expected = np.log(4)                     # 理论值 = ln(4) ≈ 1.3863
    assert result == pytest.approx(expected, rel=1e-6)  # 允许相对误差 1e-6


def test_shannon_single_species():
    """只有 1 个物种:Shannon 应为 0"""
    result = shannon_diversity([1.0])        # 只有一个物种
    assert result == pytest.approx(0.0)      # 多样性为 0


def test_shannon_with_zeros():
    """包含 0 丰度的物种,应正确忽略"""
    result = shannon_diversity([0.5, 0.5, 0, 0])  # 有两个 0
    expected = np.log(2)                      # 只有 2 个有效物种
    assert result == pytest.approx(expected, rel=1e-6)


def test_shannon_empty_raises():
    """空列表应抛出 ValueError"""
    with pytest.raises(ValueError, match="不能为空"):  # 期望抛错
        shannon_diversity([])                 # 传空列表


def test_shannon_negative_raises():
    """负数丰度应抛出 ValueError"""
    with pytest.raises(ValueError, match="不能为负"):
        shannon_diversity([0.5, -0.1, 0.6])   # 有负数

3.3 运行测试

pytest test_diversity.py -v                  # 运行并查看详细结果

输出示例:

test_diversity.py::test_shannon_equal_abundance PASSED    [ 20%]
test_diversity.py::test_shannon_single_species PASSED     [ 40%]
test_diversity.py::test_shannon_with_zeros PASSED         [ 60%]
test_diversity.py::test_shannon_empty_raises PASSED       [ 80%]
test_diversity.py::test_shannon_negative_raises PASSED    [100%]

==================== 5 passed in 0.12s ====================

3.4 关键知识点

知识点说明
assertPython 内置断言,条件不满足就报错
pytest.approx()浮点数比较利器,避免 0.1+0.2 != 0.3 的问题
pytest.raises()测试"应该报错"的场景
match=配合 pytest.raises(),检查错误信息包含指定文字

四、测试组织

4.1 推荐目录结构

my_project/
├── src/                                     # 源代码
│   ├── diversity.py                         # 多样性计算
│   ├── abundance.py                         # 丰度表操作
│   └── parsers.py                           # 文件解析
├── tests/                                   # 测试代码
│   ├── __init__.py                          # 让 Python 识别为包(可为空)
│   ├── conftest.py                          # 共享的 fixture(后面讲)
│   ├── test_diversity.py                    # 测试多样性计算
│   ├── test_abundance.py                    # 测试丰度表操作
│   └── test_parsers.py                      # 测试文件解析
└── pytest.ini                               # pytest 配置(可选)

4.2 命名规范

项目规则示例
测试文件test_ 开头或 _test 结尾test_diversity.py
测试函数test_ 开头test_shannon_equal_abundance()
测试类Test 开头(可选)class TestShannon:

4.3 conftest.py — 共享 fixture

conftest.py 是 pytest 的"公共工具箱",放在 tests/ 目录下,里面的 fixture 所有测试文件都能用,不需要 import。

# tests/conftest.py
"""所有测试共享的 fixture"""
import pytest                                # 导入 pytest
import pandas as pd                          # 数据处理
import numpy as np                           # 数值计算


@pytest.fixture
def sample_abundance_df():
    """创建一个模拟的物种丰度 DataFrame"""
    np.random.seed(42)                       # 固定随机种子(结果可重复)
    data = pd.DataFrame(
        np.random.dirichlet(                 # 生成随机丰度(总和为 1)
            np.ones(10), size=5              # 10 个物种,5 个样本
        ).T,
        index=[f"Species_{i}" for i in range(10)],   # 物种名
        columns=[f"Sample_{i}" for i in range(5)]    # 样本名
    )
    return data


@pytest.fixture
def empty_abundance_df():
    """创建一个空的丰度表"""
    return pd.DataFrame()                    # 空 DataFrame

五、Fixture 实战

5.1 什么是 Fixture?

白话:Fixture = 考试前发的草稿纸。 每次考试(测试)都给你一张新的,用完就扔,不会互相影响。

5.2 @pytest.fixture 装饰器

import pytest                                # 导入 pytest
import tempfile                              # 临时文件
import os                                    # 文件操作


@pytest.fixture
def mock_fastq_content():
    """返回一段模拟的 FASTQ 内容"""
    return (
        "@SEQ_001\n"                         # 序列名
        "ATCGATCGATCG\n"                     # 碱基序列
        "+\n"                                # 分隔符
        "IIIIIIIIIIII\n"                     # 质量分数(全是 I = Q40)
        "@SEQ_002\n"
        "GCTAGCTAGCTA\n"
        "+\n"
        "FFFFFFFFFFFF\n"
    )


@pytest.fixture
def mock_fastq_file(tmp_path, mock_fastq_content):
    """创建一个临时 FASTQ 文件用于测试"""
    # tmp_path 是 pytest 内置 fixture,自动创建临时目录
    fq_file = tmp_path / "test.fastq"        # 在临时目录创建文件
    fq_file.write_text(mock_fastq_content)   # 写入模拟内容
    return str(fq_file)                      # 返回文件路径


def test_fastq_file_exists(mock_fastq_file):
    """测试 FASTQ 文件是否被正确创建"""
    assert os.path.exists(mock_fastq_file)   # 文件应该存在


def test_fastq_has_four_lines_per_read(mock_fastq_file):
    """FASTQ 每条 read 应该有 4 行"""
    with open(mock_fastq_file) as f:         # 打开文件
        lines = f.readlines()                # 读所有行
    assert len(lines) % 4 == 0               # 行数应该是 4 的倍数

5.3 tmp_path — 测试文件 I/O 的利器

tmp_path 是 pytest 内置的 fixture,每次测试自动创建一个临时目录,测试结束后自动清理。

def test_write_abundance_csv(tmp_path, sample_abundance_df):
    """测试丰度表能否正确保存为 CSV"""
    output_file = tmp_path / "abundance.csv"     # 临时目录中的文件
    sample_abundance_df.to_csv(output_file)      # 保存 CSV

    assert output_file.exists()                  # 文件应该被创建
    assert output_file.stat().st_size > 0        # 文件不应为空

    # 读回来验证内容
    import pandas as pd
    loaded = pd.read_csv(output_file, index_col=0)  # 读取 CSV
    assert loaded.shape == sample_abundance_df.shape  # 行列数一致

六、参数化测试

6.1 @pytest.mark.parametrize — 一个测试跑多组数据

白话:同一张试卷,换不同的数字再考一遍。

import pytest                                    # 导入 pytest
import numpy as np                               # 数值计算
from diversity import shannon_diversity          # 导入被测函数


@pytest.mark.parametrize(
    "abundances, expected_diversity",            # 参数名
    [
        # (输入丰度, 期望的 Shannon 值)
        ([1.0], 0.0),                            # 1 个物种 → H=0
        ([0.5, 0.5], np.log(2)),                 # 2 个均匀 → H=ln(2)
        ([0.25, 0.25, 0.25, 0.25], np.log(4)),  # 4 个均匀 → H=ln(4)
        ([0.1] * 10, np.log(10)),                # 10 个均匀 → H=ln(10)
        ([0.9, 0.05, 0.05], None),               # 不均匀 → 计算验证
    ],
    ids=[                                        # 给每组测试起个名字
        "single_species",
        "two_equal",
        "four_equal",
        "ten_equal",
        "uneven_distribution",
    ]
)
def test_shannon_parametrized(abundances, expected_diversity):
    """用多组数据测试 Shannon 多样性计算"""
    result = shannon_diversity(abundances)        # 计算实际值

    if expected_diversity is not None:            # 有期望值时比较
        assert result == pytest.approx(expected_diversity, rel=1e-6)
    else:
        # 不均匀分布:只检查值在合理范围内
        assert 0 < result < np.log(len(abundances))  # 0 < H < ln(S)

6.2 运行参数化测试

pytest test_diversity.py::test_shannon_parametrized -v

输出:

test_diversity.py::test_shannon_parametrized[single_species] PASSED
test_diversity.py::test_shannon_parametrized[two_equal] PASSED
test_diversity.py::test_shannon_parametrized[four_equal] PASSED
test_diversity.py::test_shannon_parametrized[ten_equal] PASSED
test_diversity.py::test_shannon_parametrized[uneven_distribution] PASSED

七、实战:为宏基因组分析函数写完整测试

7.1 被测模块 metagenomics.py

"""宏基因组分析常用函数"""
import pandas as pd                          # 数据处理
import numpy as np                           # 数值计算
from scipy.spatial.distance import braycurtis  # Bray-Curtis 距离


def filter_low_abundance(df, min_mean=0.001):
    """
    过滤低丰度物种

    参数:
        df: DataFrame,行=物种,列=样本
        min_mean: 平均丰度阈值(默认 0.1%)
    返回:
        过滤后的 DataFrame
    """
    if df.empty:                             # 空表直接返回
        return df.copy()
    mean_abund = df.mean(axis=1)             # 每个物种的平均丰度
    return df.loc[mean_abund >= min_mean].copy()  # 只保留 >= 阈值的


def calculate_bray_curtis(df):
    """
    计算样本间的 Bray-Curtis 距离矩阵

    参数:
        df: DataFrame,行=物种,列=样本
    返回:
        DataFrame: 样本 x 样本 的距离矩阵
    """
    samples = df.columns.tolist()            # 获取样本名列表
    n = len(samples)                         # 样本数量
    dist_matrix = np.zeros((n, n))           # 初始化距离矩阵

    for i in range(n):                       # 双重循环
        for j in range(i + 1, n):            # 只算上三角
            d = braycurtis(                  # scipy 的 Bray-Curtis
                df.iloc[:, i].values,        # 样本 i 的丰度向量
                df.iloc[:, j].values         # 样本 j 的丰度向量
            )
            dist_matrix[i, j] = d            # 填上三角
            dist_matrix[j, i] = d            # 对称填下三角

    return pd.DataFrame(                     # 转为 DataFrame
        dist_matrix,
        index=samples,                       # 行名 = 样本名
        columns=samples                      # 列名 = 样本名
    )


def parse_kraken_report(filepath):
    """
    解析 Kraken2 报告文件

    参数:
        filepath: Kraken2 report 文件路径
    返回:
        list[dict]: 每行解析为一个字典
    """
    results = []                             # 存解析结果
    with open(filepath) as f:                # 打开文件
        for line in f:                       # 逐行读
            line = line.strip()              # 去首尾空白
            if not line:                     # 跳过空行
                continue
            parts = line.split("\t")         # 按 tab 分割
            if len(parts) < 6:               # Kraken 报告至少 6 列
                continue
            results.append({
                "percentage": float(parts[0]),    # 第 1 列:百分比
                "reads_clade": int(parts[1]),     # 第 2 列:该分支的 reads 数
                "reads_direct": int(parts[2]),    # 第 3 列:直接命中的 reads 数
                "rank": parts[3].strip(),         # 第 4 列:分类等级(如 G=属)
                "taxid": int(parts[4]),           # 第 5 列:NCBI 分类 ID
                "name": parts[5].strip()          # 第 6 列:物种名
            })
    return results

7.2 完整测试文件 test_metagenomics.py

"""宏基因组分析函数的完整测试"""
import pytest                                # 测试框架
import pandas as pd                          # 数据处理
import numpy as np                           # 数值计算
from metagenomics import (                   # 导入被测函数
    filter_low_abundance,
    calculate_bray_curtis,
    parse_kraken_report
)


# ==================== Fixtures ====================

@pytest.fixture
def abundance_df():
    """标准丰度表 fixture"""
    return pd.DataFrame(
        {
            "S1": [0.3, 0.25, 0.2, 0.001, 0.0005],  # 样本 1
            "S2": [0.1, 0.35, 0.15, 0.002, 0.0003],  # 样本 2
            "S3": [0.2, 0.2, 0.2, 0.003, 0.0001],   # 样本 3
        },
        index=["Sp_A", "Sp_B", "Sp_C", "Sp_D", "Sp_E"]  # 物种
    )


@pytest.fixture
def identical_samples_df():
    """两个完全相同的样本"""
    return pd.DataFrame(
        {"S1": [0.5, 0.3, 0.2], "S2": [0.5, 0.3, 0.2]},
        index=["Sp_A", "Sp_B", "Sp_C"]
    )


@pytest.fixture
def kraken_report_file(tmp_path):
    """创建模拟的 Kraken2 报告文件"""
    content = (
        "25.50\t1000\t500\tG\t838\tBacteroides\n"       # 属水平
        "10.20\t400\t200\tG\t239935\tPrevotella\n"       # 属水平
        "5.00\t200\t100\tS\t817\tBacteroides fragilis\n"  # 种水平
    )
    report_path = tmp_path / "kraken_report.txt"  # 临时文件路径
    report_path.write_text(content)               # 写入内容
    return str(report_path)                       # 返回路径字符串


# ==================== filter_low_abundance 测试 ====================

class TestFilterLowAbundance:
    """测试低丰度过滤功能"""

    def test_filters_below_threshold(self, abundance_df):
        """低于阈值的物种应被过滤掉"""
        result = filter_low_abundance(abundance_df, min_mean=0.01)
        # Sp_D 平均 0.002,Sp_E 平均 0.0003 → 都应被过滤
        assert "Sp_D" not in result.index    # Sp_D 应该被去掉
        assert "Sp_E" not in result.index    # Sp_E 应该被去掉
        assert "Sp_A" in result.index        # Sp_A 应该保留

    def test_keeps_above_threshold(self, abundance_df):
        """高于阈值的物种应保留"""
        result = filter_low_abundance(abundance_df, min_mean=0.01)
        assert len(result) == 3              # 应保留 3 个高丰度物种

    def test_empty_dataframe(self):
        """空 DataFrame 应返回空 DataFrame"""
        empty = pd.DataFrame()
        result = filter_low_abundance(empty)
        assert result.empty                  # 结果也是空的

    def test_does_not_modify_original(self, abundance_df):
        """原始 DataFrame 不应被修改(不可变原则)"""
        original_shape = abundance_df.shape  # 记住原始形状
        filter_low_abundance(abundance_df, min_mean=0.1)  # 运行过滤
        assert abundance_df.shape == original_shape  # 原始数据形状不变

    def test_zero_threshold_keeps_all(self, abundance_df):
        """阈值为 0 应保留所有物种"""
        result = filter_low_abundance(abundance_df, min_mean=0)
        assert len(result) == len(abundance_df)  # 数量不变


# ==================== calculate_bray_curtis 测试 ====================

class TestCalculateBrayCurtis:
    """测试 Bray-Curtis 距离计算"""

    def test_identical_samples_zero_distance(self, identical_samples_df):
        """完全相同的样本距离应为 0"""
        result = calculate_bray_curtis(identical_samples_df)
        assert result.loc["S1", "S2"] == pytest.approx(0.0)  # 距离为 0

    def test_distance_matrix_is_symmetric(self, abundance_df):
        """距离矩阵应该是对称的"""
        result = calculate_bray_curtis(abundance_df)
        # 矩阵转置后应与原矩阵相等
        pd.testing.assert_frame_equal(result, result.T)  # 对称性检查

    def test_diagonal_is_zero(self, abundance_df):
        """对角线(自己与自己的距离)应为 0"""
        result = calculate_bray_curtis(abundance_df)
        for sample in abundance_df.columns:
            assert result.loc[sample, sample] == pytest.approx(0.0)

    def test_distance_between_0_and_1(self, abundance_df):
        """Bray-Curtis 距离范围应在 [0, 1]"""
        result = calculate_bray_curtis(abundance_df)
        assert (result.values >= -1e-10).all()   # 不应有负值(允许微小浮点误差)
        assert (result.values <= 1 + 1e-10).all()  # 不应超过 1

    def test_output_shape(self, abundance_df):
        """输出矩阵应是 n x n(n = 样本数)"""
        result = calculate_bray_curtis(abundance_df)
        n = len(abundance_df.columns)
        assert result.shape == (n, n)            # 3 个样本 → 3x3 矩阵


# ==================== parse_kraken_report 测试 ====================

class TestParseKrakenReport:
    """测试 Kraken2 报告解析"""

    def test_parses_correct_count(self, kraken_report_file):
        """应解析出正确的行数"""
        result = parse_kraken_report(kraken_report_file)
        assert len(result) == 3              # 3 行数据

    def test_parses_percentage(self, kraken_report_file):
        """百分比应正确解析为浮点数"""
        result = parse_kraken_report(kraken_report_file)
        assert result[0]["percentage"] == pytest.approx(25.50)

    def test_parses_taxon_name(self, kraken_report_file):
        """物种名应正确解析"""
        result = parse_kraken_report(kraken_report_file)
        assert result[0]["name"] == "Bacteroides"
        assert result[2]["name"] == "Bacteroides fragilis"

    def test_parses_rank(self, kraken_report_file):
        """分类等级应正确解析"""
        result = parse_kraken_report(kraken_report_file)
        assert result[0]["rank"] == "G"      # Genus(属)
        assert result[2]["rank"] == "S"      # Species(种)

    def test_empty_file(self, tmp_path):
        """空文件应返回空列表"""
        empty_file = tmp_path / "empty.txt"
        empty_file.write_text("")            # 创建空文件
        result = parse_kraken_report(str(empty_file))
        assert result == []                  # 结果为空列表

    def test_file_not_found_raises(self):
        """不存在的文件应抛出 FileNotFoundError"""
        with pytest.raises(FileNotFoundError):
            parse_kraken_report("/nonexistent/path.txt")

八、覆盖率:pytest-cov

8.1 安装

# conda 安装
conda install -c conda-forge pytest-cov      # 安装 pytest-cov 7.1.0

# pip 安装
pip install pytest-cov                        # 自动安装最新版

8.2 使用

# 基本用法:测试 + 覆盖率报告
pytest --cov=src tests/                      # --cov=源代码目录  测试目录

# 显示哪些行没被测到
pytest --cov=src --cov-report=term-missing   # term-missing 显示漏掉的行号

# 生成 HTML 报告(推荐,可以在浏览器里看)
pytest --cov=src --cov-report=html           # 生成 htmlcov/ 目录

# 设置最低覆盖率,低于 80% 就报失败
pytest --cov=src --cov-fail-under=80         # 覆盖率 < 80% → 退出码非 0

8.3 怎么看覆盖率报告

终端输出示例:

---------- coverage: platform linux, python 3.12.8 ----------
Name                  Stmts   Miss  Cover   Missing
-----------------------------------------------------
src/diversity.py         12      0   100%
src/metagenomics.py      35      3    91%   42-44
src/parsers.py           28      8    71%   15-18, 25-28
-----------------------------------------------------
TOTAL                    75     11    85%
列名含义
Stmts总语句数(可执行的代码行数)
Miss未被测试覆盖的行数
Cover覆盖率百分比
Missing具体哪些行没被测到

目标:整体覆盖率 >= 80%。 重点关注 Missing 列,补测那些没覆盖到的行。

8.4 配置文件(可选)

pyproject.toml 中统一配置 pytest 和覆盖率:

[tool.pytest]                                # pytest 9.x 原生 TOML 配置
testpaths = ["tests"]                        # 测试目录
addopts = "-v --cov=src --cov-report=term-missing"  # 默认参数

[tool.coverage.run]                          # coverage 运行配置
source = ["src"]                             # 要统计的源代码目录
omit = ["tests/*", "*/migrations/*"]         # 排除的路径

[tool.coverage.report]                       # 覆盖率报告配置
fail_under = 80                              # 低于 80% 就失败
show_missing = true                          # 显示未覆盖行号

九、常见报错

报错 1:ModuleNotFoundError: No module named 'xxx'

ModuleNotFoundError: No module named 'diversity'

原因: pytest 找不到你的源代码模块。 解决方案(选一个):

# 方案 A:在项目根目录用 pip 安装为可编辑包
pip install -e .                             # 需要 pyproject.toml 或 setup.py

# 方案 B:设置 PYTHONPATH
export PYTHONPATH="${PYTHONPATH}:$(pwd)/src"  # 把 src 目录加到搜索路径

# 方案 C:在 conftest.py 里加路径
# tests/conftest.py
import sys
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))

报错 2:AssertionError — 浮点数比较失败

AssertionError: assert 1.3862943611198906 == 1.3862943611198908

原因: 浮点数精度问题,直接用 == 比较两个浮点数几乎一定会失败。 解决:pytest.approx()

# 错误:
assert shannon_diversity([0.5, 0.5]) == np.log(2)

# 正确:
assert shannon_diversity([0.5, 0.5]) == pytest.approx(np.log(2), rel=1e-6)

报错 3:fixture 'xxx' not found

fixture 'sample_df' not found

原因: fixture 定义在别的文件里,但没放在 conftest.py 中。 解决: 把共享 fixture 移到 tests/conftest.py,pytest 会自动识别。

报错 4:collected 0 items

======================== no tests ran ========================

原因: pytest 没找到任何测试函数。 解决: 检查文件名是否以 test_ 开头,函数名是否以 test_ 开头。

# 错误:文件名 diversity_tests.py(不以 test_ 开头)
# 错误:函数名 check_shannon()(不以 test_ 开头)

# 正确:文件名 test_diversity.py
# 正确:函数名 test_shannon()

十、速查表

pytest 常用命令

pytest                                       # 运行所有测试
pytest -v                                    # 详细模式
pytest -x                                    # 遇到失败立即停止
pytest -s                                    # 显示 print 输出
pytest -k "shannon"                          # 只运行名字包含 shannon 的测试
pytest --lf                                  # 只运行上次失败的测试
pytest --co                                  # 只列出测试,不运行
pytest --cov=src                             # 带覆盖率
pytest --cov=src --cov-fail-under=80         # 覆盖率不到 80% 就失败

assert 断言速查

assert x == y                                # 相等
assert x != y                                # 不等
assert x > y                                 # 大于
assert x in collection                       # 包含
assert isinstance(x, MyClass)                # 类型检查
assert x == pytest.approx(y, rel=1e-6)       # 浮点数近似相等
assert x == pytest.approx(y, abs=0.01)       # 绝对误差 0.01

# 测试异常
with pytest.raises(ValueError):              # 期望抛出 ValueError
    risky_function()

with pytest.raises(ValueError, match="xxx"):  # 期望错误信息包含 xxx
    risky_function()

fixture 速查

@pytest.fixture                              # 基本 fixture
def my_data():
    return [1, 2, 3]

@pytest.fixture(scope="module")              # 模块级(整个文件共享一个)
def expensive_resource():
    return load_big_data()

@pytest.fixture(autouse=True)                # 自动应用(不需要传参)
def setup_logging():
    logging.basicConfig(level=logging.DEBUG)

# 内置 fixture
# tmp_path     → 临时目录(pathlib.Path 对象)
# tmp_path_factory → 创建多个临时目录
# capsys       → 捕获 stdout/stderr
# monkeypatch  → 临时修改环境变量、属性等

参数化速查

@pytest.mark.parametrize("input,expected", [
    (arg1, result1),
    (arg2, result2),
])
def test_xxx(input, expected):
    assert func(input) == expected

关键概念一句话总结

概念一句话
单元测试代码的保险,改了代码自动检查有没有改坏
pytestPython 最流行的测试框架,简单好用
assert断言,"我宣布这个条件必须成立"
fixture考试草稿纸,每次测试给你一份新的
参数化同一张试卷换不同数字再考
覆盖率你的代码有多少百分比被测试覆盖了
TDD先写测试再写代码,红 → 绿 → 重构
conftest.py公共工具箱,里面的 fixture 所有测试都能用

推荐阅读: 566_Python面向对象编程_生信实战.md — 给你的 class 写好之后,用 pytest 给它上保险