tubewatch/playlist-monitor/TESTING_GUIDE.md

921 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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! 🧪✅