parallel tests
This commit is contained in:
parent
73a45b81cc
commit
139a5fbef3
5 changed files with 61 additions and 228 deletions
23
Makefile
23
Makefile
|
|
@ -38,13 +38,18 @@ db-ready:
|
||||||
@until docker compose exec -T db pg_isready -U postgres > /dev/null 2>&1; do \
|
@until docker compose exec -T db pg_isready -U postgres > /dev/null 2>&1; do \
|
||||||
sleep 1; \
|
sleep 1; \
|
||||||
done
|
done
|
||||||
|
@docker compose exec -T db psql -U postgres -tc "SELECT 1 FROM pg_database WHERE datname = 'arbret'" | grep -q 1 || \
|
||||||
|
docker compose exec -T db psql -U postgres -c "CREATE DATABASE arbret"
|
||||||
@docker compose exec -T db psql -U postgres -tc "SELECT 1 FROM pg_database WHERE datname = 'arbret_test'" | grep -q 1 || \
|
@docker compose exec -T db psql -U postgres -tc "SELECT 1 FROM pg_database WHERE datname = 'arbret_test'" | grep -q 1 || \
|
||||||
docker compose exec -T db psql -U postgres -c "CREATE DATABASE arbret_test"
|
docker compose exec -T db psql -U postgres -c "CREATE DATABASE arbret_test"
|
||||||
@# Create worker-specific databases for parallel test execution (pytest-xdist)
|
@# Create worker-specific databases for parallel backend test execution (pytest-xdist)
|
||||||
@for i in 0 1 2 3 4 5 6 7; do \
|
@for i in 0 1 2 3 4 5 6 7; do \
|
||||||
docker compose exec -T db psql -U postgres -tc "SELECT 1 FROM pg_database WHERE datname = 'arbret_test_gw$$i'" | grep -q 1 || \
|
docker compose exec -T db psql -U postgres -tc "SELECT 1 FROM pg_database WHERE datname = 'arbret_test_gw$$i'" | grep -q 1 || \
|
||||||
docker compose exec -T db psql -U postgres -c "CREATE DATABASE arbret_test_gw$$i"; \
|
docker compose exec -T db psql -U postgres -c "CREATE DATABASE arbret_test_gw$$i"; \
|
||||||
done
|
done
|
||||||
|
@# Create separate database for e2e tests
|
||||||
|
@docker compose exec -T db psql -U postgres -tc "SELECT 1 FROM pg_database WHERE datname = 'arbret_e2e'" | grep -q 1 || \
|
||||||
|
docker compose exec -T db psql -U postgres -c "CREATE DATABASE arbret_e2e"
|
||||||
@echo "PostgreSQL is ready"
|
@echo "PostgreSQL is ready"
|
||||||
|
|
||||||
db-seed: db-ready
|
db-seed: db-ready
|
||||||
|
|
@ -63,15 +68,27 @@ dev:
|
||||||
# E2E: TEST="auth" (file pattern matching e2e/*.spec.ts)
|
# E2E: TEST="auth" (file pattern matching e2e/*.spec.ts)
|
||||||
TEST ?=
|
TEST ?=
|
||||||
|
|
||||||
test-backend: db-clean db-ready
|
test-backend: db-ready test-backend-clean-dbs
|
||||||
cd backend && uv run pytest -v -n 8 $(TEST)
|
cd backend && uv run pytest -v -n 8 $(TEST)
|
||||||
|
|
||||||
|
# Clean only backend test databases (not e2e or main db)
|
||||||
|
test-backend-clean-dbs:
|
||||||
|
@for db in arbret_test arbret_test_gw0 arbret_test_gw1 arbret_test_gw2 arbret_test_gw3 arbret_test_gw4 arbret_test_gw5 arbret_test_gw6 arbret_test_gw7; do \
|
||||||
|
docker compose exec -T db psql -U postgres -c "DROP DATABASE IF EXISTS $$db" 2>/dev/null || true; \
|
||||||
|
docker compose exec -T db psql -U postgres -c "CREATE DATABASE $$db"; \
|
||||||
|
done
|
||||||
|
|
||||||
test-frontend:
|
test-frontend:
|
||||||
cd frontend && npm run test $(if $(TEST),-- $(TEST),)
|
cd frontend && npm run test $(if $(TEST),-- $(TEST),)
|
||||||
|
|
||||||
test-e2e: db-clean db-ready
|
test-e2e: db-ready test-e2e-clean-db
|
||||||
./scripts/e2e.sh $(TEST)
|
./scripts/e2e.sh $(TEST)
|
||||||
|
|
||||||
|
# Clean only e2e database (not backend test dbs or main db)
|
||||||
|
test-e2e-clean-db:
|
||||||
|
@docker compose exec -T db psql -U postgres -c "DROP DATABASE IF EXISTS arbret_e2e" 2>/dev/null || true
|
||||||
|
@docker compose exec -T db psql -U postgres -c "CREATE DATABASE arbret_e2e"
|
||||||
|
|
||||||
test: check-constants check-types-fresh test-backend test-frontend test-e2e
|
test: check-constants check-types-fresh test-backend test-frontend test-e2e
|
||||||
|
|
||||||
typecheck: generate-types-standalone
|
typecheck: generate-types-standalone
|
||||||
|
|
|
||||||
|
|
@ -1,206 +0,0 @@
|
||||||
# Backend Test Optimization Plan
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
This plan implements three optimizations to speed up backend test execution:
|
|
||||||
1. **Session-scoped role setup** (#4)
|
|
||||||
2. **Session-scoped schema + transaction rollback** (#1)
|
|
||||||
3. **Parallel test execution** (#2)
|
|
||||||
|
|
||||||
Current baseline: 236 tests in ~110 seconds (~0.46s per test)
|
|
||||||
|
|
||||||
## Implementation Steps
|
|
||||||
|
|
||||||
### Step 1: Session-Scoped Role Setup (#4)
|
|
||||||
|
|
||||||
**Goal**: Create roles once per test session instead of 236 times.
|
|
||||||
|
|
||||||
**Changes**:
|
|
||||||
- Create `@pytest.fixture(scope="session")` for engine
|
|
||||||
- Create `@pytest.fixture(scope="session")` for roles setup
|
|
||||||
- Modify `client_factory` to use pre-created roles instead of calling `setup_roles()` each time
|
|
||||||
|
|
||||||
**Benefits**:
|
|
||||||
- Eliminates 236 role creation operations
|
|
||||||
- Roles are static data, safe to share across tests
|
|
||||||
|
|
||||||
**Risks**: Low - roles are read-only after creation
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Step 2: Session-Scoped Schema Creation (#1)
|
|
||||||
|
|
||||||
**Goal**: Create database schema once per session instead of dropping/recreating 236 times.
|
|
||||||
|
|
||||||
**Changes**:
|
|
||||||
- Move schema creation (`drop_all` + `create_all`) to session-scoped `engine` fixture
|
|
||||||
- Schema created once at session start, cleaned up at session end
|
|
||||||
- Each test still gets a fresh database state via transaction rollback
|
|
||||||
|
|
||||||
**Benefits**:
|
|
||||||
- Eliminates 236 schema drop/create operations (major bottleneck)
|
|
||||||
- Expected 40-60% speed improvement
|
|
||||||
|
|
||||||
**Risks**: Medium - need to ensure proper cleanup and isolation
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Step 3: Transaction Rollback Pattern (#1)
|
|
||||||
|
|
||||||
**Goal**: Use database transactions to isolate tests instead of dropping tables.
|
|
||||||
|
|
||||||
**Approach**:
|
|
||||||
- Each test runs inside a transaction
|
|
||||||
- After test completes, rollback the transaction (not commit)
|
|
||||||
- Next test starts with clean state automatically
|
|
||||||
|
|
||||||
**Implementation Strategy**:
|
|
||||||
1. Create a session-scoped connection pool
|
|
||||||
2. For each test:
|
|
||||||
- Start a transaction (or use a savepoint)
|
|
||||||
- Run test with all DB operations in this transaction
|
|
||||||
- Rollback transaction after test
|
|
||||||
3. Override `get_db()` to yield sessions within the transaction context
|
|
||||||
|
|
||||||
**Key Challenge**: FastAPI's `get_db` dependency needs to work with transaction boundaries.
|
|
||||||
|
|
||||||
**Solution Options**:
|
|
||||||
- **Option A**: Use nested transactions (savepoints) - more complex but better isolation
|
|
||||||
- **Option B**: Use connection-level transactions - simpler, rollback entire connection state
|
|
||||||
|
|
||||||
**Recommended**: Option B (simpler, sufficient for test isolation)
|
|
||||||
|
|
||||||
**Changes**:
|
|
||||||
- Modify `client_factory` to use transaction-scoped sessions
|
|
||||||
- Update `get_db_session()` to work within transaction context
|
|
||||||
- Ensure all test DB operations happen within transaction
|
|
||||||
|
|
||||||
**Benefits**:
|
|
||||||
- Fast test isolation (rollback is much faster than drop/create)
|
|
||||||
- Maintains test independence
|
|
||||||
|
|
||||||
**Risks**: Medium - need to ensure:
|
|
||||||
- No commits happen during tests (or they're rolled back)
|
|
||||||
- Transaction boundaries are properly managed
|
|
||||||
- Async context managers work correctly
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Step 4: Update Fixtures for New Architecture
|
|
||||||
|
|
||||||
**Changes**:
|
|
||||||
- Update `client_factory` to depend on session-scoped `engine` and `roles`
|
|
||||||
- Update `get_db_session()` to work with transaction rollback
|
|
||||||
- Ensure user fixtures (`regular_user`, `admin_user`, etc.) work with new pattern
|
|
||||||
- Update `override_get_db()` to yield sessions within transaction context
|
|
||||||
|
|
||||||
**Testing**: Run a subset of tests to verify fixtures work correctly
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Step 5: Add pytest-xdist for Parallel Execution (#2)
|
|
||||||
|
|
||||||
**Goal**: Run tests in parallel across CPU cores.
|
|
||||||
|
|
||||||
**Changes**:
|
|
||||||
1. Add `pytest-xdist` to `pyproject.toml` dev dependencies
|
|
||||||
2. Update `Makefile` to use `pytest -n auto` for parallel execution
|
|
||||||
3. Ensure test isolation is maintained (transaction rollback ensures this)
|
|
||||||
|
|
||||||
**Configuration**:
|
|
||||||
- Use `-n auto` to auto-detect CPU cores
|
|
||||||
- Can override with `-n 4` for specific core count
|
|
||||||
- Add `pytest-xdist` to dependency groups
|
|
||||||
|
|
||||||
**Benefits**:
|
|
||||||
- 2-4x speed improvement (depending on CPU cores)
|
|
||||||
- Works well with transaction isolation
|
|
||||||
|
|
||||||
**Risks**: Low - transaction rollback ensures tests don't interfere
|
|
||||||
|
|
||||||
**Note**: May need to adjust if tests have shared state (but transaction rollback should prevent this)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Step 6: Testing and Validation
|
|
||||||
|
|
||||||
**Verification Steps**:
|
|
||||||
1. Run full test suite: `make test-backend`
|
|
||||||
2. Verify all 236 tests pass
|
|
||||||
3. Measure execution time improvement
|
|
||||||
4. Check for any flaky tests (shouldn't happen with proper isolation)
|
|
||||||
5. Test parallel execution with `pytest -n auto`
|
|
||||||
|
|
||||||
**Success Criteria**:
|
|
||||||
- All tests pass
|
|
||||||
- Significant speed improvement (target: 50-70% faster)
|
|
||||||
- No test flakiness introduced
|
|
||||||
- Parallel execution works correctly
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Order
|
|
||||||
|
|
||||||
1. ✅ **Step 1**: Session-scoped role setup (easiest, low risk)
|
|
||||||
2. ✅ **Step 2**: Session-scoped schema creation (foundation for #3)
|
|
||||||
3. ✅ **Step 3**: Transaction rollback pattern (core optimization)
|
|
||||||
4. ✅ **Step 4**: Update all fixtures (required for #3 to work)
|
|
||||||
5. ✅ **Step 5**: Add pytest-xdist (quick win, independent)
|
|
||||||
6. ✅ **Step 6**: Test and validate
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Technical Details
|
|
||||||
|
|
||||||
### Transaction Rollback Pattern
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Pseudo-code for transaction pattern
|
|
||||||
@pytest.fixture(scope="function")
|
|
||||||
async def db_transaction(engine):
|
|
||||||
async with engine.connect() as conn:
|
|
||||||
trans = await conn.begin()
|
|
||||||
try:
|
|
||||||
# Create session factory that uses this connection
|
|
||||||
session_factory = async_sessionmaker(bind=conn, ...)
|
|
||||||
yield session_factory
|
|
||||||
finally:
|
|
||||||
await trans.rollback() # Always rollback, never commit
|
|
||||||
```
|
|
||||||
|
|
||||||
### Session-Scoped Engine
|
|
||||||
|
|
||||||
```python
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
async def engine():
|
|
||||||
engine = create_async_engine(TEST_DATABASE_URL)
|
|
||||||
# Create schema once
|
|
||||||
async with engine.begin() as conn:
|
|
||||||
await conn.run_sync(Base.metadata.drop_all)
|
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
|
||||||
yield engine
|
|
||||||
await engine.dispose()
|
|
||||||
```
|
|
||||||
|
|
||||||
### Role Setup
|
|
||||||
|
|
||||||
```python
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
async def roles(engine):
|
|
||||||
session_factory = async_sessionmaker(engine)
|
|
||||||
async with session_factory() as db:
|
|
||||||
roles = await setup_roles(db)
|
|
||||||
await db.commit() # Commit roles once
|
|
||||||
return roles
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Rollback Plan
|
|
||||||
|
|
||||||
If issues arise:
|
|
||||||
1. Revert `conftest.py` changes
|
|
||||||
2. Remove `pytest-xdist` dependency
|
|
||||||
3. Restore original fixture structure
|
|
||||||
|
|
||||||
All changes are isolated to test files, no production code affected.
|
|
||||||
|
|
||||||
|
|
@ -82,11 +82,18 @@ test.describe("Admin Invites Page", () => {
|
||||||
// Create an invite first
|
// Create an invite first
|
||||||
const godfatherSelect = page.locator("select").first();
|
const godfatherSelect = page.locator("select").first();
|
||||||
await godfatherSelect.selectOption({ label: REGULAR_USER_EMAIL });
|
await godfatherSelect.selectOption({ label: REGULAR_USER_EMAIL });
|
||||||
|
|
||||||
|
// Wait for create invite response
|
||||||
|
const createPromise = page.waitForResponse(
|
||||||
|
(resp) => resp.url().includes("/api/admin/invites") && resp.request().method() === "POST"
|
||||||
|
);
|
||||||
await page.click('button:has-text("Create Invite")');
|
await page.click('button:has-text("Create Invite")');
|
||||||
|
await createPromise;
|
||||||
|
|
||||||
|
// Wait for table to update with new invite
|
||||||
await expect(page.locator("table")).toContainText("ready");
|
await expect(page.locator("table")).toContainText("ready");
|
||||||
|
|
||||||
// Wait for the new invite to appear and capture its code
|
// Wait for the new invite to appear and capture its code
|
||||||
// The new invite should be the first row with godfather = REGULAR_USER_EMAIL and status = ready
|
|
||||||
const newInviteRow = page
|
const newInviteRow = page
|
||||||
.locator("tr")
|
.locator("tr")
|
||||||
.filter({ hasText: REGULAR_USER_EMAIL })
|
.filter({ hasText: REGULAR_USER_EMAIL })
|
||||||
|
|
@ -97,23 +104,33 @@ test.describe("Admin Invites Page", () => {
|
||||||
// Get the invite code from this row (first cell)
|
// Get the invite code from this row (first cell)
|
||||||
const inviteCode = await newInviteRow.locator("td").first().textContent();
|
const inviteCode = await newInviteRow.locator("td").first().textContent();
|
||||||
|
|
||||||
// Click revoke on this specific row
|
// Click revoke and wait for the response
|
||||||
|
// The revoke endpoint is POST /api/admin/invites/{invite_id}/revoke
|
||||||
|
const revokePromise = page.waitForResponse(
|
||||||
|
(resp) =>
|
||||||
|
resp.url().includes("/api/admin/invites") &&
|
||||||
|
resp.url().includes("/revoke") &&
|
||||||
|
resp.request().method() === "POST"
|
||||||
|
);
|
||||||
await newInviteRow.locator('button:has-text("Revoke")').click();
|
await newInviteRow.locator('button:has-text("Revoke")').click();
|
||||||
|
await revokePromise;
|
||||||
|
|
||||||
// Verify this specific invite now shows "revoked"
|
// Wait for table to refresh and verify this specific invite now shows "revoked"
|
||||||
const revokedRow = page.locator("tr").filter({ hasText: inviteCode! });
|
const revokedRow = page.locator("tr").filter({ hasText: inviteCode! });
|
||||||
await expect(revokedRow).toContainText("revoked");
|
await expect(revokedRow).toContainText("revoked", { timeout: 5000 });
|
||||||
|
|
||||||
// Test status filter - filter by "revoked" status
|
// Test status filter - filter by "revoked" status
|
||||||
const statusFilter = page.locator("select").nth(1); // Second select is the status filter
|
const statusFilter = page.locator("select").nth(1); // Second select is the status filter
|
||||||
await statusFilter.selectOption("revoked");
|
await statusFilter.selectOption("revoked");
|
||||||
|
|
||||||
// Wait for the filter to apply
|
// Wait for the filter to apply and verify revoked invite is visible
|
||||||
await page.waitForResponse((resp) => resp.url().includes("status=revoked"));
|
await page.waitForResponse((resp) => resp.url().includes("status=revoked"));
|
||||||
|
await expect(revokedRow).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
// Filter by "ready" status - should show our invite (if we create another one)
|
// Filter by "ready" status - should not show our revoked invite
|
||||||
await statusFilter.selectOption("ready");
|
await statusFilter.selectOption("ready");
|
||||||
await page.waitForResponse((resp) => resp.url().includes("status=ready"));
|
await page.waitForResponse((resp) => resp.url().includes("status=ready"));
|
||||||
|
await expect(revokedRow).not.toBeVisible({ timeout: 5000 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ const ADMIN_EMAIL = "admin@example.com";
|
||||||
const ADMIN_PASSWORD = "admin123";
|
const ADMIN_PASSWORD = "admin123";
|
||||||
|
|
||||||
// Helper to create an invite via the API
|
// Helper to create an invite via the API
|
||||||
const API_BASE = "http://localhost:8000";
|
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
|
||||||
|
|
||||||
async function createInvite(request: APIRequestContext): Promise<string> {
|
async function createInvite(request: APIRequestContext): Promise<string> {
|
||||||
// Login as admin
|
// Login as admin
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,10 @@ set -e
|
||||||
|
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
# E2E tests use a separate database and port to allow parallel execution with backend tests
|
||||||
|
E2E_PORT=${E2E_PORT:-8001}
|
||||||
|
E2E_DATABASE_URL="postgresql+asyncpg://postgres:postgres@localhost:5432/arbret_e2e"
|
||||||
|
|
||||||
# Cleanup function to kill background processes
|
# Cleanup function to kill background processes
|
||||||
cleanup() {
|
cleanup() {
|
||||||
kill $BACKEND_PID 2>/dev/null || true
|
kill $BACKEND_PID 2>/dev/null || true
|
||||||
|
|
@ -18,34 +22,35 @@ if [ -f .env ]; then
|
||||||
set +a
|
set +a
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Kill any existing backend
|
# Kill any existing e2e backend (on our specific port)
|
||||||
pkill -f "uvicorn main:app" 2>/dev/null || true
|
pkill -f "uvicorn main:app --port $E2E_PORT" 2>/dev/null || true
|
||||||
sleep 1
|
sleep 1
|
||||||
|
|
||||||
# Seed the database with roles and test users
|
# Seed the e2e database with roles and test users
|
||||||
cd backend
|
cd backend
|
||||||
echo "Seeding database..."
|
echo "Seeding e2e database..."
|
||||||
uv run python seed.py
|
DATABASE_URL="$E2E_DATABASE_URL" uv run python seed.py
|
||||||
cd ..
|
cd ..
|
||||||
|
|
||||||
# Start backend (SECRET_KEY should be set via .envrc or environment)
|
# Start backend for e2e tests (uses e2e database and separate port)
|
||||||
cd backend
|
cd backend
|
||||||
uv run uvicorn main:app --port 8000 --log-level warning &
|
DATABASE_URL="$E2E_DATABASE_URL" uv run uvicorn main:app --port $E2E_PORT --log-level warning &
|
||||||
BACKEND_PID=$!
|
BACKEND_PID=$!
|
||||||
cd ..
|
cd ..
|
||||||
|
|
||||||
# Wait for backend
|
# Wait for backend
|
||||||
sleep 2
|
sleep 2
|
||||||
|
|
||||||
# Generate API types from OpenAPI schema
|
# Generate API types from OpenAPI schema (using e2e backend)
|
||||||
echo "Generating API types..."
|
echo "Generating API types from e2e backend..."
|
||||||
cd frontend
|
cd frontend
|
||||||
npm run generate-api-types
|
npx openapi-typescript "http://localhost:$E2E_PORT/openapi.json" -o app/generated/api.ts
|
||||||
cd ..
|
cd ..
|
||||||
|
|
||||||
# Run tests (suppress Node.js color warnings)
|
# Run tests with e2e-specific backend URL
|
||||||
# If TEST argument is provided, use it as a file pattern
|
# The frontend will connect to our e2e backend on $E2E_PORT
|
||||||
cd frontend
|
cd frontend
|
||||||
|
export NEXT_PUBLIC_API_URL="http://localhost:$E2E_PORT"
|
||||||
if [ -n "$1" ]; then
|
if [ -n "$1" ]; then
|
||||||
NODE_NO_WARNINGS=1 npx playwright test "$1"
|
NODE_NO_WARNINGS=1 npx playwright test "$1"
|
||||||
else
|
else
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue