# Playlist Monitor Service - Comprehensive Testing Guide ## ๐Ÿ“‹ Table of Contents 1. [Testing Overview](#testing-overview) 2. [Test Environment Setup](#test-environment-setup) 3. [Unit Tests](#unit-tests) 4. [Integration Tests](#integration-tests) 5. [API Tests](#api-tests) 6. [End-to-End Tests](#end-to-end-tests) 7. [Performance Tests](#performance-tests) 8. [Manual Testing](#manual-testing) 9. [CI/CD Integration](#cicd-integration) 10. [Test Data Management](#test-data-management) ## ๐Ÿ” Testing Overview ### Test Pyramid ``` ๐ŸŽฏ E2E Tests (Few) โ†‘ ๐Ÿ”Œ Integration Tests (Some) โ†‘ โšก Unit Tests (Many) ``` ### Test Categories - **Unit Tests**: Individual components (models, services, utils) - **Integration Tests**: Component interactions (database, API, external services) - **API Tests**: REST endpoint validation - **E2E Tests**: Full user workflows - **Performance Tests**: Load and stress testing ## ๐Ÿ› ๏ธ Test Environment Setup ### 1. Test Dependencies ```bash # Install test dependencies uv sync --extra dev # Or install manually pip install pytest pytest-asyncio pytest-cov pytest-mock httpx ``` ### 2. Test Configuration ```bash # Create test environment cp .env.example .env.test # Update test settings nano .env.test ``` **Test-specific settings:** ```env # Test database (in-memory SQLite) DATABASE_URL=sqlite:///:memory: # Test logging LOG_LEVEL=DEBUG LOG_FILE=logs/test.log # Test MeTube (mock or test instance) METUBE_URL=http://localhost:8081 # Test mode TESTING=true ``` ### 3. Test Directory Structure ``` tests/ โ”œโ”€โ”€ unit/ # Unit tests โ”‚ โ”œโ”€โ”€ test_config.py โ”‚ โ”œโ”€โ”€ test_models.py โ”‚ โ””โ”€โ”€ test_services.py โ”œโ”€โ”€ integration/ # Integration tests โ”‚ โ”œโ”€โ”€ test_database.py โ”‚ โ”œโ”€โ”€ test_api.py โ”‚ โ””โ”€โ”€ test_metube_client.py โ”œโ”€โ”€ e2e/ # End-to-end tests โ”‚ โ”œโ”€โ”€ test_workflows.py โ”‚ โ””โ”€โ”€ test_scenarios.py โ”œโ”€โ”€ fixtures/ # Test data โ”‚ โ”œโ”€โ”€ playlists.json โ”‚ โ””โ”€โ”€ videos.json โ””โ”€โ”€ conftest.py # Pytest configuration ``` ## โšก Unit Tests ### Running Unit Tests ```bash # Run all unit tests uv run pytest tests/unit/ -v # Run specific test file uv run pytest tests/unit/test_models.py -v # Run with coverage uv run pytest tests/unit/ --cov=app --cov-report=html # Run specific test uv run pytest tests/unit/test_models.py::test_playlist_creation -v ``` ### Model Tests (tests/unit/test_models.py) ```python import pytest from datetime import datetime, timedelta from app.models.playlist import PlaylistSubscription from app.models.video import VideoRecord, VideoStatus class TestPlaylistModel: def test_playlist_creation(self, test_db): """Test playlist creation and validation""" playlist = PlaylistSubscription( url="https://www.youtube.com/playlist?list=TEST123", title="Test Playlist", check_interval=60 ) assert playlist.url == "https://www.youtube.com/playlist?list=TEST123" assert playlist.title == "Test Playlist" assert playlist.check_interval == 60 assert playlist.enabled is True def test_playlist_should_check_logic(self): """Test playlist check logic""" playlist = PlaylistSubscription(check_interval=60) # Should check if never checked assert playlist.should_check() is True # Should not check if recently checked playlist.last_checked = datetime.utcnow() - timedelta(minutes=30) assert playlist.should_check() is False # Should check if interval passed playlist.last_checked = datetime.utcnow() - timedelta(minutes=61) assert playlist.should_check() is True class TestVideoModel: def test_video_status_transitions(self): """Test video status management""" video = VideoRecord(status=VideoStatus.PENDING) # Test downloading video.mark_as_downloading("metube_123") assert video.status == VideoStatus.DOWNLOADING assert video.metube_download_id == "metube_123" # Test completion video.mark_as_completed("video.mp4") assert video.status == VideoStatus.COMPLETED assert video.original_filename == "video.mp4" ``` ### Service Tests (tests/unit/test_services.py) ```python import pytest from unittest.mock import Mock, AsyncMock from app.services.playlist_service import PlaylistService from app.services.metube_client import MeTubeClient class TestPlaylistService: @pytest.fixture def service(self, test_db): return PlaylistService(test_db) @pytest.fixture def mock_yt_dlp(self, monkeypatch): """Mock yt-dlp for testing""" mock = Mock() mock.extract_info.return_value = { "title": "Test Playlist", "entries": [ {"id": "video1", "title": "Video 1", "playlist_index": 1}, {"id": "video2", "title": "Video 2", "playlist_index": 2} ] } monkeypatch.setattr("yt_dlp.YoutubeDL", lambda x: mock) return mock async def test_add_playlist_success(self, service, mock_yt_dlp): """Test successful playlist addition""" playlist = await service.add_playlist( url="https://www.youtube.com/playlist?list=TEST123", title="Test Playlist" ) assert playlist.title == "Test Playlist" assert playlist.check_interval == 60 assert len(playlist.videos) == 2 ``` ### Configuration Tests (tests/unit/test_config.py) ```python import pytest from app.core.config import Settings class TestConfiguration: def test_default_values(self): """Test default configuration values""" settings = Settings() assert settings.HOST == "0.0.0.0" assert settings.PORT == 8082 assert settings.METUBE_URL == "http://localhost:8081" def test_validation(self): """Test configuration validation""" with pytest.raises(ValueError): Settings(DEFAULT_CHECK_INTERVAL=0) with pytest.raises(ValueError): Settings(MAX_CONCURRENT_DOWNLOADS=15) ``` ## ๐Ÿ”Œ Integration Tests ### Database Integration (tests/integration/test_database.py) ```python import pytest from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from app.core.database import Base, get_db from app.models.playlist import PlaylistSubscription @pytest.fixture def test_engine(): """Create test database engine""" engine = create_engine("sqlite:///:memory:", echo=False) Base.metadata.create_all(bind=engine) return engine class TestDatabaseIntegration: def test_playlist_crud_operations(self, test_engine): """Test database CRUD operations""" SessionLocal = sessionmaker(bind=test_engine) db = SessionLocal() # Create playlist = PlaylistSubscription( url="https://www.youtube.com/playlist?list=TEST123", title="Test Playlist" ) db.add(playlist) db.commit() # Read retrieved = db.query(PlaylistSubscription).first() assert retrieved.title == "Test Playlist" # Update retrieved.title = "Updated Playlist" db.commit() assert db.query(PlaylistSubscription).first().title == "Updated Playlist" # Delete db.delete(retrieved) db.commit() assert db.query(PlaylistSubscription).count() == 0 db.close() ``` ### MeTube Client Integration (tests/integration/test_metube_client.py) ```python import pytest import respx from httpx import Response from app.services.metube_client import MeTubeClient @pytest.mark.asyncio class TestMeTubeClientIntegration: @respx.mock async def test_add_download_success(self): """Test successful download addition""" # Mock MeTube API route = respx.post("http://localhost:8081/add").mock( return_value=Response(200, json={"id": "download_123", "status": "pending"}) ) client = MeTubeClient("http://localhost:8081") await client.connect() result = await client.add_download( url="https://www.youtube.com/watch?v=TEST123", quality="best", format="mp4" ) assert result["id"] == "download_123" assert result["status"] == "pending" assert route.called await client.disconnect() ``` ## ๐ŸŒ API Tests ### API Integration (tests/integration/test_api.py) ```python import pytest from fastapi.testclient import TestClient from app.main import app class TestAPIIntegration: @pytest.fixture def client(self): return TestClient(app) def test_health_endpoint(self, client): """Test health check endpoint""" response = client.get("/health") assert response.status_code == 200 data = response.json() assert data["status"] == "healthy" def test_playlist_crud(self, client): """Test playlist CRUD operations""" # Create playlist response = client.post("/api/playlists", json={ "url": "https://www.youtube.com/playlist?list=TEST123", "title": "Test Playlist", "check_interval": 60 }) assert response.status_code == 201 playlist_id = response.json()["id"] # Read playlist response = client.get(f"/api/playlists/{playlist_id}") assert response.status_code == 200 assert response.json()["title"] == "Test Playlist" # Update playlist response = client.put(f"/api/playlists/{playlist_id}", json={ "title": "Updated Playlist" }) assert response.status_code == 200 assert response.json()["title"] == "Updated Playlist" # Delete playlist response = client.delete(f"/api/playlists/{playlist_id}") assert response.status_code == 204 def test_invalid_playlist_url(self, client): """Test validation of invalid playlist URLs""" response = client.post("/api/playlists", json={ "url": "https://invalid-url.com", "title": "Invalid Playlist" }) assert response.status_code == 400 assert "Invalid YouTube playlist URL" in response.json()["detail"] ``` ### Async API Tests (tests/integration/test_api_async.py) ```python import pytest import asyncio from httpx import AsyncClient from app.main import app @pytest.mark.asyncio class TestAsyncAPI: async def test_websocket_connection(self): """Test WebSocket connection and events""" # This would require a mock WebSocket server pass async def test_async_playlist_check(self): """Test async playlist checking""" async with AsyncClient(app=app, base_url="http://test") as client: # Create playlist response = await client.post("/api/playlists", json={ "url": "https://www.youtube.com/playlist?list=TEST123", "title": "Test Playlist" }) playlist_id = response.json()["id"] # Trigger check response = await client.post(f"/api/playlists/{playlist_id}/check") assert response.status_code == 200 assert response.json()["status"] == "ok" ``` ## ๐ŸŽฏ End-to-End Tests ### Workflow Tests (tests/e2e/test_workflows.py) ```python import pytest import asyncio from httpx import AsyncClient from app.main import app @pytest.mark.asyncio class TestE2EWorkflows: async def test_complete_playlist_workflow(self): """Test complete playlist lifecycle""" async with AsyncClient(app=app, base_url="http://test") as client: # 1. Create playlist response = await client.post("/api/playlists", json={ "url": "https://www.youtube.com/playlist?list=PLrAXtmErZgOeiKm4sgNOknGvNjby9efdf", "title": "Kurzgesagt Playlist", "check_interval": 60, "folder": "kurzgesagt" }) assert response.status_code == 201 playlist_id = response.json()["id"] # 2. Verify playlist was created response = await client.get(f"/api/playlists/{playlist_id}") assert response.status_code == 200 playlist = response.json() assert playlist["title"] == "Kurzgesagt Playlist" # 3. Check playlist (manual trigger) response = await client.post(f"/api/playlists/{playlist_id}/check") assert response.status_code == 200 # 4. Verify videos were created response = await client.get(f"/api/playlists/{playlist_id}/videos") assert response.status_code == 200 videos = response.json() assert len(videos) > 0 # 5. Test video operations video_id = videos[0]["id"] # Skip a video response = await client.post(f"/api/videos/{video_id}/skip") assert response.status_code == 200 assert response.json()["video"]["status"] == "SKIPPED" # Reset video response = await client.post(f"/api/videos/{video_id}/reset") assert response.status_code == 200 assert response.json()["video"]["status"] == "PENDING" # 6. Check system status response = await client.get("/api/status") assert response.status_code == 200 status = response.json() assert status["total_playlists"] >= 1 assert status["total_videos"] >= 1 # 7. Delete playlist response = await client.delete(f"/api/playlists/{playlist_id}") assert response.status_code == 204 async def test_error_handling_workflow(self): """Test error handling scenarios""" async with AsyncClient(app=app, base_url="http://test") as client: # Test invalid playlist URL response = await client.post("/api/playlists", json={ "url": "https://invalid-url.com", "title": "Invalid Playlist" }) assert response.status_code == 400 # Test non-existent playlist response = await client.get("/api/playlists/non-existent-id") assert response.status_code == 404 # Test invalid video operations response = await client.post("/api/videos/non-existent/download") assert response.status_code == 404 ``` ## โšก Performance Tests ### Load Testing (tests/performance/test_load.py) ```python import asyncio import time import aiohttp import pytest class TestLoadPerformance: @pytest.mark.performance async def test_api_response_time(self): """Test API response times""" async with aiohttp.ClientSession() as session: start_time = time.time() for i in range(100): async with session.get("http://localhost:8082/api/status") as response: assert response.status == 200 elapsed = time.time() - start_time avg_response_time = elapsed / 100 # Assert average response time is under 100ms assert avg_response_time < 0.1 @pytest.mark.performance async def test_database_performance(self): """Test database query performance""" from app.core.database import SessionLocal from app.models.playlist import PlaylistSubscription db = SessionLocal() # Create test data for i in range(1000): playlist = PlaylistSubscription( url=f"https://www.youtube.com/playlist?list=TEST{i}", title=f"Test Playlist {i}" ) db.add(playlist) db.commit() # Measure query performance start_time = time.time() playlists = db.query(PlaylistSubscription).all() elapsed = time.time() - start_time assert len(playlists) == 1000 assert elapsed < 1.0 # Should complete in under 1 second db.close() ``` ### Stress Testing (tests/performance/test_stress.py) ```python import asyncio import pytest from httpx import AsyncClient from app.main import app @pytest.mark.stress class TestStressScenarios: async def test_concurrent_playlist_creation(self): """Test concurrent playlist creation""" async with AsyncClient(app=app, base_url="http://test") as client: # Create 50 playlists concurrently tasks = [] for i in range(50): task = client.post("/api/playlists", json={ "url": f"https://www.youtube.com/playlist?list=TEST{i}", "title": f"Stress Test Playlist {i}" }) tasks.append(task) responses = await asyncio.gather(*tasks) # All should succeed for response in responses: assert response.status_code == 201 # Verify all were created response = await client.get("/api/playlists") assert response.status_code == 200 assert len(response.json()) >= 50 ``` ## ๐Ÿ”ง Manual Testing ### Manual Test Checklist #### Basic Functionality - [ ] Service starts without errors - [ ] Health endpoint returns 200 - [ ] API documentation accessible at /docs - [ ] Database connection successful #### Playlist Operations - [ ] Create playlist with valid YouTube URL - [ ] Create playlist with invalid URL (should fail) - [ ] Update playlist settings - [ ] Delete playlist - [ ] List all playlists - [ ] Get playlist with videos #### Video Operations - [ ] Videos automatically created from playlist - [ ] Manual playlist check triggers new video detection - [ ] Video status transitions work correctly - [ ] Skip/reset operations work #### Integration - [ ] MeTube connection established - [ ] Downloads triggered successfully - [ ] WebSocket events received - [ ] Status synchronization works #### Error Handling - [ ] Invalid URLs handled gracefully - [ ] Database errors handled - [ ] MeTube connection failures handled - [ ] Proper error messages returned ### Manual Testing Commands #### Service Health ```bash # Health check curl http://localhost:8082/health # System status curl http://localhost:8082/api/status # Scheduler status curl http://localhost:8082/api/scheduler/status ``` #### Playlist Testing ```bash # Create test playlist curl -X POST http://localhost:8082/api/playlists \ -H "Content-Type: application/json" \ -d '{ "url": "https://www.youtube.com/playlist?list=PLrAXtmErZgOeiKm4sgNOknGvNjby9efdf", "title": "Manual Test Playlist", "check_interval": 30 }' # Trigger manual check curl -X POST http://localhost:8082/api/playlists/{id}/check # Monitor logs tail -f logs/playlist-monitor.log ``` ## ๐Ÿ”„ CI/CD Integration ### GitHub Actions (.github/workflows/tests.yml) ```yaml name: Tests on: push: branches: [ main, develop ] pull_request: branches: [ main ] jobs: test: runs-on: ubuntu-latest services: postgres: image: postgres:15 env: POSTGRES_PASSWORD: postgres POSTGRES_DB: test_playlists options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 ports: - 5432:5432 strategy: matrix: python-version: [3.13, 3.14] steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install uv run: curl -LsSf https://astral.sh/uv/install.sh | sh - name: Install dependencies run: uv sync --extra dev - name: Run unit tests run: uv run pytest tests/unit/ -v --cov=app --cov-report=xml - name: Run integration tests run: uv run pytest tests/integration/ -v - name: Run performance tests run: uv run pytest tests/performance/ -v --tb=short - name: Upload coverage reports uses: codecov/codecov-action@v3 with: file: ./coverage.xml ``` ### Pre-commit Hooks (.pre-commit-config.yaml) ```yaml repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.4.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-yaml - id: check-added-large-files - repo: https://github.com/psf/black rev: 23.3.0 hooks: - id: black language_version: python3.13 - repo: https://github.com/pycqa/isort rev: 5.12.0 hooks: - id: isort - repo: https://github.com/pycqa/flake8 rev: 6.0.0 hooks: - id: flake8 - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.3.0 hooks: - id: mypy additional_dependencies: [types-all] ``` ## ๐Ÿ“Š Test Data Management ### Test Fixtures (tests/fixtures/) ```python # tests/fixtures/playlists.py TEST_PLAYLISTS = [ { "url": "https://www.youtube.com/playlist?list=PLrAXtmErZgOeiKm4sgNOknGvNjby9efdf", "title": "Kurzgesagt โ€“ In a Nutshell", "check_interval": 60, "quality": "best", "format": "mp4", "folder": "kurzgesagt" }, { "url": "https://www.youtube.com/playlist?list=UUX6OQ3DkcsbYNE6H8uQQuVA", "title": "Tech Channel Uploads", "check_interval": 30, "quality": "1080p", "format": "mp4", "folder": "tech-channels" } ] # tests/fixtures/videos.py TEST_VIDEOS = [ { "video_id": "dQw4w9WgXcQ", "title": "Never Gonna Give You Up", "playlist_index": 1, "status": "COMPLETED" }, { "video_id": "9bZkp7q19f0", "title": "Gangnam Style", "playlist_index": 2, "status": "PENDING" } ] ``` ### Mock Data Generation ```python # tests/utils/mock_data.py def generate_mock_playlists(count=10): """Generate mock playlist data""" playlists = [] for i in range(count): playlists.append({ "url": f"https://www.youtube.com/playlist?list=TEST{i:03d}", "title": f"Test Playlist {i}", "check_interval": 60, "quality": "best", "format": "mp4", "folder": f"test-folder-{i}" }) return playlists def generate_mock_videos(playlist_id, count=20): """Generate mock video data""" videos = [] for i in range(count): videos.append({ "playlist_id": playlist_id, "video_url": f"https://www.youtube.com/watch?v=VIDEO{i:03d}", "video_id": f"VIDEO{i:03d}", "title": f"Test Video {i}", "playlist_index": i + 1, "status": "PENDING" }) return videos ``` ## ๐Ÿ“ˆ Test Coverage ### Coverage Report Generation ```bash # Generate coverage report uv run pytest --cov=app --cov-report=html --cov-report=term # View HTML report open htmlcov/index.html # Generate XML report for CI uv run pytest --cov=app --cov-report=xml ``` ### Coverage Goals - **Unit Tests**: >90% coverage - **Integration Tests**: >80% coverage - **API Tests**: >95% coverage - **Overall**: >85% coverage ### Coverage Configuration (pyproject.toml) ```toml [tool.coverage.run] source = ["app"] omit = [ "*/tests/*", "*/venv/*", "*/__pycache__/*", "app/main.py", # Entry point ] [tool.coverage.report] exclude_lines = [ "pragma: no cover", "def __repr__", "raise AssertionError", "raise NotImplementedError", ] [tool.coverage.html] directory = "htmlcov" ``` ## ๐ŸŽฏ Test Execution Strategies ### Parallel Testing ```bash # Run tests in parallel uv run pytest -n auto # Run specific test categories in parallel uv run pytest tests/unit/ -n 4 & uv run pytest tests/integration/ -n 2 & wait ``` ### Test Selection ```bash # Run tests by marker uv run pytest -m "not performance" uv run pytest -m "performance" # Run tests by name pattern uv run pytest -k "test_playlist" uv run pytest -k "not test_performance" # Run specific test file uv run pytest tests/unit/test_models.py ``` ### Continuous Testing ```bash # Watch mode for development uv run pytest-watch tests/unit/ # Run on file changes uv run ptw -- tests/unit/ ``` ## ๐Ÿ“š Testing Best Practices ### 1. Test Naming ```python def test_playlist_creation_with_valid_url(self): """Should create playlist when valid YouTube URL provided""" # Test implementation def test_playlist_creation_fails_with_invalid_url(self): """Should reject playlist creation with invalid URL""" # Test implementation ``` ### 2. Test Independence ```python @pytest.fixture def clean_database(): """Provide clean database for each test""" # Setup clean state yield # Cleanup after test ``` ### 3. Mock External Services ```python @pytest.fixture def mock_metube(): """Mock MeTube service for testing""" with respx.mock: respx.get("http://localhost:8081/info").mock( return_value=Response(200, json={"status": "ok"}) ) yield ``` ### 4. Test Data Management ```python @pytest.fixture def sample_playlist(): """Provide sample playlist data""" return { "url": "https://www.youtube.com/playlist?list=TEST123", "title": "Test Playlist", "check_interval": 60 } ``` This comprehensive testing guide ensures robust, reliable testing of the Playlist Monitor Service! ๐Ÿงชโœ