Skip to content

Commit fe6a040

Browse files
feat: add PostgreSQL support for CI tests and update test database configuration (#6)
1 parent 4c9e04c commit fe6a040

6 files changed

Lines changed: 124 additions & 11 deletions

File tree

.github/workflows/ci.yaml

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,15 @@ jobs:
2727
test:
2828
runs-on: ubuntu-latest
2929
timeout-minutes: 20
30+
services:
31+
postgres:
32+
image: public.ecr.aws/docker/library/postgres:17
33+
env:
34+
POSTGRES_USER: develop
35+
POSTGRES_PASSWORD: develop_secret
36+
POSTGRES_DB: develop
37+
ports:
38+
- 5432:5432
3039
steps:
3140
- name: Checkout
3241
uses: actions/checkout@v5
@@ -48,8 +57,14 @@ jobs:
4857
- name: Install the project
4958
run: uv sync --locked --all-extras --dev
5059

51-
- name: Run tests
60+
- name: Run tests (SQLite)
5261
env:
5362
API_KEY: TEST
5463
run: |
5564
uv run pytest -s --durations=5 --log-cli-level=INFO --capture=tee-sys -v
65+
66+
- name: Run tests (Postgres)
67+
env:
68+
API_KEY: TEST
69+
run: |
70+
uv run pytest -s --db postgres --durations=5 --log-cli-level=INFO --capture=tee-sys -v

NOTE.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Note
2+
3+
## Rollback when using pytest
4+
5+
When using `pytest` with SQLModel and a database, it's important to ensure that changes made during tests do not persist and affect other tests.
6+
7+
Here are some strategies to achieve this:
8+
9+
In `conftest.py` create a session that rolls back after each test:
10+
```python
11+
# Start a transaction that will be rolled back after the test
12+
connection = engine.connect()
13+
transaction = connection.begin()
14+
session = Session(bind=connection)
15+
16+
yield session
17+
18+
# Rollback all changes made during the test
19+
session.close()
20+
transaction.rollback()
21+
connection.close()
22+
```
23+
24+
Or [use a nested transaction for better test isolation](https://github.com/fastapi/sqlmodel/discussions/940):
25+
```python
26+
import sqlalchemy as sa
27+
from sqlmodel import Session
28+
29+
connection = engine.connect()
30+
transaction = connection.begin()
31+
session = Session(bind=connection)
32+
33+
nested = connection.begin_nested()
34+
35+
@sa.event.listens_for(session, "after_transaction_end")
36+
def end_savepoint(session, transaction):
37+
nonlocal nested
38+
if not nested.is_active:
39+
nested = connection.begin_nested()
40+
41+
yield session
42+
43+
session.close()
44+
transaction.rollback()
45+
connection.close()
46+
```
47+
48+
or recreate the entire database schema between tests (recreating the schema is slow):
49+
```python
50+
with Session(engine) as session:
51+
yield session
52+
53+
# For Postgres, clean up all data between tests
54+
if db_type == "postgres":
55+
command.downgrade(config, "base")
56+
```

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ Repo Features:
4343

4444
* Python 3.10+
4545
* uv 0.8.17+
46-
* PoatgreSQL 18.1+ (if you want to use PostgreSQL as database)
46+
* PostgreSQL 18.1+ (if you want to use PostgreSQL as database)
4747

4848
### Install uv
4949

src/alembic/versions/2025_12_18_1212-438f1c13aa3b_init.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@
2121

2222
def upgrade() -> None:
2323
"""Upgrade schema."""
24+
25+
# Example of handling different JSON types based on the database backend
26+
# is_postgres = settings.DATABASE_URL.startswith("postgresql")
27+
# json_type = postgresql.JSONB() if is_postgres else sa.JSON()
28+
2429
# ### commands auto generated by Alembic - please adjust! ###
2530
op.create_table(
2631
"hero",

tests/api/endpoints/test_heroes.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ def test_delete_hero(session: Session, client_with_db: TestClient):
101101
assert hero_in_db is None
102102

103103

104-
def test_update_hero_rollback_on_error(
104+
def test_update_hero_rollback_on_commit_error(
105105
mocker, client_with_db: TestClient, session: Session
106106
):
107107
"""Test that updating a hero rolls back on commit error."""

tests/conftest.py

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from pathlib import Path
44

55
import pytest
6+
import sqlalchemy as sa
67
from alembic import command
78
from alembic.config import Config
89
from api.deps import get_db_session
@@ -11,21 +12,38 @@
1112
from starlette.testclient import TestClient
1213

1314

15+
def pytest_addoption(parser):
16+
"""Add custom command line options."""
17+
parser.addoption(
18+
"--db",
19+
action="store",
20+
default="sqlite",
21+
choices=["sqlite", "postgres"],
22+
help="Database to use for tests: sqlite (default) or postgres",
23+
)
24+
25+
1426
@pytest.fixture
1527
def data_folder_path():
1628
return Path(__file__).parent.parent / "data"
1729

1830

1931
@pytest.fixture(name="session")
20-
def session_fixture(monkeypatch) -> Session:
32+
def session_fixture(request, monkeypatch) -> Session:
2133
"""Create a new database session for a test."""
2234
# TODO: Use an in-memory SQLite database for faster tests if possible.
2335
# https://sqlmodel.tiangolo.com/tutorial/fastapi/tests/#memory-database
2436

25-
# Create a temporary database file
26-
fd, path = tempfile.mkstemp(suffix=".db")
27-
os.close(fd)
28-
db_url = f"sqlite:///{path}"
37+
db_type = request.config.getoption("--db")
38+
39+
if db_type == "postgres":
40+
db_url = "postgresql://develop:develop_secret@localhost:5432/develop"
41+
path = None
42+
else:
43+
# Create a temporary database file for SQLite
44+
fd, path = tempfile.mkstemp(suffix=".db")
45+
os.close(fd)
46+
db_url = f"sqlite:///{path}"
2947

3048
# Use monkeypatch to set DATABASE_URL environment variable
3149
monkeypatch.setattr("config.settings.DATABASE_URL", db_url)
@@ -42,12 +60,31 @@ def session_fixture(monkeypatch) -> Session:
4260
engine = create_engine(db_url)
4361
# TODO: Alternatively, you can create tables directly without migrations for simpler setups.
4462
# create_db_and_tables(engine)
45-
with Session(engine) as session:
46-
yield session
63+
64+
# Ensure that changes made during tests do not persist and affect other tests using a nested transaction
65+
# This is needed for PostgreSQL since the SQLite is erased after each test by deleting the temp file
66+
connection = engine.connect()
67+
transaction = connection.begin()
68+
session = Session(bind=connection)
69+
70+
nested = connection.begin_nested()
71+
72+
@sa.event.listens_for(session, "after_transaction_end")
73+
def end_savepoint(session, transaction):
74+
nonlocal nested
75+
if not nested.is_active:
76+
nested = connection.begin_nested()
77+
78+
yield session
79+
80+
session.close()
81+
transaction.rollback()
82+
connection.close()
4783

4884
# Clean up
4985
engine.dispose()
50-
os.unlink(path)
86+
if path:
87+
os.unlink(path)
5188

5289

5390
@pytest.fixture(name="client_with_db")

0 commit comments

Comments
 (0)