Skip to content

Commit 92387b4

Browse files
authored
test: isolated testing (#38)
* tests: `make test` now sets up a docker db * chore: improved `.env` file resolution in `Settings` * tests: proper isolation and first integration tests
1 parent ea8ee4d commit 92387b4

8 files changed

Lines changed: 244 additions & 60 deletions

File tree

backend/Makefile

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,21 @@ lint:
2121
poetry run flake8 .
2222

2323
test:
24-
poetry run pytest --cov=app --cov-report=term --cov-report=html tests
24+
@echo "Starting test database container..."
25+
@docker run --name evsy-db-test -e POSTGRES_USER=evsy_test -e POSTGRES_PASSWORD=evsy_test -e POSTGRES_DB=evsy_test -p 5433:5432 -d postgres:15-alpine > /dev/null
26+
@echo "Waiting for database to be ready..."
27+
@until docker exec evsy-db-test pg_isready -U evsy_test > /dev/null 2>&1; do sleep 1; done
28+
@echo "Database is ready. Running tests..."
29+
-@ENV=test poetry run pytest --cov=app --cov-report=term --cov-report=html tests
30+
@echo "Cleaning up..."
31+
@docker rm -f evsy-db-test > /dev/null
2532

2633
test-fast:
27-
poetry run pytest tests
34+
@echo "Starting test database container..."
35+
@docker run --name evsy-db-test -e POSTGRES_USER=evsy_test -e POSTGRES_PASSWORD=evsy_test -e POSTGRES_DB=evsy_test -p 5433:5432 -d postgres:15-alpine > /dev/null
36+
@echo "Waiting for database to be ready..."
37+
@until docker exec evsy-db-test pg_isready -U evsy_test > /dev/null 2>&1; do sleep 1; done
38+
@echo "Database is ready. Running tests..."
39+
-@ENV=test poetry run pytest tests
40+
@echo "Cleaning up..."
41+
@docker rm -f evsy-db-test > /dev/null

backend/app/settings.py

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,52 @@
33
from pathlib import Path
44
from typing import Literal, Optional
55

6-
from pydantic import ConfigDict, Field
7-
from pydantic_settings import BaseSettings
6+
from dotenv import load_dotenv
7+
from pydantic import Field
8+
from pydantic_settings import BaseSettings, SettingsConfigDict
89

910

10-
def resolve_env_file(base_dir: str = ".") -> str:
11-
env_mode = os.getenv("ENV", "dev")
12-
path = Path(base_dir) / f".env.{env_mode}"
13-
return str(path) if path.exists() else str(Path(base_dir) / ".env")
11+
def resolve_env_file() -> Optional[str]:
12+
# 1. Check if ENV is set (e.g., ENV=test)
13+
env_mode = os.getenv("ENV")
14+
15+
# Priority: current working directory (for tests)
16+
cwd = Path.cwd()
17+
if env_mode and (cwd / f".env.{env_mode}").exists():
18+
return str(cwd / f".env.{env_mode}")
19+
if (cwd / ".env").exists():
20+
return str(cwd / ".env")
21+
22+
# Fallback: Absolute paths relative to this file
23+
backend_root = Path(__file__).parent.parent
24+
if env_mode:
25+
test_path = backend_root / f".env.{env_mode}"
26+
if test_path.exists():
27+
return str(test_path)
28+
29+
local_env = backend_root / ".env"
30+
if local_env.exists():
31+
return str(local_env)
32+
33+
root_env = backend_root.parent / ".env"
34+
if root_env.exists():
35+
return str(root_env)
36+
37+
return None
1438

1539

1640
class Settings(BaseSettings):
17-
env: Literal["dev", "prod", "demo"] = Field(default="dev", alias="ENV")
41+
def __init__(self, _env_file: Optional[str] = None, **kwargs):
42+
# Dynamically resolve env file if not provided
43+
if _env_file is None:
44+
_env_file = resolve_env_file()
45+
46+
if _env_file:
47+
load_dotenv(_env_file, override=True)
48+
49+
super().__init__(**kwargs)
50+
51+
env: Literal["dev", "prod", "demo", "test"] = Field(default="dev", alias="ENV")
1852
database_url: str = "sqlite:///./test.db"
1953
frontend_url: Optional[str] = None
2054

@@ -26,10 +60,10 @@ class Settings(BaseSettings):
2660
google_client_id: Optional[str] = None
2761
google_client_secret: Optional[str] = None
2862

29-
model_config = ConfigDict(
30-
env_file=resolve_env_file(),
63+
model_config = SettingsConfigDict(
3164
env_file_encoding="utf-8",
3265
case_sensitive=False,
66+
extra="ignore",
3367
)
3468

3569
@property

backend/tests/conftest.py

Lines changed: 55 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,93 +1,105 @@
11
import bcrypt
22
import pytest
33
from fastapi.testclient import TestClient
4-
from sqlalchemy.orm import Session
4+
from sqlalchemy import create_engine
5+
from sqlalchemy.orm import Session, sessionmaker
56

6-
from app.core.database import Base, get_db, init_db
7+
from app.core.database import Base, get_db
78
from app.factory import create_app
89
from app.modules.auth.crud import create_user
910
from app.modules.auth.token import create_access_token
1011
from app.settings import Settings
1112

13+
# No need for manual load_dotenv, Settings() will handle it via resolve_env_file()
14+
# or we can pass it explicitly for maximum clarity in tests.
15+
1216

1317
@pytest.fixture(scope="session")
1418
def test_settings():
15-
"""Session-scoped fixture for test settings, using a file-based SQLite DB."""
16-
settings = Settings(_env_file=".env.test")
17-
settings.database_url = "sqlite:///./test.db"
18-
return settings
19+
"""Session-scoped fixture for test settings, using a Postgres DB."""
20+
# We explicitly pass the env file to ensure we use exactly what we want
21+
return Settings(_env_file=".env.test")
1922

2023

2124
@pytest.fixture(scope="session")
22-
def db_engine_session(test_settings: Settings):
23-
"""Session-scoped fixture for the database engine and session factory."""
24-
engine, session_local = init_db(test_settings)
25-
return engine, session_local
25+
def db_engine(test_settings: Settings):
26+
"""Session-scoped engine for the Postgres database."""
27+
engine = create_engine(test_settings.database_url)
28+
Base.metadata.create_all(bind=engine)
29+
yield engine
30+
Base.metadata.drop_all(bind=engine)
2631

2732

2833
@pytest.fixture(scope="session")
29-
def app(test_settings: Settings, db_engine_session):
34+
def app(test_settings: Settings, db_engine):
3035
"""Session-scoped fixture for the FastAPI application instance."""
31-
_, session_local = db_engine_session
32-
return create_app(test_settings, session_local)
36+
# We use a dummy session_local here because we'll override get_db at the request level
37+
dummy_session_local = sessionmaker(bind=db_engine)
38+
return create_app(test_settings, dummy_session_local)
3339

3440

35-
@pytest.fixture(scope="session")
36-
def override_get_db(db_engine_session):
37-
"""Session-scoped fixture to override the `get_db` dependency."""
38-
_, session_local = db_engine_session
41+
@pytest.fixture
42+
def db(db_engine):
43+
"""
44+
Function-scoped fixture that provides a transactional database session.
45+
Everything is rolled back at the end of the test.
46+
"""
47+
connection = db_engine.connect()
48+
transaction = connection.begin()
49+
session = Session(bind=connection)
3950

40-
def _override_get_db():
41-
db: Session = session_local()
42-
try:
43-
yield db
44-
finally:
45-
db.close()
51+
yield session
4652

47-
return _override_get_db
53+
session.close()
54+
transaction.rollback()
55+
connection.close()
4856

4957

50-
@pytest.fixture(scope="session", autouse=True)
51-
def setup_database(app, db_engine_session, override_get_db):
52-
"""
53-
Session-scoped, autouse fixture to set up the database schema and dependency overrides.
54-
"""
55-
engine, _ = db_engine_session
56-
Base.metadata.create_all(bind=engine)
57-
app.dependency_overrides[get_db] = override_get_db
58-
yield
59-
Base.metadata.drop_all(bind=engine)
58+
@pytest.fixture
59+
def override_get_db(app, db):
60+
"""Fixture to override the get_db dependency for every test."""
6061

62+
def _get_db():
63+
yield db
6164

62-
@pytest.fixture(scope="session")
63-
def test_user(override_get_db):
64-
"""Create a test user in the DB"""
65-
db = next(override_get_db())
65+
app.dependency_overrides[get_db] = _get_db
66+
yield _get_db # Yield the function so it can be called if needed
67+
app.dependency_overrides.pop(get_db, None)
68+
69+
70+
@pytest.fixture(autouse=True)
71+
def auto_override_get_db(override_get_db):
72+
"""Automatically apply the get_db override for every test."""
73+
pass
74+
75+
76+
@pytest.fixture
77+
def test_user(db):
78+
"""Create a test user in the DB for the current transaction."""
6679
user = create_user(
6780
db,
6881
email="test@example.com",
6982
hashed_pw=bcrypt.hashpw(b"password123", bcrypt.gensalt()).decode("utf-8"),
7083
)
71-
7284
return user
7385

7486

75-
@pytest.fixture(scope="session")
87+
@pytest.fixture
7688
def access_token(test_user):
7789
"""Create an access token for the test user."""
7890
return create_access_token({"sub": str(test_user.email)})
7991

8092

81-
@pytest.fixture(scope="module")
93+
@pytest.fixture
8294
def client(app):
83-
"""Module-scoped test client for unauthenticated requests."""
95+
"""Test client for unauthenticated requests."""
8496
with TestClient(app) as c:
8597
yield c
8698

8799

88100
@pytest.fixture
89101
def auth_client(app, access_token):
90-
"""Function-scoped test client for authenticated requests."""
102+
"""Test client for authenticated requests."""
91103
with TestClient(app) as auth_client:
92104
auth_client.headers.update({"Authorization": f"Bearer {access_token}"})
93105
yield auth_client

backend/tests/test_auth.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,17 @@ def test_signup_returns_token(client, auth_data):
1818

1919

2020
def test_signup_duplicate_email(client, auth_data):
21+
# First signup
22+
client.post("/v1/auth/signup", json=auth_data)
2123
# Repeat signup to trigger duplicate error
2224
response = client.post("/v1/auth/signup", json=auth_data)
2325
assert response.status_code == 400
2426
assert "already exists" in response.json()["message"].lower()
2527

2628

2729
def test_login_with_valid_credentials(client, auth_data):
30+
# Ensure user exists
31+
client.post("/v1/auth/signup", json=auth_data)
2832
response = client.post("/v1/auth/login", json=auth_data)
2933
assert response.status_code == 200
3034
data = response.json()
@@ -63,6 +67,8 @@ def test_me_with_valid_token(auth_client, auth_data):
6367

6468

6569
def test_login_for_access_token(client, auth_data):
70+
# Ensure user exists
71+
client.post("/v1/auth/signup", json=auth_data)
6672
form_data = {
6773
"username": auth_data["email"],
6874
"password": auth_data["password"],

backend/tests/test_events.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,14 @@ def test_create_event(auth_client, sample_event):
1919
assert response.json()["name"] == sample_event.name
2020

2121

22-
def test_get_event(auth_client):
23-
response = auth_client.get("/v1/events/1")
22+
def test_get_event(auth_client, sample_event):
23+
# Create an event first
24+
create_response = auth_client.post("/v1/events/", json=sample_event.model_dump())
25+
event_id = create_response.json()["id"]
26+
27+
response = auth_client.get(f"/v1/events/{event_id}")
2428
assert response.status_code == 200
25-
assert response.json()["id"] == 1
29+
assert response.json()["id"] == event_id
2630

2731

2832
def test_create_event_with_invalid_field(auth_client):

backend/tests/test_fields.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ def test_create_field(auth_client, sample_field):
2020

2121
def test_get_field(auth_client, sample_field):
2222
"""Тест на получение события"""
23-
response = auth_client.get("/v1/fields/1")
23+
# Create a field first
24+
create_response = auth_client.post("/v1/fields/", json=sample_field.model_dump())
25+
field_id = create_response.json()["id"]
26+
27+
response = auth_client.get(f"/v1/fields/{field_id}")
2428
assert response.status_code == 200
25-
assert response.json()["id"] == 1
29+
assert response.json()["id"] == field_id

0 commit comments

Comments
 (0)