4. Pytest 入门:断言机制、测试用例编写、Fixture 基础、conftest.py 共享配置

好,咱们进入第四章。前面几章我们把 Python 基础和环境搭好了,现在开始真正写测试脚本。

Pytest 是目前 Python 生态里最主流的测试框架。我这些年做嵌入式测试,从早期的 unittest 到后来的 nose,最后全切到了 pytest。为什么?因为它简洁、灵活,而且插件生态太丰富了。你想想看,一个框架能同时搞定单元测试、集成测试、甚至硬件接口测试,这得多省事。

4.1 断言机制:测试的灵魂

测试的本质是什么?说白了就是「比较预期和实际」。Pytest 的断言用起来特别自然——就是 Python 原生的 assert 关键字。

核心原则: 测试用例中只要 assert 后面的表达式为 True,用例就通过;为 False,就失败。

4.1.1 基本断言写法

# test_assert_basic.py
def test_addition():
    result = 1 + 1
    assert result == 2

def test_string():
    name = "hello"
    assert "he" in name

def test_boolean():
    flag = True
    assert flag is True

你看,就这么简单。不需要像 unittest 那样记一堆 self.assertEqual() 的方法名。

4.1.2 常用断言场景

场景 断言写法 说明
相等判断 assert a == b 最常用,比较值
不等判断 assert a != b 确认两者不同
包含判断 assert x in y 字符串、列表、字典都适用
布尔判断 assert x is True 注意用 is 而不是 ==
异常判断 with pytest.raises(ValueError) 确认代码抛出了指定异常
近似判断 assert abs(a - b) < 1e-6 浮点数比较,避免精度问题

我的经验: 嵌入式测试里经常要比较浮点数,比如 ADC 采样值。直接用 assert a == b 很容易踩坑。我习惯用 pytest.approx(),它内置了容差处理。

# 浮点数比较的正确姿势
def test_float():
    adc_value = 3.1415926
    expected = 3.14
    assert adc_value == pytest.approx(expected, rel=1e-2)  # 相对误差 1%

4.1.3 异常断言

有时候我们需要测试「代码应该抛出异常」的场景。比如读取一个不存在的文件,或者传入非法参数。

import pytest

def test_divide_by_zero():
    with pytest.raises(ZeroDivisionError):
        result = 1 / 0

def test_custom_exception():
    with pytest.raises(ValueError, match="参数不能为负数"):
        validate_input(-1)

这里 match 参数可以匹配异常信息中的关键字,非常实用。

4.2 测试用例编写:从单测到套件

Pytest 的用例编写有几个约定,记住就行:

  • 测试文件以 test_ 开头,或者以 _test 结尾
  • 测试函数以 test_ 开头
  • 测试类以 Test 开头,且不能有 __init__ 方法

4.2.1 函数式用例

# test_sensor.py
def test_temperature_read():
    """测试温度传感器读取"""
    sensor = TemperatureSensor()
    value = sensor.read()
    assert 20 <= value <= 30

def test_humidity_read():
    """测试湿度传感器读取"""
    sensor = HumiditySensor()
    value = sensor.read()
    assert 40 <= value <= 60

4.2.2 类式用例

当多个测试用例共享相同的初始化逻辑时,用类组织更清晰。

class TestSensorSuite:
    """传感器测试套件"""

    def test_temperature(self):
        sensor = TemperatureSensor()
        assert sensor.init() == True

    def test_humidity(self):
        sensor = HumiditySensor()
        assert sensor.init() == True

    def test_pressure(self):
        sensor = PressureSensor()
        assert sensor.init() == True

注意: 类里的每个 test_ 方法都是独立执行的。不要依赖方法之间的执行顺序,也不要共享变量状态。我曾经见过有人把测试结果存在类变量里,结果并行执行时全乱了。

4.3 Fixture 基础:测试的「前置条件」管理

Fixture 是 pytest 最强大的特性之一。它解决了测试中「准备数据、创建对象、清理资源」这些重复劳动。

4.3.1 最简单的 Fixture

import pytest

@pytest.fixture
def sensor():
    """创建一个传感器实例"""
    s = TemperatureSensor()
    s.connect()
    yield s
    s.disconnect()

def test_sensor_read(sensor):
    """使用 fixture 作为参数"""
    value = sensor.read()
    assert value is not None

这里 yield 之前的代码是「前置准备」,之后的代码是「后置清理」。我特别喜欢这种写法,比 unittest 的 setUp/tearDown 直观多了。

4.3.2 Fixture 的作用域

作用域 生命周期 适用场景
function 每个测试函数执行一次 默认值,适合独立资源
class 每个测试类执行一次 类级别的共享资源
module 每个模块执行一次 模块级别的配置加载
session 整个测试会话执行一次 数据库连接、硬件初始化
@pytest.fixture(scope="session")
def hardware_board():
    """整个测试过程只初始化一次硬件"""
    board = Board()
    board.power_on()
    yield board
    board.power_off()

我的建议: 嵌入式测试里,硬件初始化通常很慢。用 scope="session" 可以大幅节省时间。但要注意——如果硬件状态会被测试用例修改,就别用 session 级别,否则用例之间会互相影响。

4.3.3 Fixture 的自动使用

有些 fixture 你希望每个测试都自动执行,比如记录日志、检查环境。

@pytest.fixture(autouse=True)
def check_environment():
    """每个测试前自动检查环境"""
    assert is_board_connected(), "硬件未连接"
    yield
    print("环境检查完成")

加上 autouse=True 后,这个 fixture 会自动应用到所有测试用例,不需要显式声明参数。

4.4 conftest.py:共享配置的「中央枢纽」

conftest.py 是 pytest 的「魔法文件」。放在某个目录下的 conftest.py,会自动被该目录及其子目录下的所有测试文件共享。

4.4.1 目录结构示例

project/
├── conftest.py              # 全局 fixture
├── tests/
│   ├── conftest.py          # tests 目录共享 fixture
│   ├── test_sensor.py
│   └── sub_tests/
│       ├── conftest.py      # sub_tests 目录共享 fixture
│       └── test_advanced.py

4.4.2 conftest.py 里写什么

# conftest.py (项目根目录)
import pytest

@pytest.fixture(scope="session")
def global_config():
    """全局配置,所有测试共享"""
    return {
        "baudrate": 115200,
        "timeout": 5,
        "log_level": "DEBUG"
    }

@pytest.fixture
def serial_port(global_config):
    """串口连接 fixture"""
    import serial
    port = serial.Serial(
        port="/dev/ttyUSB0",
        baudrate=global_config["baudrate"],
        timeout=global_config["timeout"]
    )
    yield port
    port.close()

然后在任意测试文件里,直接使用 serial_port 这个 fixture:

# tests/test_uart.py
def test_uart_send(serial_port):
    serial_port.write(b"AT\r\n")
    response = serial_port.readline()
    assert b"OK" in response

关键点: conftest.py 不需要被 import。pytest 会自动发现它。你只需要把 fixture 定义在里面,测试文件就能直接用。

4.4.3 覆盖与优先级

如果多个 conftest.py 定义了同名的 fixture,离测试文件最近的会覆盖远的。这个机制很灵活,你可以:

  • 在根 conftest.py 定义通用 fixture
  • 在子目录 conftest.py 定义专用 fixture
  • 在测试文件里直接定义局部 fixture

避坑指南: 我曾经在项目里把 fixture 定义在 conftest.py 里,但忘记加 scope 参数。结果每个测试都重新创建数据库连接,跑 200 个用例花了 10 分钟。加上 scope="session" 后,直接降到 30 秒。所以,fixture 的作用域一定要想清楚。

4.5 本章知识体系

下面这张图总结了本章的核心内容,你可以把它当作速查表:

Pytest 入门知识体系 Pytest 入门核心知识体系 断言机制 测试用例编写 Fixture 基础 conftest.py Pytest assert 表达式 pytest.raises pytest.approx test_ 函数 Test 类 命名约定 @pytest.fixture scope 作用域 yield 清理 自动发现 层级覆盖 全局共享 核心工作流 编写 test_ 用例 → 使用 assert 断言 → 通过 fixture 管理前置/后置 → 利用 conftest.py 共享配置 → pytest 自动发现并执行

4.6 小结

这一章我们覆盖了 pytest 最核心的四个概念:

  • 断言机制:用 Python 原生的 assert,加上 pytest 的异常断言和近似断言
  • 测试用例:函数式和类式两种写法,记住命名约定
  • Fixture:管理测试的前置条件和后置清理,注意作用域的选择
  • conftest.py:共享配置的中央枢纽,自动发现,层级覆盖