Comprehensive testing guide for Yoto Smart Stream - covering authentication testing, functional testing, Playwright UI automation, and test-and-fix development loops...
Comprehensive testing guide covering authentication testing, functional testing patterns, Playwright UI automation, and iterative test-and-fix development loops.
Testing strategy for Yoto Smart Stream focuses on:
Playwright UI Testing Results:
# Install development dependencies
pip install -e ".[dev]"
# Verify pytest is installed
pytest --version
# Verify Playwright is installed (for UI tests)
playwright --version
# Run all tests with coverage
pytest --cov=yoto_smart_stream --cov-report=html
# Run specific test categories
pytest tests/test_auth.py # Authentication tests
pytest tests/test_api.py # API endpoint tests
pytest tests/test_login_flows.py # Playwright UI tests
# Run with verbose output
pytest -v
# Run with output capture disabled (see print statements)
pytest -s
import pytest
from fastapi.testclient import TestClient
from yoto_smart_stream.api.app import app
@pytest.fixture
def client():
"""Test client fixture."""
return TestClient(app)
def test_health_endpoint(client):
"""Test health check endpoint."""
response = client.get("/api/health")
assert response.status_code == 200
assert response.json()["status"] == "healthy"
def test_login_success(client):
"""Test successful login."""
response = client.post("/api/user/login", json={
"username": "admin",
"password": "yoto"
})
assert response.status_code == 200
assert "access_token" in response.json()
For detailed testing information, refer to:
Pattern: Determine the correct service URL for testing
import os
import subprocess
def get_service_url():
"""Get service URL from environment or Railway CLI."""
# Try environment variable first
url = os.getenv("SERVICE_URL")
if url:
return url
# Try Railway CLI
try:
result = subprocess.run(
["railway", "domain"],
capture_output=True,
text=True,
check=True
)
domain = result.stdout.strip()
return f"https://{domain}"
except Exception:
pass
# Fall back to pattern
env = os.getenv("RAILWAY_ENVIRONMENT", "develop")
return f"https://yoto-smart-stream-{env}.up.railway.app"
# Use in tests
@pytest.fixture
def service_url():
return get_service_url()
def test_service_health(service_url):
response = requests.get(f"{service_url}/api/health")
assert response.status_code == 200
Pattern: Automate browser login for UI testing
import pytest
from playwright.sync_api import Page, expect
@pytest.fixture(scope="session")
def service_url():
"""Get service URL for testing."""
return os.getenv("SERVICE_URL", "https://yoto-smart-stream-develop.up.railway.app")
def test_ui_login(page: Page, service_url: str):
"""Test login via UI."""
# Navigate to login page
page.goto(service_url)
# Should redirect to login
expect(page).to_have_url(f"{service_url}/login")
# Fill login form
page.fill('input[name="username"]', "admin")
page.fill('input[name="password"]', "yoto")
page.click('button[type="submit"]')
# Should redirect to dashboard
expect(page).to_have_url(f"{service_url}/")
expect(page.locator("h1")).to_contain_text("Dashboard")
def test_oauth_button_visible(page: Page, service_url: str):
"""Test OAuth connect button appears after login."""
# Login first
page.goto(f"{service_url}/login")
page.fill('input[name="username"]', "admin")
page.fill('input[name="password"]', "yoto")
page.click('button[type="submit"]')
# Check OAuth button
oauth_button = page.locator('button:has-text("Connect Yoto Account")')
expect(oauth_button).to_be_visible()
Pattern: Get JWT token for API testing
import requests
@pytest.fixture
def auth_token(service_url):
"""Get authentication token for API tests."""
response = requests.post(
f"{service_url}/api/user/login",
json={"username": "admin", "password": "yoto"}
)
assert response.status_code == 200
return response.json()["access_token"]
def test_authenticated_endpoint(service_url, auth_token):
"""Test endpoint requiring authentication."""
headers = {"Authorization": f"Bearer {auth_token}"}
response = requests.get(
f"{service_url}/api/players",
headers=headers
)
assert response.status_code == 200
assert isinstance(response.json(), list)
Pattern: Combine UI and API testing for complex scenarios
def test_complete_user_journey(page: Page, service_url: str):
"""Test complete user journey from login to playing audio."""
# 1. Login via UI
page.goto(f"{service_url}/login")
page.fill('input[name="username"]', "admin")
page.fill('input[name="password"]', "yoto")
page.click('button[type="submit"]')
# 2. Extract auth cookie/token from browser
cookies = page.context.cookies()
auth_cookie = next(c for c in cookies if c["name"] == "session")
# 3. Use API to create content
headers = {"Cookie": f"session={auth_cookie['value']}"}
response = requests.post(
f"{service_url}/api/cards",
headers=headers,
json={"title": "Test Card", "content": {...}}
)
assert response.status_code == 201
card_id = response.json()["id"]
# 4. Verify card appears in UI
page.goto(f"{service_url}/library")
expect(page.locator(f'[data-card-id="{card_id}"]')).to_be_visible()
Pattern: Reusable test fixtures for common setups
import pytest
from playwright.sync_api import Page
@pytest.fixture
def logged_in_page(page: Page, service_url: str):
"""Fixture providing a logged-in browser page."""
page.goto(f"{service_url}/login")
page.fill('input[name="username"]', "admin")
page.fill('input[name="password"]', "yoto")
page.click('button[type="submit"]')
expect(page).to_have_url(f"{service_url}/")
return page
@pytest.fixture
def api_client(service_url: str):
"""Fixture providing authenticated API client."""
response = requests.post(
f"{service_url}/api/user/login",
json={"username": "admin", "password": "yoto"}
)
token = response.json()["access_token"]
class AuthClient:
def __init__(self, base_url, token):
self.base_url = base_url
self.headers = {"Authorization": f"Bearer {token}"}
def get(self, path):
return requests.get(f"{self.base_url}{path}", headers=self.headers)
def post(self, path, json):
return requests.post(f"{self.base_url}{path}", headers=self.headers, json=json)
return AuthClient(service_url, token)
# Use fixtures in tests
def test_dashboard_loads(logged_in_page: Page):
"""Test dashboard loads after login."""
expect(logged_in_page.locator("h1")).to_contain_text("Dashboard")
def test_api_players_list(api_client):
"""Test listing players via API."""
response = api_client.get("/api/players")
assert response.status_code == 200
tests/
āāā __init__.py
āāā conftest.py # Shared fixtures
āāā test_auth.py # Authentication tests
āāā test_api_endpoints.py # API endpoint tests
āāā test_login_flows.py # Playwright UI tests
āāā test_audio.py # Audio management tests
āāā test_cards.py # MYO card tests
āāā test_users.py # User management tests
āāā test_oauth.py # OAuth flow tests
āāā integration/ # Integration tests
ā āāā test_complete_flows.py
ā āāā test_player_control.py
āāā fixtures/ # Test data
āāā sample_audio.mp3
āāā sample_icon.png
Unit Tests (test_*.py in root):
Integration Tests (integration/):
UI Tests (test_login_flows.py, test_ui_*.py):
/\
/UI\ ā Few (critical workflows)
/āāāā\
/INTEG\ ā Some (component interactions)
/āāāāāā\
/ UNIT \ ā Many (isolated components)
āāāāāāāāāā
# Good: Descriptive test names
def test_login_with_valid_credentials_returns_token():
...
def test_login_with_invalid_password_returns_401():
...
# Bad: Vague test names
def test_login():
...
def test_case_1():
...
def test_create_user():
# Arrange: Setup test data
user_data = {
"username": "testuser",
"password": "testpass",
"role": "user"
}
# Act: Execute the action
response = client.post("/api/admin/users", json=user_data)
# Assert: Verify the results
assert response.status_code == 201
assert response.json()["username"] == "testuser"
assert response.json()["role"] == "user"
# Good: Each test is independent
@pytest.fixture
def clean_database():
db.clear()
yield
db.clear()
def test_create_user(clean_database):
# Test creates user in clean database
...
def test_list_users(clean_database):
# Test lists users in clean database
...
# Bad: Tests depend on each other
def test_create_user():
global user_id
user_id = create_user()
def test_delete_user():
delete_user(user_id) # Depends on previous test
# Good: Descriptive assertions
def test_player_status():
response = client.get("/api/players/123")
assert response.status_code == 200, \
f"Expected 200 but got {response.status_code}. Response: {response.text}"
data = response.json()
assert "online" in data, \
f"'online' field missing from response. Got: {list(data.keys())}"
# Bad: Bare assertions
def test_player_status():
response = client.get("/api/players/123")
assert response.status_code == 200
assert "online" in response.json()
# Good: Use fixtures
@pytest.fixture
def test_user(client):
response = client.post("/api/admin/users", json={
"username": "testuser",
"password": "testpass"
})
user_id = response.json()["id"]
yield user_id
client.delete(f"/api/admin/users/{user_id}")
def test_with_user(client, test_user):
# User automatically created and cleaned up
...
# Bad: Manual setup/teardown
def test_with_user(client):
# Setup
response = client.post("/api/admin/users", ...)
user_id = response.json()["id"]
try:
# Test
...
finally:
# Teardown
client.delete(f"/api/admin/users/{user_id}")
# Run all tests
pytest
# Run specific test file
pytest tests/test_auth.py
# Run specific test
pytest tests/test_auth.py::test_login_success
# Run tests matching pattern
pytest -k "login"
# Run with coverage
pytest --cov=yoto_smart_stream --cov-report=html
# Run and stop on first failure
pytest -x
# Run with verbose output
pytest -v
# Run with print statements visible
pytest -s
# Run in parallel (requires pytest-xdist)
pytest -n auto
# Run Playwright tests
pytest tests/test_login_flows.py
# Run in headed mode (see browser)
pytest tests/test_login_flows.py --headed
# Run specific browser
pytest tests/test_login_flows.py --browser chromium
pytest tests/test_login_flows.py --browser firefox
pytest tests/test_login_flows.py --browser webkit
# Debug mode (opens Playwright Inspector)
PWDEBUG=1 pytest tests/test_login_flows.py
# Generate trace for debugging
pytest tests/test_login_flows.py --tracing on
# Run in CI mode (non-interactive)
pytest --tb=short --maxfail=3
# Generate JUnit XML for CI reporting
pytest --junitxml=test-results.xml
# Generate coverage reports for CI
pytest --cov=yoto_smart_stream --cov-report=xml --cov-report=term
# Run with environment-specific config
SERVICE_URL=https://yoto-smart-stream-pr-61.up.railway.app pytest
Symptom: Tests pass in CI but fail locally (or vice versa)
Common Causes:
Environment Variables:
# Check required environment variables
echo $SERVICE_URL
echo $YOTO_CLIENT_ID
# Set for testing
export SERVICE_URL=https://yoto-smart-stream-develop.up.railway.app
Database State:
# Clear test database
rm -f test_database.db
# Or use fixture to ensure clean state
@pytest.fixture(autouse=True)
def clean_db():
db.clear()
yield
db.clear()
Network Issues:
# Test connectivity
curl https://yoto-smart-stream-develop.up.railway.app/api/health
# Check if service is running
railway status
Symptom: Playwright tests hang or timeout
Solutions:
Increase Timeout:
@pytest.fixture(scope="session")
def browser_context_args(browser_context_args):
return {
**browser_context_args,
"timeout": 30000 # 30 seconds
}
Wait for Elements:
# Bad: No wait
page.click("button")
# Good: Wait for element
page.wait_for_selector("button", state="visible")
page.click("button")
Debug with Traces:
# Generate trace
pytest tests/test_login_flows.py --tracing on
# View trace
playwright show-trace test-results/trace.zip
Symptom: Tests requiring OAuth fail with "Not authenticated"
Solutions:
Mock OAuth in Tests:
@pytest.fixture
def mock_oauth(monkeypatch):
"""Mock OAuth for testing."""
def mock_get_players():
return [{"id": "player-123", "name": "Test Player"}]
monkeypatch.setattr("yoto_smart_stream.api.routes.get_players", mock_get_players)
def test_with_oauth(client, mock_oauth):
# OAuth is mocked
response = client.get("/api/players")
assert response.status_code == 200
Skip OAuth Tests:
@pytest.mark.skipif(
not os.getenv("OAUTH_CONFIGURED"),
reason="OAuth not configured"
)
def test_real_oauth():
# Only runs when OAuth is configured
...
Symptom: Tests take too long to run
Solutions:
Run Tests in Parallel:
pip install pytest-xdist
pytest -n auto
Use Markers to Run Subsets:
@pytest.mark.fast
def test_fast():
...
@pytest.mark.slow
def test_slow():
...
# Run only fast tests
pytest -m fast
Mock External Services:
# Instead of real Yoto API calls
@pytest.fixture
def mock_yoto_client(monkeypatch):
monkeypatch.setattr("yoto_api.YotoClient", MockYotoClient)
Symptom: Coverage report shows missing lines that are actually tested
Solutions:
Include Source in Coverage:
# setup.cfg or pyproject.toml
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.coverage.run]
source = ["yoto_smart_stream"]
omit = ["*/tests/*", "*/venv/*"]
Run Tests with Coverage:
pytest --cov=yoto_smart_stream --cov-report=html
open htmlcov/index.html
Write Failing Test:
def test_new_feature():
response = client.post("/api/new-endpoint", json={...})
assert response.status_code == 200
Run Test (Verify Failure):
pytest tests/test_new_feature.py -v
# Should fail with clear error
Implement Minimum Code:
@app.post("/api/new-endpoint")
def new_endpoint(data: dict):
return {"status": "ok"}
Run Test Again:
pytest tests/test_new_feature.py -v
# Should pass
Refactor:
Run Full Test Suite:
pytest --cov=yoto_smart_stream
# Verify no regressions
# Step 1: Write test
def test_get_player_volume(client, auth_token):
headers = {"Authorization": f"Bearer {auth_token}"}
response = client.get("/api/players/123/volume", headers=headers)
assert response.status_code == 200
assert "volume" in response.json()
assert 0 <= response.json()["volume"] <= 100
# Step 2: Run test (fails)
# $ pytest tests/test_players.py::test_get_player_volume
# E 404 Not Found
# Step 3: Implement endpoint
@app.get("/api/players/{player_id}/volume")
async def get_player_volume(
player_id: str,
current_user: User = Depends(get_current_user)
):
player = await get_player(player_id)
return {"volume": player.config.volume}
# Step 4: Run test (passes)
# $ pytest tests/test_players.py::test_get_player_volume
# ā PASSED
# Step 5: Add more test cases
def test_get_player_volume_invalid_id(client, auth_token):
headers = {"Authorization": f"Bearer {auth_token}"}
response = client.get("/api/players/invalid/volume", headers=headers)
assert response.status_code == 404
# Step 6: Run full suite
# $ pytest --cov=yoto_smart_stream
# ā All tests passed
# Coverage: 87%
/docs folder for additional guides/tests folder for test examples