Skip to content

Commit c011b70

Browse files
authored
feat: pytest plugin (#11)
1 parent 100acc3 commit c011b70

9 files changed

Lines changed: 213 additions & 24 deletions

File tree

README.md

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,77 @@ router = APIRouter()
4545
def example(session: Session = Depends(with_session)):
4646
return session.execute("SELECT now()").scalar()
4747
```
48+
49+
## Pytest fixtures
50+
This library provides a set of utility fixtures, through its PyTest plugin, which is
51+
automatically installed with the library.
52+
53+
By default, no records are actually written to the database when running tests.
54+
There currently is no way to change this behaviour.
55+
56+
### `sqla_modules`
57+
58+
You must define this fixture, in order for the plugin to reflect table metadata in your
59+
SQLAlchemy entities. It should just import all of the application's modules which contain
60+
SQLAlchemy models.
61+
62+
Example:
63+
64+
```python
65+
# tests/conftest.py
66+
from pytest import fixture
67+
68+
69+
@fixture
70+
def sqla_modules():
71+
from er import sqla # noqa
72+
```
73+
74+
### `db_url`
75+
76+
The DB url to use.
77+
78+
When `CI` key is set in environment variables, it defaults to using `postgres` as the host name:
79+
80+
```
81+
postgresql://postgres@posgres/postgres
82+
```
83+
84+
In other cases, the host is set to `localhost`:
85+
86+
```
87+
postgresql://postgres@localhost/postgres
88+
```
89+
90+
Of course, you can override it by overloading the fixture:
91+
92+
```python
93+
from pytest import fixture
94+
95+
96+
@fixture(scope="session")
97+
def db_url():
98+
return "postgresql://postgres@localhost/test_database"
99+
```
100+
101+
102+
### `session`
103+
104+
Sqla session to create db fixture:
105+
* All changes done at test setiup or during the test are rollbacked at test tear down;
106+
* No record will actually be written in the database;
107+
* Changes in one session need to be committed to be available from other sessions;
108+
109+
Example:
110+
```python
111+
from pytest import fixture
112+
113+
114+
@fixture
115+
def patient(session):
116+
from er.sqla import Patient
117+
patient = Patient(first_name="Bob", last_name="David")
118+
session.add(patient)
119+
session.commit()
120+
return patient
121+
```

fastapi_sqla/_pytest_plugin.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import os
2+
from unittest.mock import patch
3+
4+
from pytest import fixture
5+
from sqlalchemy import create_engine
6+
7+
8+
@fixture(scope="session")
9+
def db_url():
10+
"""Default db url used by depending fixtures.
11+
12+
When CI key is set in environment variables, it uses `postgres` as host name:
13+
postgresql://postgres@posgres/postgres
14+
15+
Else, host used is `localhost`: postgresql://postgres@localhost/postgres
16+
"""
17+
host = "postgres" if "CI" in os.environ else "localhost"
18+
return f"postgresql://postgres@{host}/postgres"
19+
20+
21+
@fixture(scope="session")
22+
def sqla_connection(db_url):
23+
engine = create_engine(db_url)
24+
connection = engine.connect()
25+
yield connection
26+
connection.close()
27+
28+
29+
@fixture
30+
def sqla_modules():
31+
raise Exception(
32+
"sqla_modules fixture is not defined. Define a sqla_modules fixture which "
33+
"imports all modules with sqla entities deriving from fastapi_sqla.Base ."
34+
)
35+
36+
37+
@fixture(autouse=True)
38+
def sqla_reflection(sqla_modules, sqla_connection, db_url):
39+
import fastapi_sqla
40+
41+
fastapi_sqla.Base.metadata.bind = sqla_connection
42+
fastapi_sqla.Base.prepare(sqla_connection)
43+
44+
45+
@fixture(autouse=True)
46+
def patch_sessionmaker(db_url, sqla_connection, sqla_transaction):
47+
"""So that all DB operations are never written to db for real."""
48+
with patch("fastapi_sqla.engine_from_config") as engine_from_config:
49+
engine_from_config.return_value = sqla_connection
50+
yield engine_from_config
51+
52+
53+
@fixture
54+
def sqla_transaction(sqla_connection):
55+
transaction = sqla_connection.begin()
56+
yield transaction
57+
transaction.rollback()
58+
59+
60+
@fixture
61+
def session(sqla_transaction, sqla_connection):
62+
"""Sqla session to use when creating db fixtures.
63+
64+
While it does not write any record in DB, the application will still be able to access any record
65+
committed with that session.
66+
"""
67+
import fastapi_sqla
68+
69+
session = fastapi_sqla._Session(bind=sqla_connection)
70+
yield session
71+
session.close()

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,6 @@ build-backend = "poetry.masonry.api"
3131
version_variable = "pyproject.toml:version"
3232
upload_to_pypi = false
3333
commit_message = "Version generated by python-semantic-release [ci skip]"
34+
35+
[tool.poetry.plugins."pytest11"]
36+
fastapi_sqla = "fastapi_sqla._pytest_plugin"

setup.cfg

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ ext = .py,.yaml,.cfg,.yml
1414

1515
[coverage:run]
1616
branch = True
17-
omit = tests/*,.venv/*
17+
omit = tests/*,.venv/*,fastapi_sqla/_pytest_plugin.py
1818

1919
[coverage:report]
2020
skip_covered = true

tests/conftest.py

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,24 @@
11
import importlib
2-
import os
32
from unittest.mock import patch
43

54
from pytest import fixture
5+
from sqlalchemy import engine_from_config
66
from sqlalchemy.orm.session import close_all_sessions
77

88

9-
@fixture(scope="session")
10-
def db_uri():
11-
host = "postgres" if "CIRCLECI" in os.environ else "localhost"
12-
return f"postgresql://postgres@{host}/postgres"
13-
14-
15-
@fixture(autouse=True)
16-
def environ(db_uri):
17-
values = {"sqlalchemy_url": db_uri}
9+
@fixture(scope="session", autouse=True)
10+
def environ(db_url):
11+
values = {"sqlalchemy_url": db_url}
1812
with patch.dict("os.environ", values=values, clear=True):
1913
yield values
2014

2115

16+
@fixture(scope="session")
17+
def engine(environ):
18+
engine = engine_from_config(environ, prefix="sqlalchemy_")
19+
return engine
20+
21+
2222
@fixture(autouse=True)
2323
def tear_down():
2424
import fastapi_sqla
@@ -28,3 +28,8 @@ def tear_down():
2828
close_all_sessions()
2929
# reload fastapi_sqla to clear sqla deferred reflection mapping stored in Base
3030
importlib.reload(fastapi_sqla)
31+
32+
33+
@fixture
34+
def sqla_modules():
35+
pass

tests/test_base.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,14 @@
11
from pytest import fixture, raises
22
from sqlalchemy import engine_from_config
33
from sqlalchemy.exc import NoSuchTableError
4-
from sqlalchemy.orm.session import close_all_sessions
54

65

7-
@fixture(autouse=True)
6+
@fixture(autouse=True, scope="module")
87
def setup_tear_down(environ):
98
engine = engine_from_config(environ, prefix="sqlalchemy_")
109

1110
engine.execute("CREATE TABLE IF NOT EXISTS test_table (id integer primary key)")
1211
yield
13-
close_all_sessions()
1412
engine.execute("DROP TABLE test_table")
1513

1614

tests/test_middleware.py

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,12 @@
55
from fastapi import Depends, FastAPI
66
from pydantic import BaseModel
77
from pytest import fixture, mark
8-
from sqlalchemy import engine_from_config
9-
from sqlalchemy.orm.session import close_all_sessions
108
from structlog.testing import capture_logs
119

1210
pytestmark = mark.asyncio
1311

1412

15-
@fixture
16-
def engine(environ):
17-
engine = engine_from_config(environ, prefix="sqlalchemy_")
18-
return engine
19-
20-
21-
@fixture(autouse=True)
13+
@fixture(scope="module", autouse=True)
2214
def setup_tear_down(engine):
2315
engine.execute(
2416
"""
@@ -30,7 +22,6 @@ def setup_tear_down(engine):
3022
"""
3123
)
3224
yield
33-
close_all_sessions()
3425
engine.execute("DROP TABLE public.user")
3526

3627

tests/test_pytest_plugin.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
from pytest import fixture
2+
3+
4+
@fixture(scope="module", autouse=True)
5+
def setup_tear_down(engine):
6+
engine.execute(
7+
"""
8+
CREATE TABLE IF NOT EXISTS singer (
9+
id integer primary key,
10+
name varchar,
11+
country varchar
12+
)
13+
"""
14+
)
15+
yield
16+
engine.execute("DROP TABLE singer")
17+
18+
19+
@fixture
20+
def singer_cls():
21+
from fastapi_sqla import Base
22+
23+
class Singer(Base):
24+
__tablename__ = "singer"
25+
26+
return Singer
27+
28+
29+
@fixture
30+
def sqla_modules(singer_cls):
31+
pass
32+
33+
34+
def test_session_fixture_does_not_write_in_db(session, singer_cls, engine):
35+
session.add(singer_cls(id=1, name="Bob Marley", country="Jamaica"))
36+
session.commit()
37+
assert engine.execute("select count(*) from singer").scalar() == 0
38+
39+
40+
def test_all_opened_sessions_are_within_the_same_transaction(session, singer_cls):
41+
from fastapi_sqla import _Session
42+
43+
session.add(singer_cls(id=1, name="Bob Marley", country="Jamaica"))
44+
session.commit()
45+
46+
other_session = _Session()
47+
assert other_session.query(singer_cls).get(1)

0 commit comments

Comments
 (0)