| name | yoto-smart-stream-testing |
| description | Comprehensive testing guide for Yoto Smart Stream - covering authentication testing, functional testing, Playwright UI automation, and test-and-fix development loops. Use when writing tests, debugging test failures, or implementing test coverage. |
Yoto Smart Stream Testing
Comprehensive testing guide covering authentication testing, functional testing patterns, Playwright UI automation, and iterative test-and-fix development loops.
Overview
Testing strategy for Yoto Smart Stream focuses on:
- Authentication Testing - Verifying login flows, session management, and OAuth integration
- Functional Testing - API endpoints, business logic, and integration testing
- Playwright Testing - UI automation for login workflows and end-to-end scenarios
- Test-and-Fix Loops - Iterative development with automated validation
Test Philosophy
- Test First: Write tests before implementing features (TDD)
- Fast Feedback: Tests should run quickly and provide clear error messages
- Real Scenarios: Tests should mirror actual user workflows
- Automated: All tests should run in CI/CD without manual intervention
- Maintainable: Tests should be easy to understand and update
v0.3.0 Release Validation ✅
Playwright UI Testing Results:
- Dashboard Page: PWA registered, 2 players loaded, MQTT enabled, zero console errors
- Admin Page: Dark Mode widget visible and functional, all controls responsive, zero errors
- Login Page: Authentication form renders correctly, Dark Mode widget operational, zero errors
- Dark Mode Widget: "🌓 Activate dark mode" control verified on all pages
- Browser Console: No errors, warnings, or issues on any tested page
- Deployment: v0.3.0 stable on Railway, health checks passing, service running
Quick Start
Prerequisites
pip install -e ".[dev]"
pytest --version
playwright --version
Run All Tests
pytest --cov=yoto_smart_stream --cov-report=html
pytest tests/test_auth.py
pytest tests/test_api.py
pytest tests/test_login_flows.py
pytest -v
pytest -s
Basic Test Structure
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()
Reference Documentation
For detailed testing information, refer to:
- 📋 Testing Guide - Complete testing guide with unit tests, integration tests, and manual testing procedures
- 🔐 Login Workflows - Detailed documentation of authentication flows and Playwright UI testing patterns
Common Testing Patterns
1. Resolving Service URL
Pattern: Determine the correct service URL for testing
import os
import subprocess
def get_service_url():
"""Get service URL from environment or Railway CLI."""
url = os.getenv("SERVICE_URL")
if url:
return url
try:
result = subprocess.run(
["railway", "domain"],
capture_output=True,
text=True,
check=True
)
domain = result.stdout.strip()
return f"https://{domain}"
except Exception:
pass
env = os.getenv("RAILWAY_ENVIRONMENT", "develop")
return f"https://yoto-smart-stream-{env}.up.railway.app"
@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
2. UI Login (Playwright)
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."""
page.goto(service_url)
expect(page).to_have_url(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}/")
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."""
page.goto(f"{service_url}/login")
page.fill('input[name="username"]', "admin")
page.fill('input[name="password"]', "yoto")
page.click('button[type="submit"]')
oauth_button = page.locator('button:has-text("Connect Yoto Account")')
expect(oauth_button).to_be_visible()
3. API Login (Programmatic)
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)
4. Hybrid Testing (UI + API)
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."""
page.goto(f"{service_url}/login")
page.fill('input[name="username"]', "admin")
page.fill('input[name="password"]', "yoto")
page.click('button[type="submit"]')
cookies = page.context.cookies()
auth_cookie = next(c for c in cookies if c["name"] == "session")
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"]
page.goto(f"{service_url}/library")
expect(page.locator(f'[data-card-id="{card_id}"]')).to_be_visible()
5. Fixture-Based Testing
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)
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
Test Organization
Directory Structure
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
Test Categories
Unit Tests (test_*.py in root):
- Fast execution
- Isolated components
- Mocked dependencies
- High coverage target (>80%)
Integration Tests (integration/):
- Test component interactions
- Use real dependencies where possible
- Slower but more realistic
- Coverage target (>60%)
UI Tests (test_login_flows.py, test_ui_*.py):
- Playwright browser automation
- Real user workflows
- Slower execution
- Focus on critical paths
Test Pyramid
/\
/UI\ ← Few (critical workflows)
/────\
/INTEG\ ← Some (component interactions)
/──────\
/ UNIT \ ← Many (isolated components)
──────────
Best Practices
1. Test Naming
def test_login_with_valid_credentials_returns_token():
...
def test_login_with_invalid_password_returns_401():
...
def test_login():
...
def test_case_1():
...
2. Arrange-Act-Assert
def test_create_user():
user_data = {
"username": "testuser",
"password": "testpass",
"role": "user"
}
response = client.post("/api/admin/users", json=user_data)
assert response.status_code == 201
assert response.json()["username"] == "testuser"
assert response.json()["role"] == "user"
3. Test Isolation
@pytest.fixture
def clean_database():
db.clear()
yield
db.clear()
def test_create_user(clean_database):
...
def test_list_users(clean_database):
...
def test_create_user():
global user_id
user_id = create_user()
def test_delete_user():
delete_user(user_id)
4. Clear Error Messages
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())}"
def test_player_status():
response = client.get("/api/players/123")
assert response.status_code == 200
assert "online" in response.json()
5. Fixtures Over Setup/Teardown
@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):
...
def test_with_user(client):
response = client.post("/api/admin/users", ...)
user_id = response.json()["id"]
try:
...
finally:
client.delete(f"/api/admin/users/{user_id}")
Running Tests
Local Development
pytest
pytest tests/test_auth.py
pytest tests/test_auth.py::test_login_success
pytest -k "login"
pytest --cov=yoto_smart_stream --cov-report=html
pytest -x
pytest -v
pytest -s
pytest -n auto
Playwright Tests
pytest tests/test_login_flows.py
pytest tests/test_login_flows.py --headed
pytest tests/test_login_flows.py --browser chromium
pytest tests/test_login_flows.py --browser firefox
pytest tests/test_login_flows.py --browser webkit
PWDEBUG=1 pytest tests/test_login_flows.py
pytest tests/test_login_flows.py --tracing on
CI/CD Testing
pytest --tb=short --maxfail=3
pytest --junitxml=test-results.xml
pytest --cov=yoto_smart_stream --cov-report=xml --cov-report=term
SERVICE_URL=https://yoto-smart-stream-pr-61.up.railway.app pytest
Troubleshooting
Tests Failing Locally
Symptom: Tests pass in CI but fail locally (or vice versa)
Common Causes:
-
Environment Variables:
echo $SERVICE_URL
echo $YOTO_CLIENT_ID
export SERVICE_URL=https://yoto-smart-stream-develop.up.railway.app
-
Database State:
rm -f test_database.db
@pytest.fixture(autouse=True)
def clean_db():
db.clear()
yield
db.clear()
-
Network Issues:
curl https://yoto-smart-stream-develop.up.railway.app/api/health
railway status
Playwright Tests Timing Out
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
}
-
Wait for Elements:
page.click("button")
page.wait_for_selector("button", state="visible")
page.click("button")
-
Debug with Traces:
pytest tests/test_login_flows.py --tracing on
playwright show-trace test-results/trace.zip
OAuth Tests Failing
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):
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():
...
Slow Test Suite
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():
...
pytest -m fast
-
Mock External Services:
@pytest.fixture
def mock_yoto_client(monkeypatch):
monkeypatch.setattr("yoto_api.YotoClient", MockYotoClient)
Coverage Not Accurate
Symptom: Coverage report shows missing lines that are actually tested
Solutions:
-
Include Source in Coverage:
[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
Test-and-Fix Development Loop
Iterative Development Pattern
-
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
-
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
-
Refactor:
- Improve implementation
- Add error handling
- Optimize performance
-
Run Full Test Suite:
pytest --cov=yoto_smart_stream
Example: Adding New Endpoint
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
@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}
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
Additional Resources