You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Copy file name to clipboardExpand all lines: docs/index.md
+310-9Lines changed: 310 additions & 9 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -9,7 +9,7 @@ hide:
9
9
10
10
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.
11
11
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"}.
13
13
14
14
TL;DR: The full `conftest.py` setup is available [here](Add link here).
15
15
@@ -32,9 +32,11 @@ Below is the documentation example of how to use an instance of PostgreSQL, whic
32
32
'PostgreSQL 16...'
33
33
```
34
34
35
-
## Example setup
35
+
## Context
36
36
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.
38
40
39
41
Below are all the dependencies used to run the example.
40
42
@@ -48,9 +50,9 @@ sqlalchemy>=2.0.36
48
50
testcontainers>=4.8.2
49
51
```
50
52
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"}.
52
54
53
-
The example repository is structured in the following way:
55
+
Below is how the example is structured:
54
56
55
57
```yaml
56
58
.
@@ -69,10 +71,309 @@ The example repository is structured in the following way:
69
71
70
72
## API routes example
71
73
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
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.
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
+
classTicketBase(BaseModel):
134
+
price: int
135
+
is_sold: bool=False
136
+
sold_to: str|None=None
137
+
138
+
139
+
classTicketResponse(TicketBase):
140
+
id: int
141
+
142
+
143
+
classTicketRequestCreate(TicketBase):
144
+
pass
145
+
146
+
147
+
classTicketRequestBuy(BaseModel):
148
+
ticket_id: int
149
+
user: str
150
+
151
+
152
+
classListTickets(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.
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.
status_code=HTTPStatus.NOT_FOUND, detail='Ticket was not found'
235
+
)
236
+
237
+
asyncwith 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
+
asyncwith 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.
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
+
defpostgres_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.
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.
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'))
0 commit comments