Demo application
The code repository that forms the basis of this post is public and can be found at https://github.com/Weird-Sheep-Labs/fastapi-pytest-asyncio-demo. The demo application is a simple CRUD API to interact with a Song
database model. The structure of the repo follows the src
layout, with tests in a separate directory:
1 | |
2 | |
3 | |
4 | |
5 | |
6 | |
7 | |
8 | |
9 | |
10 | |
11 | |
12 | |
13 | |
14 | |
15 | |
16 | |
17 | |
18 | |
19 | |
20 | |
21 | |
We'll first highlight some of the most relevant sections from the application code first, before going over the main meat of the topic, the test setup, in more detail.
Application code
In order to take advantage of FastAPI's asynchronous capabilities, we need to interact with I/O and make network requests using async libraries - a typical example of this would be the database driver. We connect to the Postgres DB using asyncpg, a highly performant, async DB driver for Python and Postgres, by specifying the driver in the database connection string in a .env
at the root of project:
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/fastapi-pytest-asyncio-demo
We then configure the DB engine and session in src/db.py:
1 | |
2 | |
3 | |
4 | |
5 | |
6 | |
7 | |
8 | |
9 | |
10 | |
11 | |
12 | |
13 | |
14 | |
15 | |
16 | |
17 | |
18 | |
19 | |
20 | |
21 | |
22 | |
We use methods from SQLAlchemy's asyncio
extension to create the DB engine and session, very similar to how this would be done in a sync context. Note that not only is get_session()
now defined as an async method, but the context manager that yields the session is also async, which allows any failed transactions to be implicitly handled in the __aexit__()
context manager method.
SQLModel
As we are using SQLModel for the ORM, we specify class_=AsyncSession
when calling the session maker otherwise the returned session class would be an instance of the default sqlalchemy.ext.asyncio.AsyncSession
. SQLModel brings significant benefits over SQLAlchemy when used within a FastAPI application, largely due to the fact a sqlmodel.BaseModel
is a subclass of both sqlalchemy.BaseModel
and pydantic.BaseModel
! This avoids having to define models twice - once for the DB model itself and once as a request payload validation model.
Test setup
As the path operators (i.e router methods) of our application are async functions, we need to await
them in our tests; pytest-asyncio is what allows us to do this.
Configuration
In order to achieve maximum test isolation, we choose to scope the fixtures, tests and event loop at the function level by specifying asyncio_default_fixture_loop_scope = "function"
in the pyproject.toml
at the root of the project. The demo repository includes a class_scope
branch with the test setup configured to be scoped at the class level. This can result in a faster-running test suite as the test DB does not have to be setup and torn down after each test. Additionally, it can also be useful if individual tests are necessarily coupled via shared dependencies, however, this coupling can also lead to hard-to-debug errors within the test code itself.
Fixtures
The tests/conftest.py
file contains the async engine, session and client fixtures that are available to use in the tests:
1 | |
2 | |
3 | |
4 | |
5 | |
6 | |
7 | |
8 | |
9 | |
10 | |
11 | |
12 | |
13 | |
14 | |
15 | |
16 | |
17 | |
18 | |
19 | |
20 | |
21 | |
22 | |
23 | |
24 | |
25 | |
26 | |
27 | |
28 | |
29 | |
30 | |
31 | |
32 | |
33 | |
34 | |
35 | |
36 | |
37 | |
38 | |
39 | |
40 | |
41 | |
42 | |
43 | |
44 | |
45 | |
46 | |
47 | |
48 | |
49 | |
50 | |
51 | |
52 | |
53 | |
54 | |
55 | |
56 | |
57 | |
58 | |
59 | |
We define an async engine similar to that in the application code itself, however, due to the fact that we have scoped the test setup at the function level, we disable connection pooling with poolclass=NullPool
. The engine is wrapped in an async fixture which creates the DB tables and drops them before and after each test function, allowing for maximum test isolation.
The DB session fixture is defined similarly; beginning a session before each test, and rolling it back after. Using FastAPI's dependency injection mechanism, we then provide the session to an httpx async client, overriding the get_session()
method from the application. We can then use this client to make HTTP calls to the API endpoints and make assertions about their responses:
1 | |
2 | |
3 | |
4 | |
5 | |
6 | |
7 | |
8 | |
9 | |
10 | |
Conclusion
Testing is a critical component of building systems that are reliable and resistant to the inadvertent introduction of regressions. In this post, we have shown how a FastAPI application can be set up with asynchronous tests using pytest and pytest-asyncio, allowing for faster iteration and development velocity.
At Weird Sheep Labs, we have built APIs for multiple clients that use the technologies mentioned in this post. If this sounds like something your company could benefit from, don't hesitate to get in touch!