在Python生态中,单元测试框架的选择一直是个值得讨论的话题。我最早接触的是Python自带的unittest模块,后来也尝试过nose,但最终Pytest成为了我的主力测试工具。原因很简单:它让编写测试变得异常简单,同时又提供了强大的扩展能力。
Pytest最吸引我的几个特点:
举个例子,用unittest写一个简单的测试需要这样:
python复制import unittest
class TestStringMethods(unittest.TestCase):
def test_upper(self):
self.assertEqual('foo'.upper(), 'FOO')
而用Pytest可以简化为:
python复制def test_upper():
assert 'foo'.upper() == 'FOO'
这种简洁性在大型项目中尤其有价值,能显著减少样板代码。根据我的经验,一个中等规模的项目(约100个测试用例)改用Pytest后,测试代码量能减少30%左右。
Pytest遵循"约定优于配置"的原则,对测试文件和测试函数有明确的命名约定:
test_*.py或*_test.pytest_开头Test开头(且不能有__init__方法)这种命名约定不是强制的,但遵循它能让Pytest自动发现你的测试。我在项目中遇到过因为命名不规范导致测试没被运行的情况,所以建议严格遵守这些约定。
Pytest支持Python原生的assert语句,这是它与unittest最大的不同之一。以下是一些常见的断言示例:
python复制def test_addition():
assert 1 + 1 == 2
def test_list_contains():
items = ['apple', 'banana', 'orange']
assert 'apple' in items
def test_dictionary_keys():
d = {'name': 'Alice', 'age': 30}
assert 'name' in d
assert 'address' not in d
当断言失败时,Pytest会提供非常详细的错误信息。例如:
python复制def test_string_compare():
assert "hello" == "world"
运行后会输出:
code复制E AssertionError: assert 'hello' == 'world'
E - world
E + hello
测试代码是否抛出了预期的异常也很简单:
python复制import pytest
def test_zero_division():
with pytest.raises(ZeroDivisionError):
1 / 0
你还可以检查异常的具体信息:
python复制def test_exception_message():
with pytest.raises(ValueError) as excinfo:
int('foo')
assert 'invalid literal' in str(excinfo.value)
fixture是Pytest最强大的功能之一,它可以帮助你设置和清理测试环境。我经常用它来初始化数据库连接、创建临时文件等。
python复制import pytest
@pytest.fixture
def database_connection():
# 建立数据库连接
conn = create_connection()
yield conn # 这是测试中实际使用的部分
# 测试完成后清理
conn.close()
def test_query(database_connection):
result = database_connection.execute("SELECT 1")
assert result == 1
fixture可以设置作用域:
function(默认):每个测试函数运行一次class:每个测试类运行一次module:每个模块运行一次session:整个测试会话运行一次当你想用不同的输入测试相同的功能时,参数化测试非常有用:
python复制import pytest
@pytest.mark.parametrize("input,expected", [
("3+5", 8),
("2+4", 6),
("6*9", 42),
])
def test_eval(input, expected):
assert eval(input) == expected
这个测试会运行三次,每次使用不同的输入和预期输出。当某个case失败时,Pytest会清楚地告诉你哪个参数组合失败了。
Pytest允许你给测试打标记,然后选择性地运行它们:
python复制@pytest.mark.slow
def test_long_running_operation():
time.sleep(10)
assert True
@pytest.mark.skip(reason="not implemented yet")
def test_unimplemented_feature():
assert False
运行时可以这样过滤:
bash复制pytest -m "not slow" # 不运行标记为slow的测试
pytest -m "slow" # 只运行标记为slow的测试
经过多个项目的实践,我发现这些插件特别有用:
pytest-cov:生成测试覆盖率报告
bash复制pip install pytest-cov
pytest --cov=myproject tests/
pytest-xdist:并行运行测试
bash复制pip install pytest-xdist
pytest -n 4 # 使用4个worker并行运行
pytest-mock:简化mock使用
python复制def test_mocking(mocker):
mocker.patch('os.listdir', return_value=['file1.txt'])
assert os.listdir() == ['file1.txt']
pytest-html:生成HTML报告
bash复制pip install pytest-html
pytest --html=report.html
项目根目录下的pytest.ini文件可以保存常用配置:
ini复制[pytest]
addopts = -v --tb=native
python_files = test_*.py
python_functions = test_*
markers =
slow: marks tests as slow (deselect with '-m "not slow"')
integration: marks integration tests
这个配置会:
-v)--tb=native)根据我的经验,健康的测试结构应该遵循测试金字塔:
Pytest适合所有层次的测试,但要注意组织方式。我通常这样组织项目结构:
code复制project/
├── src/
│ └── mymodule.py
└── tests/
├── unit/
│ └── test_*.py
├── integration/
│ └── test_*.py
└── e2e/
└── test_*.py
问题1:测试依赖顺序
Pytest默认以随机顺序运行测试,但有时测试之间有依赖关系
解决方案:
pytest-order插件问题2:测试太慢
随着项目增长,测试套件可能变得很慢
优化方法:
pytest-xdist并行运行@pytest.mark.slow并默认跳过问题3:测试不稳定
有时测试会随机失败(flaky tests)
排查技巧:
--flake-finder插件识别不稳定测试测试代码也需要维护,我遵循这些原则:
测试名称应该清晰表达测试目的
test_case1test_addition_with_positive_numbers每个测试只验证一件事
避免测试中包含太多逻辑 - 测试应该简单直接
定期清理过时测试
Pytest可以轻松集成到持续集成流程中。这是我在GitHub Actions中的配置示例:
yaml复制name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: '3.9'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install pytest pytest-cov
- name: Test with pytest
run: |
pytest --cov=./ --cov-report=xml
- name: Upload coverage
uses: codecov/codecov-action@v1
这个配置会:
在最近的一个Web项目中,我们遇到了API测试的挑战。最终我们采用了这样的方案:
python复制import pytest
from fastapi.testclient import TestClient
from myapp.main import app
@pytest.fixture
def client():
return TestClient(app)
def test_read_item(client):
response = client.get("/items/42")
assert response.status_code == 200
assert response.json() == {"item_id": 42}
def test_create_item(client):
response = client.post(
"/items/",
json={"name": "New Item"},
)
assert response.status_code == 201
assert "id" in response.json()
关键点:
另一个有用的技巧是使用工厂模式创建测试数据:
python复制@pytest.fixture
def user_factory():
def create_user(name=None, email=None):
return User(
name=name or "Test User",
email=email or "test@example.com"
)
return create_user
def test_user_creation(user_factory):
user = user_factory(name="Alice")
assert user.name == "Alice"
这种方法使测试更灵活,同时避免了重复代码。