diff --git a/backend/pyproject.toml b/backend/pyproject.toml index eab1a95..a0f735e 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -63,18 +63,3 @@ exclude = [ "alembic/", ".venv/", ] - -[tool.pytest.ini_options] -minversion = "7.0" -testpaths = ["tests"] -python_files = ["test_*.py"] -python_classes = ["Test*"] -python_functions = ["test_*"] -addopts = [ - "-v", - "--strict-markers", - "--strict-config", - "--cov=app", - "--cov-report=term-missing", - "--cov-report=html", -] diff --git a/backend/pytest.ini b/backend/pytest.ini index c67a46a..1943f83 100644 --- a/backend/pytest.ini +++ b/backend/pytest.ini @@ -9,6 +9,9 @@ python_functions = test_* # Test directories testpaths = tests +# Asyncio configuration +asyncio_mode = auto + # Output options addopts = # Verbose output diff --git a/backend/tests/README.md b/backend/tests/README.md new file mode 100644 index 0000000..d2d4072 --- /dev/null +++ b/backend/tests/README.md @@ -0,0 +1,450 @@ +# 测试配置说明 + +## 概述 + +本项目使用 Pytest 作为测试框架,配置了完整的测试基础设施,包括数据库隔离、认证模拟和代码覆盖率报告。 + +## 快速开始 + +### 安装依赖 + +```bash +pip install -r requirements-dev.txt +``` + +### 运行测试 + +```bash +# 运行所有测试 +pytest + +# 运行特定测试文件 +pytest tests/test_auth_api.py + +# 运行特定测试函数 +pytest tests/test_auth_api.py::test_login_success + +# 运行带标记的测试 +pytest -m auth +pytest -m "not slow" + +# 查看覆盖率报告 +pytest --cov=app --cov-report=html +# 报告生成在 htmlcov/index.html +``` + +## 核心 Fixtures + +### 数据库 Fixtures + +#### `test_db` / `db_session` +创建临时 SQLite 测试数据库,每个测试函数使用独立的事务,测试结束后自动回滚。 + +```python +def test_create_user(test_db): + user = User(username="test", email="test@example.com") + test_db.add(user) + test_db.commit() + assert user.id is not None +``` + +#### `test_db_file` +Session级别的临时数据库文件路径。 + +#### `test_engine` +Session级别的数据库引擎。 + +### 客户端 Fixtures + +#### `test_client` / `client` +FastAPI TestClient 实例,自动注入测试数据库依赖。 + +```python +def test_api_endpoint(test_client): + response = test_client.get("/api/users") + assert response.status_code == 200 +``` + +### 测试数据 Fixtures + +#### `test_user` +创建一个普通测试用户,自动关联到 `test_community`,角色为 admin。 + +```python +def test_user_properties(test_user): + assert test_user.username == "testuser" + assert test_user.email == "testuser@example.com" +``` + +#### `test_superuser` +创建一个超级管理员用户。 + +#### `test_community` +创建一个测试社区。 + +```python +def test_community_properties(test_community): + assert test_community.slug == "test-community" + assert test_community.is_active is True +``` + +#### `test_another_community` / `test_another_user` +用于测试多社区隔离场景的额外社区和用户。 + +### 认证 Fixtures + +#### `auth_headers` +生成包含 JWT token 和 Community ID 的认证头,用于 `test_user`。 + +```python +def test_authenticated_request(test_client, auth_headers): + response = test_client.get("/api/protected", headers=auth_headers) + assert response.status_code == 200 +``` + +#### `superuser_auth_headers` +超级管理员的认证头。 + +#### `another_user_auth_headers` +另一个用户的认证头,用于测试权限隔离。 + +#### `user_token` / `superuser_token` / `another_user_token` +JWT token 字符串(不包含 Community ID)。 + +## 测试示例 + +### 基础 API 测试 + +```python +def test_get_users(test_client, auth_headers): + """测试获取用户列表""" + response = test_client.get("/api/users", headers=auth_headers) + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) +``` + +### 数据库操作测试 + +```python +def test_create_community(test_db, test_user): + """测试创建社区""" + community = Community( + name="New Community", + slug="new-community", + description="Test description" + ) + test_db.add(community) + test_db.commit() + test_db.refresh(community) + + assert community.id is not None + assert community.slug == "new-community" +``` + +### 异步测试 + +```python +import pytest + +@pytest.mark.asyncio +async def test_async_operation(test_db): + """测试异步操作""" + # 异步代码会自动运行在正确的事件循环中 + result = await some_async_function() + assert result is not None +``` + +### 权限测试 + +```python +def test_permission_isolation( + test_client, + auth_headers, + another_user_auth_headers, + test_community, + test_another_community +): + """测试用户只能访问自己社区的内容""" + # test_user 创建内容 + response = test_client.post( + "/api/contents", + headers=auth_headers, + json={"title": "Test", "content": "Content"} + ) + content_id = response.json()["id"] + + # another_user 不应该能访问 + response = test_client.get( + f"/api/contents/{content_id}", + headers=another_user_auth_headers + ) + assert response.status_code == 404 +``` + +## 测试标记 + +使用标记来分类和选择性运行测试: + +```python +@pytest.mark.slow +def test_heavy_operation(): + """慢速测试""" + pass + +@pytest.mark.integration +def test_full_workflow(): + """集成测试""" + pass + +@pytest.mark.auth +def test_login(): + """认证相关测试""" + pass +``` + +运行特定标记的测试: + +```bash +pytest -m slow # 只运行慢速测试 +pytest -m "not slow" # 跳过慢速测试 +pytest -m "auth or api" # 运行认证或API测试 +``` + +可用标记: +- `slow`: 慢速测试 +- `integration`: 集成测试 +- `unit`: 单元测试 +- `api`: API端点测试 +- `auth`: 认证相关测试 +- `community`: 社区相关测试 +- `content`: 内容管理测试 +- `publish`: 发布相关测试 +- `analytics`: 分析相关测试 + +## 代码覆盖率 + +### 覆盖率要求 + +项目要求最低 **80%** 代码覆盖率。如果覆盖率低于此阈值,pytest 将以失败状态退出。 + +### 查看覆盖率报告 + +```bash +# 终端输出(显示未覆盖的行) +pytest --cov=app --cov-report=term-missing + +# HTML 报告(更详细) +pytest --cov=app --cov-report=html +open htmlcov/index.html + +# 两种报告都生成(默认配置) +pytest +``` + +### 覆盖率配置 + +覆盖率配置在 [pytest.ini](../pytest.ini) 中: + +```ini +[coverage:run] +source = app +omit = + */tests/* + */migrations/* + */__pycache__/* + +[coverage:report] +fail_under = 80 # 最低覆盖率要求 +precision = 2 # 精度 +show_missing = True # 显示未覆盖的行 +``` + +## Pytest 配置 + +### pytest.ini + +主要配置在 [pytest.ini](../pytest.ini): + +- **asyncio_mode = auto**: 自动检测和运行异步测试 +- **-v**: 详细输出 +- **-l**: 显示局部变量 +- **-ra**: 显示所有测试结果摘要 +- **--strict-markers**: 严格标记模式 +- **--cov-branch**: 分支覆盖率 +- **-p no:warnings**: 禁用警告 + +### 异步测试支持 + +使用 `pytest-asyncio` 支持异步测试,配置为 `auto` 模式: + +```python +# 自动识别为异步测试(不需要 @pytest.mark.asyncio) +async def test_async_function(): + result = await async_operation() + assert result is not None +``` + +如果需要手动标记: + +```python +@pytest.mark.asyncio +async def test_explicit_async(): + await something() +``` + +## 项目结构 + +``` +backend/ +├── tests/ +│ ├── conftest.py # Fixtures 配置 +│ ├── test_auth_api.py # 认证 API 测试 +│ ├── test_communities_api.py # 社区 API 测试 +│ ├── test_contents_api.py # 内容 API 测试 +│ ├── test_governance_api.py # 治理 API 测试 +│ ├── test_publish_api.py # 发布 API 测试 +│ ├── test_analytics_api.py # 分析 API 测试 +│ └── README.md # 本文档 +├── pytest.ini # Pytest 配置 +├── requirements-dev.txt # 开发依赖 +└── htmlcov/ # 覆盖率报告(生成) +``` + +## 最佳实践 + +### 1. 测试隔离 + +每个测试应该独立运行,不依赖其他测试的状态: + +```python +# 好的做法 +def test_create_user(test_db): + user = User(username="unique_user") + test_db.add(user) + test_db.commit() + +# 避免共享状态 +global_user = None # 不要这样做 +``` + +### 2. 使用 Fixtures + +充分利用已有的 fixtures,避免重复代码: + +```python +# 好的做法 +def test_with_fixtures(test_client, auth_headers, test_user): + pass + +# 避免 +def test_manual_setup(test_client): + # 手动创建用户、token 等 + user = User(...) + # ... +``` + +### 3. 清晰的测试名称 + +测试函数名应该清楚地描述测试内容: + +```python +def test_create_content_with_valid_data_succeeds(): + pass + +def test_create_content_without_auth_returns_401(): + pass +``` + +### 4. 单一职责 + +每个测试应该只测试一个功能点: + +```python +# 好的做法 +def test_user_creation(): + # 只测试创建用户 + pass + +def test_user_login(): + # 只测试登录 + pass + +# 避免 +def test_user_creation_and_login_and_update(): + # 测试太多功能 + pass +``` + +### 5. 使用标记组织测试 + +```python +@pytest.mark.api +@pytest.mark.auth +def test_login_endpoint(): + pass +``` + +## 常见问题 + +### Q: 测试数据库会影响生产数据吗? + +A: 不会。测试使用独立的临时 SQLite 数据库,每次测试后自动清理。 + +### Q: 如何调试失败的测试? + +```bash +# 在第一个失败时停止 +pytest -x + +# 显示局部变量 +pytest -l + +# 进入调试器 +pytest --pdb + +# 详细输出 +pytest -vv +``` + +### Q: 如何跳过某些测试? + +```python +@pytest.mark.skip(reason="Not implemented yet") +def test_future_feature(): + pass + +@pytest.mark.skipif(condition, reason="...") +def test_conditional(): + pass +``` + +### Q: 覆盖率不达标怎么办? + +1. 运行 `pytest --cov=app --cov-report=html` +2. 打开 `htmlcov/index.html` 查看详细报告 +3. 找出未覆盖的代码 +4. 为未覆盖的代码编写测试 + +## 持续集成 + +在 CI/CD 中运行测试: + +```bash +# 安装依赖 +pip install -r requirements.txt +pip install -r requirements-dev.txt + +# 运行测试并生成覆盖率报告 +pytest --cov=app --cov-report=xml --cov-report=term + +# 检查覆盖率(会在低于80%时失败) +pytest +``` + +## 相关文档 + +- [Pytest 官方文档](https://docs.pytest.org/) +- [pytest-asyncio 文档](https://pytest-asyncio.readthedocs.io/) +- [Coverage.py 文档](https://coverage.readthedocs.io/) +- [FastAPI 测试文档](https://fastapi.tiangolo.com/tutorial/testing/) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 175505a..bc1fdd6 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -231,3 +231,16 @@ def another_user_auth_headers(another_user_token: str, test_another_community: C "Authorization": f"Bearer {another_user_token}", "X-Community-Id": str(test_another_community.id), } + + +# Fixture aliases for standard naming conventions +@pytest.fixture(scope="function") +def test_db(db_session: Session) -> Generator[Session, None, None]: + """Alias for db_session fixture (standard naming convention).""" + yield db_session + + +@pytest.fixture(scope="function") +def test_client(client: TestClient) -> Generator[TestClient, None, None]: + """Alias for client fixture (standard naming convention).""" + yield client diff --git a/backend/tests/test_example.py b/backend/tests/test_example.py new file mode 100644 index 0000000..9b5870a --- /dev/null +++ b/backend/tests/test_example.py @@ -0,0 +1,179 @@ +""" +示例测试文件:展示如何使用测试框架和 fixtures + +本文件包含各种测试示例,演示如何使用配置好的 fixtures。 +""" + +import pytest +from sqlalchemy.orm import Session +from fastapi.testclient import TestClient + +from app.models.user import User +from app.models.community import Community + + +class TestFixturesExample: + """演示如何使用各种 fixtures""" + + def test_with_test_db(self, test_db: Session): + """使用 test_db fixture 进行数据库操作""" + # test_db 是 db_session 的别名 + user = User( + username="example_user", + email="example@test.com", + hashed_password="hashed_password" + ) + test_db.add(user) + test_db.commit() + test_db.refresh(user) + + assert user.id is not None + assert user.username == "example_user" + + def test_with_test_client(self, test_client: TestClient): + """使用 test_client fixture 测试 API""" + # test_client 是 client 的别名 + response = test_client.get("/api/health") + # 根据你的实际 API 调整断言 + assert response.status_code in [200, 404] + + def test_with_test_user(self, test_user: User): + """使用预创建的测试用户""" + assert test_user.username == "testuser" + assert test_user.email == "testuser@example.com" + assert test_user.is_active is True + assert test_user.is_superuser is False + + def test_with_test_community(self, test_community: Community): + """使用预创建的测试社区""" + assert test_community.name == "Test Community" + assert test_community.slug == "test-community" + assert test_community.is_active is True + + def test_with_auth_headers(self, test_client: TestClient, auth_headers: dict): + """使用认证头访问受保护的端点""" + # auth_headers 包含 JWT token 和 Community ID + assert "Authorization" in auth_headers + assert "X-Community-Id" in auth_headers + assert auth_headers["Authorization"].startswith("Bearer ") + + # 示例:访问需要认证的端点 + # response = test_client.get("/api/me", headers=auth_headers) + # assert response.status_code == 200 + + def test_complete_workflow( + self, + test_client: TestClient, + auth_headers: dict, + test_user: User, + test_community: Community, + ): + """完整的工作流程测试:使用多个 fixtures""" + # 验证用户属于社区 + assert test_user.id is not None + assert test_community.id is not None + + # 使用认证头访问 API + # response = test_client.get( + # f"/api/communities/{test_community.id}", + # headers=auth_headers + # ) + # assert response.status_code == 200 + + +class TestPermissionIsolation: + """演示如何测试多社区权限隔离""" + + def test_cross_community_access_denied( + self, + test_client: TestClient, + auth_headers: dict, + another_user_auth_headers: dict, + test_community: Community, + test_another_community: Community, + ): + """测试用户不能访问其他社区的资源""" + # test_user 属于 test_community + # test_another_user 属于 test_another_community + + # 验证两个社区不同 + assert test_community.id != test_another_community.id + + # 示例:test_user 不应该能访问 another_community 的资源 + # response = test_client.get( + # f"/api/communities/{test_another_community.id}/contents", + # headers=auth_headers + # ) + # assert response.status_code in [403, 404] + + +class TestAsyncExample: + """演示异步测试(如果你的代码使用异步)""" + + @pytest.mark.asyncio + async def test_async_operation(self): + """异步测试示例""" + # pytest-asyncio 配置为 auto 模式 + # async def 函数会自动运行在正确的事件循环中 + + async def async_function(): + return "async result" + + result = await async_function() + assert result == "async result" + + async def test_auto_async(self): + """ + 不需要 @pytest.mark.asyncio 装饰器 + 因为配置了 asyncio_mode = auto + """ + async def another_async(): + return 42 + + result = await another_async() + assert result == 42 + + +@pytest.mark.api +class TestAPIEndpoints: + """API 端点测试示例""" + + @pytest.mark.auth + def test_protected_endpoint(self, test_client: TestClient, auth_headers: dict): + """测试需要认证的端点""" + # response = test_client.get("/api/protected", headers=auth_headers) + # assert response.status_code == 200 + pass + + @pytest.mark.auth + def test_unauthenticated_access(self, test_client: TestClient): + """测试未认证访问受保护端点""" + # response = test_client.get("/api/protected") + # assert response.status_code == 401 + pass + + +@pytest.mark.integration +class TestIntegration: + """集成测试示例""" + + def test_full_user_journey( + self, + test_client: TestClient, + test_db: Session, + test_community: Community, + ): + """测试完整的用户使用流程""" + # 1. 注册 + # 2. 登录 + # 3. 创建内容 + # 4. 发布内容 + # 5. 查看内容 + pass + + +# 测试运行示例命令: +# pytest backend/tests/test_example.py -v +# pytest backend/tests/test_example.py::TestFixturesExample::test_with_test_user -v +# pytest backend/tests/test_example.py -m api +# pytest backend/tests/test_example.py -m "not integration" diff --git a/backend/verify_test_config.py b/backend/verify_test_config.py new file mode 100644 index 0000000..41b2964 --- /dev/null +++ b/backend/verify_test_config.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +""" +验证 Pytest 测试配置 + +运行此脚本检查测试配置是否正确设置。 +""" + +import sys +from pathlib import Path + +# 检查配置文件 +backend_dir = Path(__file__).parent +configs = { + "pytest.ini": backend_dir / "pytest.ini", + "conftest.py": backend_dir / "tests" / "conftest.py", + "requirements-dev.txt": backend_dir / "requirements-dev.txt", + "pyproject.toml": backend_dir / "pyproject.toml", +} + +print("=" * 60) +print("Pytest 测试配置验证") +print("=" * 60) + +all_good = True + +# 1. 检查配置文件是否存在 +print("\n✓ 检查配置文件:") +for name, path in configs.items(): + if path.exists(): + print(f" ✓ {name} 存在") + else: + print(f" ✗ {name} 不存在") + all_good = False + +# 2. 检查 pytest.ini 中的 asyncio_mode +print("\n✓ 检查 pytest.ini 配置:") +pytest_ini = configs["pytest.ini"] +if pytest_ini.exists(): + content = pytest_ini.read_text() + if "asyncio_mode = auto" in content: + print(" ✓ asyncio_mode 已配置为 auto") + else: + print(" ✗ asyncio_mode 未配置") + all_good = False + + if "fail_under = 80" in content or "80" in content: + print(" ✓ 覆盖率要求 ≥80% 已配置") + else: + print(" ! 覆盖率阈值可能未设置为 80%") + + if "--cov=app" in content: + print(" ✓ 覆盖率报告已配置") + else: + print(" ✗ 覆盖率报告未配置") + all_good = False + +# 3. 检查 conftest.py 中的 fixtures +print("\n✓ 检查 conftest.py fixtures:") +conftest = configs["conftest.py"] +if conftest.exists(): + content = conftest.read_text() + required_fixtures = [ + ("test_db", "测试数据库 fixture"), + ("test_client", "测试客户端 fixture"), + ("auth_headers", "认证头 fixture"), + ("test_user", "测试用户 fixture"), + ("test_community", "测试社区 fixture"), + ] + + for fixture_name, description in required_fixtures: + if f"def {fixture_name}" in content: + print(f" ✓ {fixture_name} ({description})") + else: + print(f" ✗ {fixture_name} ({description}) 未找到") + all_good = False + +# 4. 检查依赖包 +print("\n✓ 检查开发依赖:") +req_dev = configs["requirements-dev.txt"] +if req_dev.exists(): + content = req_dev.read_text() + required_packages = [ + ("pytest", "测试框架"), + ("pytest-asyncio", "异步测试支持"), + ("pytest-cov", "覆盖率报告"), + ] + + for package, description in required_packages: + if package in content: + print(f" ✓ {package} ({description})") + else: + print(f" ✗ {package} ({description}) 未安装") + all_good = False + +# 5. 检查测试文件 +print("\n✓ 检查测试文件:") +tests_dir = backend_dir / "tests" +if tests_dir.exists(): + test_files = list(tests_dir.glob("test_*.py")) + print(f" ✓ 找到 {len(test_files)} 个测试文件") + for test_file in sorted(test_files)[:5]: # 只显示前5个 + print(f" - {test_file.name}") + if len(test_files) > 5: + print(f" ... 还有 {len(test_files) - 5} 个文件") +else: + print(" ✗ tests 目录不存在") + all_good = False + +# 总结 +print("\n" + "=" * 60) +if all_good: + print("✓ 所有配置检查通过!") + print("\n下一步:") + print("1. 安装开发依赖: pip install -r requirements-dev.txt") + print("2. 运行测试: pytest") + print("3. 查看覆盖率: pytest --cov=app --cov-report=html") + print("4. 打开覆盖率报告: open htmlcov/index.html") + sys.exit(0) +else: + print("✗ 部分配置检查失败,请检查上述错误。") + sys.exit(1)