Skip to content

Commit 37934bf

Browse files
committed
docs: work on text
1 parent 3eb5055 commit 37934bf

File tree

1 file changed

+110
-23
lines changed

1 file changed

+110
-23
lines changed

docs/index.md

Lines changed: 110 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,9 @@ Below is the documentation example of how to use an instance of PostgreSQL, whic
3434

3535
## Context
3636

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"}.
37+
The objective of this repository is to test asynchronous FastAPI endpoints using PostgreSQL as a database. To achieve that, besides the `testcontainers`, it uses [`pytest`](https://docs.pytest.org/en/stable/){:target="\_blank"} and [`anyio`](https://anyio.readthedocs.io/en/stable/testing.html){:target="\_blank"}, which provides built-in support for testing applications in the form of a pytest plugin. The choice of `anyio` over `pytest-asyncio` is because FastAPI is based on Starlette, which uses AnyIO, so we don't need to install an additional package here.
3838

39-
The the development of the routes it uses [aiosqlite](https://aiosqlite.omnilib.dev/en/stable/){:target="\_blank"}, the async driver to SQLite.
39+
The development of the routes uses [aiosqlite](https://aiosqlite.omnilib.dev/en/stable/){:target="\_blank"}, the async driver for SQLite.
4040

4141
Below are all the dependencies used to run the example.
4242

@@ -45,12 +45,11 @@ aiosqlite>=0.20.0
4545
asyncpg>=0.30.0
4646
fastapi[standard]>=0.115.6
4747
pytest>=8.3.4
48-
pytest-asyncio>=0.24.0
4948
sqlalchemy>=2.0.36
5049
testcontainers>=4.8.2
5150
```
5251

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

5554
Below is how the example is structured:
5655

@@ -71,13 +70,13 @@ Below is how the example is structured:
7170

7271
## API routes example
7372

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:
73+
This section will show the endpoints created for later tests. For this example, three simple routes were created to simulate a ticketing sell system:
7574

7675
- `GET /tickets/all` - To list all the available tickets
7776
- `POST /tickets/create` - To create a new ticket to sell
7877
- `POST /tickets/buy` - To buy an available ticket to sell
7978

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.
79+
In the database, besides 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 who the ticket was sold to. The `models.py` file contains this information, using the [`SQLAlchemy`](https://www.sqlalchemy.org/){:target="\_blank"} ORM.
8180

8281
```py title="src/models.py" linenums="1"
8382
from sqlalchemy.orm import Mapped, mapped_column, registry
@@ -124,7 +123,7 @@ async def get_session() -> typing.AsyncGenerator[AsyncSession, None]:
124123
yield session
125124
```
126125

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.
126+
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.
128127

129128
```py title="src/schemas.py" linenums="1"
130129
from pydantic import BaseModel
@@ -153,9 +152,9 @@ class ListTickets(BaseModel):
153152
tickets: list[TicketResponse]
154153
```
155154

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.
155+
The previous three files are imported in `app.py`, which 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 using SQLite to avoid the necessity of having an instance of PostgreSQL running all the time.
157156

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.
157+
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 doesn't exist yet.
159158

160159
```py title="src/app.py" linenums="1" hl_lines="18-24"
161160
from contextlib import asynccontextmanager
@@ -271,9 +270,9 @@ fastapi dev src/app.py
271270

272271
## Tests workflow
273272

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.
273+
To use PostgreSQL in the tests, the testcontainer will be set up in `conftest.py`, along with the database session and the client required to test the endpoints.
275274

276-
Below is a asimple schem of how it will work for each tests, where each block will represent a different function.
275+
Below is a simple diagram illustrating how it works for each test, where each block represents a different function.
277276

278277
```mermaid
279278
flowchart LR
@@ -292,6 +291,7 @@ flowchart LR
292291
293292
%% Arrows from async_client to test blocks
294293
async_client --> test
294+
async_session --> test
295295
296296
%% Style the nodes with rounded corners
297297
classDef fixtureStyle rx:10, ry:10;
@@ -300,14 +300,14 @@ flowchart LR
300300
class postgres_container,async_session,async_client,test fixtureStyle;
301301
```
302302

303-
As we are working with the async fistures, all the funtion will have the decorator `@pytest_asyncio.fixture` assigned.
303+
The `postgres_container` will be passed to `async_session`, which will be used in both `async_client` and directly in the tests in some cases where we need to transact directly with the database.
304304

305305
## Creating the test fixtures
306306

307-
The `postgres_container` function will create the PostgreSQL intsnace and provide the database URL to be connected in the `async_session` funtion.
307+
The first fixture inserted in `conftest.py` is the `anyio_backend`, highlighted in the code below. This function will be used in `postgres_container` and marked for the AnyIO pytest plugin, as well as setting `asyncio` as the backend to run the tests. This function was not included in the previous diagram because it is an AnyIO specification. You can check more details about it [here](https://anyio.readthedocs.io/en/stable/testing.html#specifying-the-backends-to-run-on).
308308

309-
```py title="tests/conftest.py" linenums="1" hl_lines="15-18"
310-
import pytest_asyncio
309+
```py title="tests/conftest.py" linenums="1" hl_lines="15-17"
310+
import pytest
311311
from httpx import ASGITransport, AsyncClient
312312
from sqlalchemy.ext.asyncio import (
313313
AsyncSession,
@@ -321,16 +321,26 @@ from src.database import get_session
321321
from src.models import table_register
322322

323323

324-
@pytest_asyncio.fixture
325-
def postgres_container():
324+
@pytest.fixture
325+
def anyio_backend():
326+
return 'asyncio'
327+
```
328+
329+
Now, in the `postgres_container`, the `anyio_backend` is passed, and all the tests that use the `postgres_container` as a fixture at some level will be marked to run asynchronously.
330+
331+
Below is the `postgres_container` function, which will be responsible for creating the `PostgresContainer` instance from `testcontainers`. The `asyncpg` is passed as an argument in the class to specify that this will be the driver used.
332+
333+
```py title="tests/conftest.py" linenums="20"
334+
@pytest.fixture
335+
def postgres_container(anyio_backend):
326336
with PostgresContainer('postgres:16', driver='asyncpg') as postgres:
327337
yield postgres
328338
```
329339

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.
340+
The `async_session` takes the connection URL from the `PostgresContainer` object returned by the `postgres_container` function and uses it to create the tables inside the database, as well as the session that will establish all interactions with the PostgreSQL instance created. The function will return and persist a session to be used, and then restore the database for the next test by deleting the tables.
331341

332-
```py title="tests/conftest.py" linenums="21"
333-
@pytest_asyncio.fixture
342+
```py title="tests/conftest.py" linenums="26"
343+
@pytest.fixture
334344
async def async_session(postgres_container: PostgresContainer):
335345
async_db_url = postgres_container.get_connection_url()
336346
async_engine = create_async_engine(async_db_url, pool_pre_ping=True)
@@ -352,10 +362,10 @@ async def async_session(postgres_container: PostgresContainer):
352362
await async_engine.dispose()
353363
```
354364

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.
365+
The last fixture is the `async_client` function, which will create the [`AsyncClient`](https://fastapi.tiangolo.com/advanced/async-tests/), directly imported from [HTTPX](https://www.python-httpx.org/), and provide it to make requests to our endpoints. Here, the session provided by `async_session` will override the session originally used in our app as dependency injection while the client is being used.
356366

357-
```py title="tests/conftest.py" linenums="43"
358-
@pytest_asyncio.fixture
367+
```py title="tests/conftest.py" linenums="48"
368+
@pytest.fixture
359369
async def async_client(async_session: async_sessionmaker[AsyncSession]):
360370
app.dependency_overrides[get_session] = lambda: async_session
361371
_transport = ASGITransport(app=app)
@@ -370,8 +380,85 @@ async def async_client(async_session: async_sessionmaker[AsyncSession]):
370380

371381
## Running the tests
372382

383+
With all the test fixtures created, it's now possible to write and run the tests.
384+
385+
Below are the test examples for the `GET /tickets/all`. The first one inserts 3 records in the table using the `async_session` and then asserts if the response has a 200 status and the list returned has a length of 3. The second one tests the case where there are no records yet in the database, as the response must return a 200 status and an empty list.
386+
387+
```py title="tests/test_routes.py" linenums="1"
388+
from http import HTTPStatus
389+
390+
from httpx import AsyncClient
391+
from sqlalchemy.ext.asyncio import AsyncSession
392+
393+
from src.models import Ticket
394+
395+
396+
async def test_get_all_tickets_success(
397+
async_session: AsyncSession, async_client: AsyncClient
398+
):
399+
ticket_data_list = [
400+
{'price': 100, 'is_sold': False, 'sold_to': None},
401+
{'price': 200, 'is_sold': True, 'sold_to': 'Buyer1'},
402+
{'price': 150, 'is_sold': False, 'sold_to': None},
403+
]
404+
405+
expected_len = len(ticket_data_list)
406+
407+
tickets = [Ticket(**data) for data in ticket_data_list]
408+
409+
async with async_session.begin():
410+
async_session.add_all(tickets)
411+
await async_session.commit()
412+
413+
response = await async_client.get('/tickets/all')
414+
415+
assert response.status_code == HTTPStatus.OK
416+
assert len(response.json()['tickets']) == expected_len
417+
418+
419+
async def test_get_all_tickets_when_empty(async_client: AsyncClient):
420+
response = await async_client.get('/tickets/all')
421+
422+
assert response.status_code == HTTPStatus.OK
423+
assert response.json()['tickets'] == []
424+
```
425+
426+
In total there are 6 test, and the rest of them has the same logic. Their full implementations can be checked in the repository.
427+
428+
Now, if we run the following command in the terminal...
429+
430+
```bash
431+
pytest -vv
432+
```
433+
434+
...we will see something similar to this:
435+
436+
```
437+
tests/test_routes.py::test_get_all_tickets_success PASSED [ 16%]
438+
tests/test_routes.py::test_get_all_tickets_when_empty PASSED [ 33%]
439+
tests/test_routes.py::test_create_ticket_success PASSED [ 50%]
440+
tests/test_routes.py::test_buy_ticket_success PASSED [ 66%]
441+
tests/test_routes.py::test_buy_ticket_when_ticket_not_found PASSED [ 83%]
442+
tests/test_routes.py::test_buy_ticket_when_already_sold PASSED [100%]
443+
444+
============================================= 6 passed in 12.31s =============================================
445+
```
446+
447+
Although all the tests are very simple, it took an average of more than two seconds for each one of them to execute.
448+
373449
## The pytest scope
374450

451+
```
452+
tests/test_routes.py::test_get_all_tickets_success PASSED [ 16%]
453+
tests/test_routes.py::test_get_all_tickets_when_empty PASSED [ 33%]
454+
tests/test_routes.py::test_create_ticket_success PASSED [ 50%]
455+
tests/test_routes.py::test_buy_ticket_success PASSED [ 66%]
456+
tests/test_routes.py::test_buy_ticket_when_ticket_not_found PASSED [ 83%]
457+
tests/test_routes.py::test_buy_ticket_when_already_sold PASSED [100%]
458+
459+
============================================= 6 passed in 3.94s =============================================
460+
```
461+
375462
## Final `conftest.py`
376463

377464
## Conclusion

0 commit comments

Comments
 (0)