Skip to content

Commit 964a016

Browse files
VuQuoc18claude
andcommitted
feat(test): implement test isolation with transaction scope
Add test database configuration and function-scoped fixtures to ensure complete test isolation and prevent data persistence between test cases. - Add TEST_DATABASE_URL configuration in app/core/config.py - Create test_lesmee database configuration for separation - Implement function-scoped db_engine and db fixtures with transaction isolation - Override get_db dependency in client fixture for proper test isolation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent 033f3e7 commit 964a016

File tree

2 files changed

+104
-7
lines changed

2 files changed

+104
-7
lines changed

backend/app/core/config.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,13 @@ def all_cors_origins(self) -> list[str]:
6262
POSTGRES_PASSWORD: str = ""
6363
POSTGRES_DB: str = ""
6464

65+
# Test Database Configuration
66+
POSTGRES_TEST_SERVER: str | None = None
67+
POSTGRES_TEST_PORT: int = 5432
68+
POSTGRES_TEST_USER: str | None = None
69+
POSTGRES_TEST_PASSWORD: str | None = None
70+
POSTGRES_TEST_DB: str = "test_lesmee"
71+
6572
@computed_field # type: ignore[prop-decorator]
6673
@property
6774
def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn:
@@ -74,6 +81,27 @@ def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn:
7481
path=self.POSTGRES_DB,
7582
)
7683

84+
@computed_field # type: ignore[prop-decorator]
85+
@property
86+
def TEST_DATABASE_URL(self) -> str:
87+
"""Database URL for testing environment."""
88+
# Use test-specific PostgreSQL configuration if available,
89+
# otherwise fallback to main config with test DB name
90+
server = self.POSTGRES_TEST_SERVER or self.POSTGRES_SERVER
91+
port = self.POSTGRES_TEST_PORT if self.POSTGRES_TEST_SERVER else self.POSTGRES_PORT
92+
user = self.POSTGRES_TEST_USER or self.POSTGRES_USER
93+
password = self.POSTGRES_TEST_PASSWORD or self.POSTGRES_PASSWORD
94+
db = self.POSTGRES_TEST_DB
95+
96+
return str(PostgresDsn.build(
97+
scheme="postgresql+psycopg",
98+
username=user,
99+
password=password,
100+
host=server,
101+
port=port,
102+
path=db,
103+
))
104+
77105
SMTP_TLS: bool = True
78106
SMTP_SSL: bool = False
79107
SMTP_PORT: int = 587

backend/tests/conftest.py

Lines changed: 76 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,75 @@
11
from collections.abc import Generator
2+
from typing import Generator as TypingGenerator
23

34
import pytest
45
from fastapi.testclient import TestClient
5-
from sqlmodel import Session, delete
6+
from sqlmodel import Session, delete, create_engine
7+
from sqlalchemy.orm import sessionmaker
68

79
from app.core.config import settings
8-
from app.core.db import engine, init_db
10+
from app.core.db import engine as default_engine, init_db
911
from app.main import app
12+
from app import api
1013
from app.models import Item, User, Image, ImageVariant, ImageProcessingJob
1114
from tests.utils.user import authentication_token_from_email
1215
from tests.utils.utils import get_superuser_token_headers
1316

1417

18+
@pytest.fixture(scope="function")
19+
def db_engine():
20+
"""Create fresh database engine for each test to ensure complete isolation."""
21+
# Use dedicated test database URL to separate from main database
22+
test_db_url = str(settings.TEST_DATABASE_URL)
23+
24+
# Create engine with test-specific settings
25+
engine = create_engine(
26+
test_db_url,
27+
pool_pre_ping=True,
28+
echo=False, # Disable SQL logging for tests
29+
)
30+
31+
# Create all tables for each test
32+
from app.models import SQLModel
33+
SQLModel.metadata.create_all(engine)
34+
35+
yield engine
36+
37+
# Cleanup: Drop all tables after each test
38+
SQLModel.metadata.drop_all(engine)
39+
engine.dispose()
40+
41+
42+
@pytest.fixture(scope="function")
43+
def db(db_engine) -> Generator[Session, None, None]:
44+
"""Create transaction-isolated database session for each test."""
45+
# Bind the session to the engine
46+
with Session(bind=db_engine) as session:
47+
# Begin a transaction
48+
transaction = session.begin()
49+
50+
# Initialize database with superuser for tests
51+
init_db(session)
52+
53+
try:
54+
yield session
55+
finally:
56+
# Always rollback the transaction to ensure isolation
57+
try:
58+
transaction.rollback()
59+
except Exception:
60+
pass # Ignore rollback errors
61+
62+
# Explicit cleanup of session state
63+
session.expunge_all()
64+
65+
1566
@pytest.fixture(scope="session")
16-
def db() -> Generator[Session, None, None]:
17-
with Session(engine) as session:
67+
def db_session_scope() -> Generator[Session, None, None]:
68+
"""Session-scoped database fixture for backward compatibility (deprecated)."""
69+
with Session(default_engine) as session:
1870
init_db(session)
1971
yield session
72+
# Cleanup data at end of session
2073
statement = delete(ImageProcessingJob)
2174
session.execute(statement)
2275
statement = delete(ImageVariant)
@@ -30,18 +83,34 @@ def db() -> Generator[Session, None, None]:
3083
session.commit()
3184

3285

33-
@pytest.fixture(scope="module")
86+
@pytest.fixture(scope="function")
3487
def client(db: Session) -> Generator[TestClient, None, None]:
88+
"""
89+
Create a test client with database session override.
90+
This ensures each test gets an isolated database session.
91+
"""
92+
def override_get_db():
93+
try:
94+
yield db
95+
finally:
96+
pass # Transaction will be rolled back by db fixture
97+
98+
# Override the dependency
99+
app.dependency_overrides[api.deps.get_db] = override_get_db
100+
35101
with TestClient(app) as c:
36102
yield c
37103

104+
# Clean up overrides after test
105+
app.dependency_overrides.clear()
106+
38107

39-
@pytest.fixture(scope="module")
108+
@pytest.fixture(scope="function")
40109
def superuser_token_headers(client: TestClient) -> dict[str, str]:
41110
return get_superuser_token_headers(client)
42111

43112

44-
@pytest.fixture(scope="module")
113+
@pytest.fixture(scope="function")
45114
def normal_user_token_headers(client: TestClient, db: Session) -> dict[str, str]:
46115
return authentication_token_from_email(
47116
client=client, email=settings.EMAIL_TEST_USER, db=db

0 commit comments

Comments
 (0)