26 KiB
26 KiB
Playlist Monitor Service - Comprehensive Testing Guide
📋 Table of Contents
- Testing Overview
- Test Environment Setup
- Unit Tests
- Integration Tests
- API Tests
- End-to-End Tests
- Performance Tests
- Manual Testing
- CI/CD Integration
- 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
# Install test dependencies
uv sync --extra dev
# Or install manually
pip install pytest pytest-asyncio pytest-cov pytest-mock httpx
2. Test Configuration
# Create test environment
cp .env.example .env.test
# Update test settings
nano .env.test
Test-specific settings:
# 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
# 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)
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)
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)
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)
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)
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)
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)
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)
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)
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)
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
# 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
# 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)
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)
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/)
# 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
# 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
# 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)
[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
# 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
# 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
# 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
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
@pytest.fixture
def clean_database():
"""Provide clean database for each test"""
# Setup clean state
yield
# Cleanup after test
3. Mock External Services
@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
@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! 🧪✅