diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..c065e82 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,245 @@ +# 测试框架使用指南 + +## 概述 + +项目已配置 **pytest** 测试框架,相比你之前的手动测试方法,有以下优势: + +### ✅ 改进点 + +| 旧方法 (Jupyter Notebook) | 新方法 (pytest) | +|-------------------------|----------------| +| 手动运行每个测试用例 | 自动发现和运行所有测试 | +| 手动读取和对比文件 | 自动化断言和差异报告 | +| 难以定位失败原因 | 清晰的错误堆栈和 diff | +| 无法批量运行 | 支持参数化和并行测试 | +| 无覆盖率统计 | 自动生成覆盖率报告 | + +## 快速开始 + +### 1. 运行所有测试 +```bash +python -m pytest +``` + +### 2. 运行特定测试文件 +```bash +python -m pytest tests/test_playlist_merge.py -v +``` + +### 3. 在 VS Code 中运行 +- 按 `Ctrl+Shift+P` +- 输入 "Run Task" +- 选择 "运行所有测试" 或其他测试任务 + +## 测试文件说明 + +### 已创建的测试文件 + +``` +tests/ +├── __init__.py # 包初始化 +├── conftest.py # pytest fixtures 和配置 +├── test_playlist_merge.py # 播放列表合并测试 +└── README.md # 详细测试指南 +``` + +### 测试用例覆盖 + +`test_playlist_merge.py` 包含: + +1. **基础功能测试** (`TestPlaylistMergeBasics`) + - `test_load_paths_removes_comments` - 测试路径加载 + - `test_save_paths_adds_header` - 测试文件保存 + - `test_empty_playlist_merge` - 测试空播放列表合并 + +2. **本地优先测试** (`TestPlaylistMergeLocalPriority`) + - 参数化测试 case1-4 的本地优先合并 + +3. **远程优先测试** (`TestPlaylistMergeRemotePriority`) + - 参数化测试 case1-4 的远程优先合并 + +4. **冲突检测测试** (`TestConflictDetection`) + - 测试冲突识别和解决 + +## 常用命令 + +### 基础运行 +```bash +# 运行所有测试 +pytest + +# 详细输出 +pytest -v + +# 显示 print 输出 +pytest -s + +# 只运行特定测试 +pytest tests/test_playlist_merge.py::TestPlaylistMergeBasics::test_load_paths_removes_comments +``` + +### 测试筛选 +```bash +# 运行特定 case +pytest -k "case_num-1" + +# 运行包含特定关键字的测试 +pytest -k "local_priority" + +# 运行标记的测试 +pytest -m unit +``` + +### 调试和重试 +```bash +# 失败时进入调试器 +pytest --pdb + +# 只运行上次失败的测试 +pytest --lf + +# 先运行失败的测试 +pytest --ff + +# 遇到第一个失败就停止 +pytest -x +``` + +### 覆盖率报告 +```bash +# 生成覆盖率报告 +pytest --cov=app --cov-report=html + +# 在浏览器中查看 +# 打开 htmlcov/index.html + +# 终端中显示覆盖率 +pytest --cov=app --cov-report=term +``` + +## 编写新测试 + +### 1. 创建测试文件 +在 `tests/` 目录下创建 `test_.py` + +### 2. 编写测试类和函数 +```python +import pytest + +class TestMyFeature: + """测试我的功能""" + + def test_basic_case(self): + """测试基本用例""" + result = my_function() + assert result == expected_value + + @pytest.mark.parametrize("input,expected", [ + (1, 2), + (2, 4), + (3, 6), + ]) + def test_multiple_cases(self, input, expected): + """参数化测试多个用例""" + assert my_function(input) == expected +``` + +### 3. 使用 fixtures +```python +@pytest.fixture +def sample_data(): + """提供测试数据""" + return {"key": "value"} + +def test_with_fixture(sample_data): + assert sample_data["key"] == "value" +``` + +## 与旧测试方法的对比 + +### 旧方法 (test.ipynb) +```python +# 手动循环和对比 +for i in range(len(test_playlists[1])): + with open(test_playlists[1][i], 'r', encoding='utf-8') as f1, \ + open(target_playlists[i], 'r', encoding='utf-8') as f2: + content1 = f1.read() + content2 = f2.read() + # 手动处理和对比... + if content1 == content2: + print(f"Test case {i+1} passed.") + else: + print(f"Test case {i+1} failed.") +``` + +### 新方法 (pytest) +```python +@pytest.mark.parametrize("case_num", [1, 2, 3, 4]) +def test_merge_local_priority(case_num, tmp_path): + """自动运行所有用例,失败时显示详细 diff""" + # 加载输入 + base_text = load_file(f"case{case_num}.m3u") + # ... 执行测试 + result = merge_playlists(base_text, local_text, remote_text) + # 自动断言和差异报告 + assert_playlists_equal(actual_file, expected_file) +``` + +## 持续集成 + +可以将测试集成到 CI/CD 流程: + +```yaml +# .github/workflows/test.yml (示例) +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 + - name: Install dependencies + run: pip install -r requirements.txt + - name: Run tests + run: pytest --cov=app +``` + +## 最佳实践 + +1. **测试命名** - 使用清晰描述性的名称 +2. **独立性** - 每个测试应该独立运行 +3. **快速** - 单元测试应该快速执行 +4. **明确** - 一个测试只测一个功能点 +5. **维护** - 定期更新测试用例 + +## 故障排除 + +### pytest 找不到 +```bash +python -m pip install pytest pytest-cov +``` + +### 导入错误 +确保项目根目录在 Python 路径中: +```python +import sys +sys.path.insert(0, str(Path(__file__).parent.parent)) +``` + +### 测试发现失败 +检查 `pytest.ini` 配置和文件命名是否符合规范。 + +## 下一步 + +- [ ] 为其他模块添加测试 (如 `plex_client.py`, `local_playlist.py`) +- [ ] 添加集成测试 +- [ ] 配置 CI/CD 自动运行测试 +- [ ] 设置测试覆盖率目标 (如 >80%) + +## 参考资料 + +- [pytest 官方文档](https://docs.pytest.org/) +- [pytest-cov 文档](https://pytest-cov.readthedocs.io/) +- 项目内测试: `tests/README.md` diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..2b1bf3f --- /dev/null +++ b/pytest.ini @@ -0,0 +1,26 @@ +[pytest] +# Pytest configuration + +# Test discovery patterns +python_files = test_*.py +python_classes = Test* +python_functions = test_* + +# Test paths +testpaths = tests + +# Output options +addopts = + -v + --tb=short + --strict-markers + --disable-warnings + +# Markers for categorizing tests +markers = + slow: marks tests as slow (deselect with '-m "not slow"') + integration: marks tests as integration tests + unit: marks tests as unit tests + +# Minimum version +minversion = 6.0 diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..b20066d --- /dev/null +++ b/tests/README.md @@ -0,0 +1,88 @@ +# 测试指南 + +## 运行测试 + +### 运行所有测试 +```bash +pytest +``` + +### 运行特定测试文件 +```bash +pytest tests/test_playlist_merge.py +``` + +### 运行特定测试类或函数 +```bash +pytest tests/test_playlist_merge.py::TestPlaylistMergeLocalPriority +pytest tests/test_playlist_merge.py::TestPlaylistMergeLocalPriority::test_merge_local_priority +``` + +### 运行特定参数化测试用例 +```bash +# 只运行 case1 的测试 +pytest tests/test_playlist_merge.py -k "case_num-1" +``` + +### 查看详细输出 +```bash +pytest -v # verbose +pytest -vv # more verbose +pytest -s # 显示 print 输出 +``` + +### 查看测试覆盖率 +```bash +pip install pytest-cov +pytest --cov=app --cov-report=html +``` +然后在浏览器中打开 `htmlcov/index.html` + +## 测试结构 + +- `test_playlist_merge.py` - 播放列表合并功能的单元测试 +- `conftest.py` - pytest fixtures 和配置 +- `pytest.ini` - pytest 配置文件 + +## 编写新测试 + +1. 在 `tests/` 目录下创建 `test_*.py` 文件 +2. 创建以 `Test` 开头的测试类 +3. 编写以 `test_` 开头的测试函数 +4. 使用 `assert` 进行断言 +5. 使用 `@pytest.mark.parametrize` 进行参数化测试 + +示例: +```python +import pytest + +class TestMyFeature: + def test_basic_functionality(self): + result = my_function(input_data) + assert result == expected_output + + @pytest.mark.parametrize("input,expected", [ + (1, 2), + (2, 4), + (3, 6), + ]) + def test_with_multiple_inputs(self, input, expected): + assert my_function(input) == expected +``` + +## 调试测试 + +### 在测试失败时进入调试器 +```bash +pytest --pdb +``` + +### 只运行上次失败的测试 +```bash +pytest --lf +``` + +### 先运行失败的测试 +```bash +pytest --ff +``` diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..73d90cd --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# Test package initialization diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..d53fcc8 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,45 @@ +""" +Pytest configuration and shared fixtures. +""" + +import os +import shutil +from pathlib import Path + +import pytest + + +@pytest.fixture(scope="session") +def project_root(): + """Return the project root directory.""" + return Path(__file__).parent.parent + + +@pytest.fixture(scope="session") +def test_data_dir(project_root): + """Return the test data directory.""" + return project_root / "test_case" + + +@pytest.fixture(scope="session") +def expected_results_dir(project_root): + """Return the expected results directory.""" + return project_root / "test_res" + + +@pytest.fixture +def temp_test_dir(tmp_path): + """Create a temporary directory for test output.""" + test_dir = tmp_path / "test_output" + test_dir.mkdir(exist_ok=True) + yield test_dir + # Cleanup after test (optional) + # shutil.rmtree(test_dir, ignore_errors=True) + + +@pytest.fixture(autouse=True) +def reset_test_state(): + """Reset any global state before each test.""" + # Add any necessary cleanup here + yield + # Post-test cleanup diff --git a/tests/test_playlist_merge.py b/tests/test_playlist_merge.py new file mode 100644 index 0000000..cdba70e --- /dev/null +++ b/tests/test_playlist_merge.py @@ -0,0 +1,198 @@ +""" +Unit tests for playlist merge functionality. +""" + +import os +from pathlib import Path + +import pytest + +from app.utils.playlist_merge import ( + merge_playlists, + ConflictResolutionStrategy, + load_paths, + save_paths, +) + + +# Test data directory +TEST_CASE_DIR = Path(__file__).parent.parent / "test_case" +TEST_RES_DIR = Path(__file__).parent.parent / "test_res" +DOCKERAPP_TEST_DIR = Path(__file__).parent.parent / "dockerapp" / "test_playlists" + + +def normalize_playlist_content(file_path: str | Path) -> list[str]: + """ + Read a playlist file and return normalized content (without comments/blanks). + + Args: + file_path: Path to the playlist file + + Returns: + List of track paths (non-empty, non-comment lines) + """ + with open(file_path, 'r', encoding='utf-8') as f: + content = f.read() + + # Remove blank lines and comments + lines = [ + line.strip() + for line in content.splitlines() + if line.strip() and not line.startswith('#') + ] + return lines + + +def assert_playlists_equal(actual_file: str | Path, expected_file: str | Path): + """ + Assert that two playlist files have identical content (ignoring comments). + + Args: + actual_file: Path to the generated playlist + expected_file: Path to the expected playlist + + Raises: + AssertionError: If playlists differ + """ + actual_content = normalize_playlist_content(actual_file) + expected_content = normalize_playlist_content(expected_file) + + if actual_content != expected_content: + # Provide detailed diff on failure + import difflib + diff = difflib.unified_diff( + expected_content, + actual_content, + fromfile=str(expected_file), + tofile=str(actual_file), + lineterm='' + ) + diff_text = '\n'.join(diff) + pytest.fail(f"Playlist files differ:\n{diff_text}") + + +class TestPlaylistMergeBasics: + """Test basic playlist merge operations.""" + + def test_load_paths_removes_comments(self): + """Test that load_paths correctly filters comments and blanks.""" + text = "#EXTM3U\n\n/path/to/track1.mp3\n#Comment\n/path/to/track2.mp3\n" + paths = load_paths(text) + assert paths == ["/path/to/track1.mp3", "/path/to/track2.mp3"] + + def test_save_paths_adds_header(self): + """Test that save_paths adds proper m3u header.""" + paths = ["/path/to/track1.mp3", "/path/to/track2.mp3"] + text = save_paths(paths) + assert text.startswith("#EXTM3U\n") + assert "/path/to/track1.mp3" in text + assert "/path/to/track2.mp3" in text + + def test_empty_playlist_merge(self): + """Test merging empty playlists.""" + result = merge_playlists("", "", "", test_folder=str(DOCKERAPP_TEST_DIR / "empty_test")) + assert result.merged_paths == [] + assert result.conflicts == [] + + +@pytest.mark.parametrize("case_num", [1, 2, 3, 4]) +class TestPlaylistMergeLocalPriority: + """Test playlist merge with local priority strategy.""" + + def test_merge_local_priority(self, case_num, tmp_path): + """Test case {case_num} with local priority.""" + # Load input files + base_file = TEST_CASE_DIR / "base" / f"case{case_num}.m3u" + local_file = TEST_CASE_DIR / "local_playlist" / f"case{case_num}.m3u" + remote_file = TEST_CASE_DIR / "remote_playlist" / f"case{case_num}.m3u" + + with open(base_file, 'r', encoding='utf-8') as f: + base_text = f.read() + with open(local_file, 'r', encoding='utf-8') as f: + local_text = f.read() + with open(remote_file, 'r', encoding='utf-8') as f: + remote_text = f.read() + + # Create test output folder + test_folder = str(tmp_path / f"case{case_num}_local") + + # Perform merge + result = merge_playlists( + base_text=base_text, + local_text=local_text, + remote_text=remote_text, + strategy=ConflictResolutionStrategy.LOCAL_PRIORITY, + test_folder=test_folder + ) + + # Compare with expected result + expected_file = TEST_RES_DIR / f"case{case_num}_merged_local_primary.m3u" + actual_file = Path(test_folder) / "local_result.m3u8" + + assert_playlists_equal(actual_file, expected_file) + + +@pytest.mark.parametrize("case_num", [1, 2, 3, 4]) +class TestPlaylistMergeRemotePriority: + """Test playlist merge with remote priority strategy.""" + + def test_merge_remote_priority(self, case_num, tmp_path): + """Test case {case_num} with remote priority.""" + # Load input files + base_file = TEST_CASE_DIR / "base" / f"case{case_num}.m3u" + local_file = TEST_CASE_DIR / "local_playlist" / f"case{case_num}.m3u" + remote_file = TEST_CASE_DIR / "remote_playlist" / f"case{case_num}.m3u" + + with open(base_file, 'r', encoding='utf-8') as f: + base_text = f.read() + with open(local_file, 'r', encoding='utf-8') as f: + local_text = f.read() + with open(remote_file, 'r', encoding='utf-8') as f: + remote_text = f.read() + + # Create test output folder + test_folder = str(tmp_path / f"case{case_num}_remote") + + # Perform merge + result = merge_playlists( + base_text=base_text, + local_text=local_text, + remote_text=remote_text, + strategy=ConflictResolutionStrategy.REMOTE_PRIORITY, + test_folder=test_folder + ) + + # Compare with expected result + expected_file = TEST_RES_DIR / f"case{case_num}_merged_remote_primary.m3u" + actual_file = Path(test_folder) / "remote_result.m3u8" + + assert_playlists_equal(actual_file, expected_file) + + +class TestConflictDetection: + """Test conflict detection in merges.""" + + def test_conflict_reporting(self, tmp_path): + """Test that conflicts are properly reported.""" + base = "#EXTM3U\n/track1.mp3\n" + local = "#EXTM3U\n/track2.mp3\n" # Changed + remote = "#EXTM3U\n/track3.mp3\n" # Changed differently + + result = merge_playlists( + base_text=base, + local_text=local, + remote_text=remote, + strategy=ConflictResolutionStrategy.LOCAL_PRIORITY, + test_folder=str(tmp_path / "conflict_test") + ) + + # Should detect conflict + assert len(result.conflicts) > 0 + + # Local priority should prefer local version + assert "/track2.mp3" in result.merged_paths + + +if __name__ == "__main__": + # Allow running tests directly + pytest.main([__file__, "-v"])