Add Playwright e2e test infrastructure
Set up browser-based end-to-end testing using pytest-playwright: - Add playwright-driver and pytest-playwright to nix flake - Configure PLAYWRIGHT_BROWSERS_PATH for NixOS compatibility - Create ServerHarness to manage live server for tests - Add smoke tests for health endpoint and page loading - Exclude e2e tests from pre-commit hook (require special setup) Run e2e tests with: pytest tests/e2e/ -v -n 0 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -61,6 +61,8 @@
|
||||
# Dev-only (not needed in Docker, but fine to include)
|
||||
pytest
|
||||
pytest-xdist
|
||||
pytest-playwright
|
||||
requests
|
||||
ruff
|
||||
filelock
|
||||
]);
|
||||
@@ -84,8 +86,13 @@
|
||||
pkgs.sqlite
|
||||
pkgs.skopeo # For pushing Docker images
|
||||
pkgs.lefthook # Git hooks manager
|
||||
pkgs.playwright-driver # Browser binaries for e2e tests
|
||||
];
|
||||
|
||||
# Playwright browser configuration for NixOS
|
||||
PLAYWRIGHT_BROWSERS_PATH = "${pkgs.playwright-driver.browsers}";
|
||||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1";
|
||||
|
||||
shellHook = ''
|
||||
export PYTHONPATH="$PWD/src:$PYTHONPATH"
|
||||
export PATH="$PWD/bin:$PATH"
|
||||
|
||||
@@ -12,4 +12,4 @@ pre-commit:
|
||||
run: ruff format --check src/ tests/
|
||||
pytest:
|
||||
glob: "**/*.py"
|
||||
run: pytest tests/ -q --tb=short
|
||||
run: pytest tests/ --ignore=tests/e2e -q --tb=short
|
||||
|
||||
@@ -28,6 +28,8 @@ dependencies = [
|
||||
dev = [
|
||||
"pytest>=7.4.0",
|
||||
"pytest-xdist>=3.5.0",
|
||||
"pytest-playwright>=0.4.0",
|
||||
"requests>=2.31.0",
|
||||
"ruff>=0.1.0",
|
||||
"filelock>=3.13.0",
|
||||
]
|
||||
@@ -56,3 +58,6 @@ python_files = "test_*.py"
|
||||
python_classes = "Test*"
|
||||
python_functions = "test_*"
|
||||
addopts = "--durations=20 -n auto"
|
||||
markers = [
|
||||
"e2e: end-to-end browser tests (run with -n 0 to disable parallel execution)",
|
||||
]
|
||||
|
||||
2
tests/e2e/__init__.py
Normal file
2
tests/e2e/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# ABOUTME: End-to-end test package for browser-based testing.
|
||||
# ABOUTME: Uses Playwright to test the full application stack.
|
||||
122
tests/e2e/conftest.py
Normal file
122
tests/e2e/conftest.py
Normal file
@@ -0,0 +1,122 @@
|
||||
# ABOUTME: E2E test fixtures and server harness for Playwright tests.
|
||||
# ABOUTME: Provides a live server instance for browser-based testing.
|
||||
|
||||
import os
|
||||
import random
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from animaltrack.db import get_db
|
||||
from animaltrack.migrations import run_migrations
|
||||
from animaltrack.seeds import run_seeds
|
||||
|
||||
|
||||
class ServerHarness:
|
||||
"""Manages a live AnimalTrack server for e2e tests.
|
||||
|
||||
Starts the server as a subprocess with an isolated test database,
|
||||
waits for it to be ready, and cleans up after tests complete.
|
||||
"""
|
||||
|
||||
def __init__(self, port: int):
|
||||
self.port = port
|
||||
self.url = f"http://127.0.0.1:{port}"
|
||||
self.process = None
|
||||
|
||||
def start(self, db_path: str):
|
||||
"""Start the server with the given database."""
|
||||
env = {
|
||||
**os.environ,
|
||||
"DB_PATH": db_path,
|
||||
"DEV_MODE": "true",
|
||||
"CSRF_SECRET": "e2e-test-csrf-secret-32chars!!",
|
||||
"TRUSTED_PROXY_IPS": "127.0.0.1",
|
||||
}
|
||||
# Use sys.executable to ensure we use the same Python environment
|
||||
self.process = subprocess.Popen(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"animaltrack.cli",
|
||||
"serve",
|
||||
"--port",
|
||||
str(self.port),
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
],
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
self._wait_for_ready()
|
||||
|
||||
def _wait_for_ready(self, timeout: float = 30.0):
|
||||
"""Poll /healthz until server is ready."""
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
try:
|
||||
response = requests.get(f"{self.url}/healthz", timeout=1)
|
||||
if response.ok:
|
||||
return
|
||||
except requests.RequestException:
|
||||
pass
|
||||
time.sleep(0.1)
|
||||
# If we get here, dump stderr for debugging
|
||||
if self.process:
|
||||
stderr = self.process.stderr.read() if self.process.stderr else b""
|
||||
raise TimeoutError(
|
||||
f"Server not ready after {timeout}s. stderr: {stderr.decode('utf-8', errors='replace')}"
|
||||
)
|
||||
|
||||
def stop(self):
|
||||
"""Stop the server and clean up."""
|
||||
if self.process:
|
||||
self.process.terminate()
|
||||
try:
|
||||
self.process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.process.kill()
|
||||
self.process.wait()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def e2e_db_path(tmp_path_factory):
|
||||
"""Create and migrate a fresh database for e2e tests.
|
||||
|
||||
Session-scoped so all e2e tests share the same database state.
|
||||
"""
|
||||
temp_dir = tmp_path_factory.mktemp("e2e")
|
||||
db_path = str(temp_dir / "animaltrack.db")
|
||||
|
||||
# Run migrations
|
||||
run_migrations(db_path, "migrations", verbose=False)
|
||||
|
||||
# Seed with test data
|
||||
db = get_db(db_path)
|
||||
run_seeds(db)
|
||||
|
||||
return db_path
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def live_server(e2e_db_path):
|
||||
"""Start the server for the entire e2e test session.
|
||||
|
||||
Uses a random port in the 33660-33759 range to avoid conflicts
|
||||
with other services or parallel test runs.
|
||||
"""
|
||||
port = 33660 + random.randint(0, 99)
|
||||
harness = ServerHarness(port)
|
||||
harness.start(e2e_db_path)
|
||||
yield harness
|
||||
harness.stop()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def base_url(live_server):
|
||||
"""Provide the base URL for the live server."""
|
||||
return live_server.url
|
||||
29
tests/e2e/test_smoke.py
Normal file
29
tests/e2e/test_smoke.py
Normal file
@@ -0,0 +1,29 @@
|
||||
# ABOUTME: Basic smoke tests to verify the e2e test setup works.
|
||||
# ABOUTME: Tests server startup, health endpoint, and page loading.
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from playwright.sync_api import Page, expect
|
||||
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
|
||||
def test_healthz_endpoint(live_server):
|
||||
"""Verify health endpoint returns OK."""
|
||||
response = requests.get(f"{live_server.url}/healthz")
|
||||
assert response.status_code == 200
|
||||
assert response.text == "OK"
|
||||
|
||||
|
||||
def test_home_page_loads(page: Page, live_server):
|
||||
"""Verify the home page loads successfully."""
|
||||
page.goto(live_server.url)
|
||||
# Should see the page body
|
||||
expect(page.locator("body")).to_be_visible()
|
||||
|
||||
|
||||
def test_animals_page_accessible(page: Page, live_server):
|
||||
"""Verify animals list page is accessible."""
|
||||
page.goto(f"{live_server.url}/animals")
|
||||
# Should see some content (exact content depends on seed data)
|
||||
expect(page.locator("body")).to_be_visible()
|
||||
Reference in New Issue
Block a user