Skip to content

Commit 6404d7d

Browse files
committed
docs: working on the text
1 parent 9038a28 commit 6404d7d

File tree

2 files changed

+315
-9
lines changed

2 files changed

+315
-9
lines changed

docs/index.md

Lines changed: 310 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ hide:
99

1010
When I first learned about [testcontainers](https://testcontainers.com/){:target="\_blank"}, I wanted to know how to integrate it with [`asyncpg`](https://magicstack.github.io/asyncpg/current/){:target="\_blank"}, an asynchronous driver for PostgreSQL. At first look, I came across [this text](https://www.linkedin.com/pulse/utilizando-testcontainers-fastapi-guilherme-de-carvalho-carneiro-9cmlf/){:target="\_blank"} from [Guilherme](https://www.linkedin.com/in/guilhermecarvalho/){:target="\_blank"}, who worked on it instantly. Based on that, I decided to write this simple repository with an example application.
1111

12-
You can check the complete repository [here](https://github.com/lealre/fastapi-testcontainer-asyncpg).
12+
You can check the complete repository [here](https://github.com/lealre/fastapi-testcontainer-asyncpg){:target="\_blank"}.
1313

1414
TL;DR: The full `conftest.py` setup is available [here](Add link here).
1515

@@ -32,9 +32,11 @@ Below is the documentation example of how to use an instance of PostgreSQL, whic
3232
'PostgreSQL 16...'
3333
```
3434

35-
## Example setup
35+
## Context
3636

37-
The routes of the example will be written using FastAPI and [aiosqlite](https://aiosqlite.omnilib.dev/en/stable/), the async driver to SQLite. To the tests it uses [pytest](https://docs.pytest.org/en/stable/) and [pytest-asyncio](https://pytest-asyncio.readthedocs.io/en/latest/).
37+
The objective of this repository is to test asynchronous FastAPI endpoints using an PostgesQL with an asyncrhounous driver. To achive that, besides the testconteiner, it uses [pytest](https://docs.pytest.org/en/stable/){:target="\_blank"} and [pytest-asyncio](https://pytest-asyncio.readthedocs.io/en/latest/){:target="\_blank"}.
38+
39+
The the development of the routes it uses [aiosqlite](https://aiosqlite.omnilib.dev/en/stable/){:target="\_blank"}, the async driver to SQLite.
3840

3941
Below are all the dependencies used to run the example.
4042

@@ -48,9 +50,9 @@ sqlalchemy>=2.0.36
4850
testcontainers>=4.8.2
4951
```
5052

51-
The README contains all the steps to run it locally using [uv](https://docs.astral.sh/uv/).
53+
The README contains all the steps to run it locally using [uv](https://docs.astral.sh/uv/){:target="\_blank"}.
5254

53-
The example repository is structured in the following way:
55+
Below is how the example is structured:
5456

5557
```yaml
5658
.
@@ -69,10 +71,309 @@ The example repository is structured in the following way:
6971

7072
## API routes example
7173

72-
To build the API
74+
This section will show the endpoints create for later tests. For this example it was created 3 simple routes simulating a ticketing sell system:
75+
76+
- `GET /tickets/all` - To list all the available tickets
77+
- `POST /tickets/create` - To create a new ticket to sell
78+
- `POST /tickets/buy` - To buy an available ticket to sell
79+
80+
In the database, beside the `id` field, the ticket table has: a `price` field; a boolean field `is_sold` to identify if it's sold or not; and a `sold_to` field to identify to who was sold the ticket. The `models.py` file contains these informations, using the [`SQLAlchemy`](https://www.sqlalchemy.org/){:target="\_blank"} ORM.
81+
82+
```py title="src/models.py" linenums="1"
83+
from sqlalchemy.orm import Mapped, mapped_column, registry
84+
85+
table_register = registry()
86+
87+
88+
@table_register.mapped_as_dataclass
89+
class Ticket:
90+
__tablename__ = 'tickets'
91+
92+
id: Mapped[int] = mapped_column(init=False, primary_key=True)
93+
price: Mapped[int]
94+
is_sold: Mapped[bool] = mapped_column(default=False)
95+
sold_to: Mapped[str] = mapped_column(nullable=True, default=None)
96+
```
97+
98+
The `database.py` contains the connection with the database, as also the `get_session()` generator, responsible for creating asynchronous sessions for performing transactions in the database.
99+
100+
```python title="src/database.py" linenums="1"
101+
import typing
102+
103+
from sqlalchemy.ext.asyncio import (
104+
AsyncSession,
105+
async_sessionmaker,
106+
create_async_engine,
107+
)
108+
109+
DATABASE_URL = 'sqlite+aiosqlite:///db.sqlite3'
110+
111+
engine = create_async_engine(DATABASE_URL, future=True, echo=True)
112+
113+
AsyncSessionLocal = async_sessionmaker(
114+
autocommit=False,
115+
expire_on_commit=False,
116+
autoflush=True,
117+
bind=engine,
118+
class_=AsyncSession,
119+
)
120+
121+
122+
async def get_session() -> typing.AsyncGenerator[AsyncSession, None]:
123+
async with AsyncSessionLocal() as session:
124+
yield session
125+
```
126+
127+
The last file before to create the routes is the `schemas.py`, that will contain all the [pydantic](https://docs.pydantic.dev/latest/){:target="\_blank"} models.
128+
129+
```py title="src/schemas.py" linenums="1"
130+
from pydantic import BaseModel
131+
132+
133+
class TicketBase(BaseModel):
134+
price: int
135+
is_sold: bool = False
136+
sold_to: str | None = None
137+
138+
139+
class TicketResponse(TicketBase):
140+
id: int
141+
142+
143+
class TicketRequestCreate(TicketBase):
144+
pass
145+
146+
147+
class TicketRequestBuy(BaseModel):
148+
ticket_id: int
149+
user: str
150+
151+
152+
class ListTickets(BaseModel):
153+
tickets: list[TicketResponse]
154+
```
155+
156+
The previous three files are imported in `app.py`, that contains the API routes for this example. As mentioned before, although the objective is to test the endpoints in a PostgreSQL database, the development of the API is done by using SQLite to avoid the necessity to have an instance of PostgreSQL running all the time.
157+
158+
To keep it simple and avoid using database migrations, the database creation is handled using the [lifespan events](https://fastapi.tiangolo.com/advanced/events/){:target="\_blank"}. Here, it will just guarantee that every time we run the application, a database will be created in case it's not yet.
159+
160+
```py title="src/app.py" linenums="1" hl_lines="18-24"
161+
from contextlib import asynccontextmanager
162+
from http import HTTPStatus
163+
from typing import Annotated
164+
165+
from fastapi import Depends, FastAPI, HTTPException
166+
from sqlalchemy import and_, select, update
167+
168+
from src.database import AsyncSession, engine, get_session
169+
from src.models import Ticket, table_register
170+
from src.schemas import (
171+
ListTickets,
172+
TicketRequestBuy,
173+
TicketRequestCreate,
174+
TicketResponse,
175+
)
176+
177+
178+
@asynccontextmanager
179+
async def lifespan(app: FastAPI):
180+
async with engine.begin() as conn:
181+
await conn.run_sync(table_register.metadata.create_all)
182+
yield
183+
await engine.dispose()
184+
185+
186+
app = FastAPI(lifespan=lifespan)
187+
```
188+
189+
Below are the routes implementation, as also the `SessionDep` to be used as [dependency injection](https://fastapi.tiangolo.com/tutorial/dependencies/){:target="\_blank"} in each route.
190+
191+
```py title="src/app.py" linenums="27" hl_lines="3"
192+
...
193+
194+
SessionDep = Annotated[AsyncSession, Depends(get_session)]
195+
196+
197+
@app.get('/tickets/all', response_model=ListTickets)
198+
async def get_all_tickets(session: SessionDep):
199+
async with session.begin():
200+
tickets = await session.scalars(select(Ticket))
201+
202+
all_tickets = tickets.all()
203+
204+
return {'tickets': all_tickets}
205+
206+
207+
@app.post(
208+
'/tickets/create',
209+
response_model=TicketResponse,
210+
status_code=HTTPStatus.CREATED,
211+
)
212+
async def create_ticket(session: SessionDep, ticket_in: TicketRequestCreate):
213+
new_ticket = Ticket(**ticket_in.model_dump())
214+
215+
async with session.begin():
216+
session.add(new_ticket)
217+
await session.commit()
218+
219+
async with session.begin():
220+
await session.refresh(new_ticket)
221+
222+
return new_ticket
223+
224+
225+
@app.post('/tickets/buy', response_model=TicketResponse)
226+
async def get_ticket_by_id(session: SessionDep, ticket_in: TicketRequestBuy):
227+
async with session.begin():
228+
ticket_db = await session.scalar(
229+
select(Ticket).where(Ticket.id == ticket_in.ticket_id)
230+
)
231+
232+
if not ticket_db:
233+
raise HTTPException(
234+
status_code=HTTPStatus.NOT_FOUND, detail='Ticket was not found'
235+
)
236+
237+
async with session.begin():
238+
stm = (
239+
update(Ticket)
240+
.where(
241+
and_(
242+
Ticket.id == ticket_in.ticket_id,
243+
Ticket.is_sold == False, # noqa: E712
244+
)
245+
)
246+
.values(is_sold=True, sold_to=ticket_in.user)
247+
)
248+
249+
ticket_updated = await session.execute(stm)
250+
251+
if ticket_updated.rowcount == 0:
252+
raise HTTPException(
253+
status_code=HTTPStatus.CONFLICT,
254+
detail='Ticket has already been sold',
255+
)
256+
257+
await session.commit()
258+
259+
async with session.begin():
260+
await session.refresh(ticket_db)
261+
262+
return ticket_db
263+
264+
```
265+
266+
Now, running the command below in the terminal, the application should be available at `http://127.0.0.1:8000`.
267+
268+
```bash
269+
fastapi dev src/app.py
270+
```
271+
272+
## Tests workflow
273+
274+
To use PostgeSQL in the tests, the testcontainer will be setup in the `conftest.py`, as also the database session and the client to be used to test our endpoints.
275+
276+
Below is a asimple schem of how it will work for each tests, where each block will represent a different function.
277+
278+
```mermaid
279+
flowchart LR
280+
%% Nodes for fixtures
281+
postgres_container["postgres_container"]
282+
async_session["async_session"]
283+
async_client["async_client"]
284+
test["Test"]
285+
286+
%% Subgraph for dependencies
287+
subgraph Fixtures in conftest.py
288+
direction LR
289+
postgres_container --> async_session
290+
async_session --> async_client
291+
end
292+
293+
%% Arrows from async_client to test blocks
294+
async_client --> test
295+
296+
%% Style the nodes with rounded corners
297+
classDef fixtureStyle rx:10, ry:10;
298+
299+
%% Style the nodes
300+
class postgres_container,async_session,async_client,test fixtureStyle;
301+
```
302+
303+
As we are working with the async fistures, all the funtion will have the decorator `@pytest_asyncio.fixture` assigned.
304+
305+
## Creating the test fixtures
306+
307+
The `postgres_container` function will create the PostgreSQL intsnace and provide the database URL to be connected in the `async_session` funtion.
308+
309+
```py title="tests/conftest.py" linenums="1" hl_lines="15-18"
310+
import pytest_asyncio
311+
from httpx import ASGITransport, AsyncClient
312+
from sqlalchemy.ext.asyncio import (
313+
AsyncSession,
314+
async_sessionmaker,
315+
create_async_engine,
316+
)
317+
from testcontainers.postgres import PostgresContainer
318+
319+
from src.app import app
320+
from src.database import get_session
321+
from src.models import table_register
322+
323+
324+
@pytest_asyncio.fixture
325+
def postgres_container():
326+
with PostgresContainer('postgres:16', driver='asyncpg') as postgres:
327+
yield postgres
328+
```
329+
330+
In `async_session`, it takes the url connection from the PostgresContainer object, and use it to cretae the tables inside the Database, as also the session that will establishe all conversations with the POstgreSQl instance created. The function will persist this session to be used in `async_client`, and them restarte the database to the next test.
331+
332+
```py title="tests/conftest.py" linenums="21"
333+
@pytest_asyncio.fixture
334+
async def async_session(postgres_container: PostgresContainer):
335+
async_db_url = postgres_container.get_connection_url()
336+
async_engine = create_async_engine(async_db_url, pool_pre_ping=True)
337+
338+
async with async_engine.begin() as conn:
339+
await conn.run_sync(table_register.metadata.drop_all)
340+
await conn.run_sync(table_register.metadata.create_all)
341+
342+
async_session = async_sessionmaker(
343+
autoflush=False,
344+
bind=async_engine,
345+
class_=AsyncSession,
346+
expire_on_commit=False,
347+
)
348+
349+
async with async_session() as session:
350+
yield session
351+
352+
await async_engine.dispose()
353+
```
354+
355+
The `async_client` function will create the [`AsyncClient`](https://fastapi.tiangolo.com/advanced/async-tests/), directly importted from [`HTTPX`](https://www.python-httpx.org/), and provide it to get reqeusts from our endpoints. Here, the session provided by `async_session` wil overrides the session originally used in our app as dependencie injection while the client is being used.
356+
357+
```py title="tests/conftest.py" linenums="43"
358+
@pytest_asyncio.fixture
359+
async def async_client(async_session: async_sessionmaker[AsyncSession]):
360+
app.dependency_overrides[get_session] = lambda: async_session
361+
_transport = ASGITransport(app=app)
362+
363+
async with AsyncClient(
364+
transport=_transport, base_url='http://test', follow_redirects=True
365+
) as client:
366+
yield client
367+
368+
app.dependency_overrides.clear()
369+
```
370+
371+
## Running the tests
372+
373+
## The pytest scope
73374

74-
## Tests with `pytest`
375+
## Final `conftest.py`
75376

76-
### Conftest setup
377+
## Conclusion
77378

78-
### Writing and runnig hte tests
379+
ERROR tests/test_routes.py::test_get_all_tickets_success - docker.errors.DockerException: Error while fetching server API version: ('Connection aborted.', FileNotFoundError(2, 'No such file or directory'))

mkdocs.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,8 @@ markdown_extensions:
3131
- attr_list
3232
- md_in_html
3333
- pymdownx.superfences
34+
- pymdownx.superfences:
35+
custom_fences:
36+
- name: mermaid
37+
class: mermaid
38+
format: !!python/name:pymdownx.superfences.fence_code_format

0 commit comments

Comments
 (0)