Testing#
Restly ships a pytest plugin and two test clients. You build your application
against a test database in conftest.py, and then call
fr.testing.configure_tests()
once. After that, your tests only talk to your API. The plugin gives every
test four things:
One database configuration.
configure_tests()records the session sources that your application configured. It does not create or replace them. If the database configuration changes later, while pytest collects the tests or while the app’s lifespan starts, the suite fails. So the schema setup, the cleanup and the requests always use the same database. You must still choose a test database that the tests are allowed to change.A schema that is already there. The tables are created once, before the first test: from your models with
create_all=True, or with your migrations withalembic_upgrade=True.A clean database for each test. By default, everything that a test writes is rolled back when the test ends. No test sees the rows of another test, and the order of the tests does not matter. You write no teardown code, and nothing rebuilds the database between tests, so the suite stays fast as it grows. Choose how tests clean up describes the other modes.
Clients connected to your app.
restly_clientsends requests from ordinarydeftests.restly_async_clientsends them fromasync deftests. Both run the app’s lifespan, and the default cleanup rolls back their requests with everything else.
Set up the test suite#
Install the testing extra:
pip install "fastapi-restly[testing]"
The testing extra does not depend on standard, which is only for running
the app. Neither one installs a database driver, because Restly does not
choose one for you. So install the driver that your test database needs as
well. The examples below use SQLite through aiosqlite:
pip install aiosqlite
The package registers a pytest11 entry point. So when the testing extra is
installed, pytest loads the Restly fixtures by itself. If your project turns off plugin autoloading, register
the plugin in conftest.py:
pytest_plugins = ["fastapi_restly.pytest_fixtures"]
The application still owns its database configuration. To let a test choose
the database, build the application in a factory: a create_app() function
that receives its settings, configures Restly, and returns the app. The
factory keeps the database setup out of the module import, so a test can
build the application that it needs. It does not have to change the
environment before an import.
# app/main.py
import os
import fastapi_restly as fr
from fastapi import FastAPI
from .tasks.views import TaskView
VIEWS = (TaskView,)
def create_app(database_url: str | None = None) -> FastAPI:
fr.configure(
async_database_url=database_url
or os.environ.get("DATABASE_URL", "sqlite+aiosqlite:///./app.db")
)
app = FastAPI()
for view in VIEWS:
fr.include_view(app, view)
return app
Run the app shows how a server runs this factory. The
test suite builds its own application with the test database, and passes it
to configure_tests():
# conftest.py
import fastapi_restly as fr
from app.main import create_app
from app.models import Base
app = create_app("sqlite+aiosqlite:///./test.db")
fr.testing.configure_tests(
app=app,
base=Base,
create_all=True,
)
An application that calls fr.configure() when its module is imported can
still be tested. Choose the database before you import the module, and keep
the import order:
# conftest.py
import os
os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///./test.db"
import fastapi_restly as fr
from app.main import app # noqa: E402
from app.models import Base # noqa: E402
fr.testing.configure_tests(app=app, base=Base, create_all=True)
A .env file is useful in development, when the settings class reads one.
The production template does not; the
SaaS example
does. Keep it out of the test suite. When the conftest builds its own
settings, turn the file off (with pydantic-settings:
Settings(database_url=..., _env_file=None)). The values that you pass
explicitly already win over the file. But a value that the test does not
set, such as a pool size, would still come from the local .env of a
developer, and the suite would then behave differently on that machine than
in CI. _env_file=None turns off only the file. A variable that is exported
in the shell still reaches Settings() in tests, as it does in production,
so set a value explicitly if the suite must not depend on it. If you choose
the database with an environment variable before the import, as above, the
process environment must win over the file. So do not use the dotenv option
that overrides existing variables: it would replace the database that the
test chose.
The testing extra installs pytest-asyncio. Set its default fixture loop
scope in pyproject.toml, or pytest-asyncio prints a deprecation warning on
every run:
[tool.pytest.ini_options]
asyncio_default_fixture_loop_scope = "function"
That is the whole setup. With configure_tests(), every test gets the schema
setup and the cleanup that you chose. Without that call, the client fixtures
still work, but they do not isolate the database: see
restly_client.
Your tests can stay sync, even when your application and its only database driver are async. Write async tests explains when a test needs to be async.
Details
The conftest.py above works because Restly has
one configuration per process, and the last
fr.configure() call wins. The factory call in conftest.py points the process at the test
database. Then configure_tests() records that configuration and freezes it.
This also works when a module that the conftest imports has already built an
app: the conftest’s factory call runs after that import, and before the freeze.
A factory that requires its settings, like the PostgreSQL one below, must
not build an app when its module is imported. Keep it a factory. Otherwise
conftest.py would again need the environment variable before the import, and
the factory exists to remove that need. A module that builds an app when it
is imported works only if the conftest imports it before configure_tests().
A module imported later, such as
an asgi.py that a test file imports, calls its factory after the freeze, and
fails with
RestlyConfigurationError.
This is one more reason why only the server should import asgi.py.
Test an async-only PostgreSQL app#
An application that uses only async PostgreSQL can keep its normal pooled
engine in tests. You do not need a special NullPool engine for tests. A
factory that reads its settings, like
the production template, takes Settings and
not a URL. So the conftest builds the settings, with the test database and
without the local .env. This is the conftest.py that restly new
generates:
import os
import fastapi_restly as fr
from app.main import create_app
from app.models import Base
from app.settings import Settings
TEST_DATABASE_URL = os.environ.get(
"TEST_DATABASE_URL",
"postgresql+asyncpg://postgres:postgres@localhost:5433/myapp_test",
)
# _env_file=None so a local .env cannot point the suite at the development
# database. The ignore is for the type checker: pydantic-settings accepts the
# underscore arguments at runtime but does not declare them.
app = create_app(Settings(database_url=TEST_DATABASE_URL, _env_file=None)) # type: ignore[call-arg]
fr.testing.configure_tests(app=app, base=Base, alembic_upgrade=True)
configure_tests() does not build a new engine, and it does not change the
pool. The app’s lifespan disposes the engine, in production and in tests. The
SaaS example tests its application-owned engine the same way
(tests/conftest.py).
Write a first test#
A test needs only the client:
# test_users.py
def test_create_and_fetch_user(restly_client):
response = restly_client.post( # fails the test unless the status is 2xx
"/users", json={"name": "Jane", "email": "jane@example.com"}
)
user_id = response.json()["id"]
data = restly_client.get(f"/users/{user_id}").json()
assert data["name"] == "Jane"
With the default cleanup, the user that this test creates is gone when the
next test runs. To work with the database directly instead of through the
API, ask for a session fixture, or use fr.open_session() or
fr.open_async_session() in a unit test. The database isolation belongs to
the test, not to the client fixture, so both ways get the same cleanup; see
the fixture reference.
Build the test schema#
base= names your models. configure_tests() cleans the tables of these
models between tests, and builds the schema from them if you ask it to. Pass
your declarative base, or its MetaData.
Who builds the schema is a separate choice, with three answers.
From your models, with create_all=True. It builds the tables as
fr.db.create_all() does. This is the
quickest way, and the right one when you do not test your migrations. Use a
database that you can lose: a file left from an older run keeps its old
tables, because creating a schema never drops a table.
From your migrations, with alembic_upgrade=True. It runs
alembic upgrade head before the first test. Restly looks for
alembic.ini in pytest’s
rootdir,
not in the directory where you started pytest. It sets sqlalchemy.url in the Alembic config to the database that
the application configured. An env.py that reads this setting therefore
migrates your test database, and not another one. Restly keeps the driver
of this URL, so an async-only application needs an env.py made from
Alembic’s async template, as
Set up migrations with Alembic shows. When the application
configures both a sync and an async database, Restly passes the sync URL.
Alembic runs in the test process. The env.py of Alembic’s templates calls
logging.config.fileConfig(), which turns off every logger that exists
already. A test that checks the log of such a logger with pytest’s caplog
then sees nothing. To keep these loggers on, pass
disable_existing_loggers=False:
if config.config_file_name is not None:
fileConfig(config.config_file_name, disable_existing_loggers=False)
The SaaS example builds its test schema from its migrations
(tests/conftest.py).
Yourself, when you pass neither. Restly then does not touch the schema. Choose this when your suite already builds it, or when your migrations do not use Alembic. Build the schema once per session, not once per test. Under the default rollback mode, a table that a test creates belongs to the transaction of that test, and the rollback removes it.
Details
A base knows only the models that have been imported when the suite freezes
the configuration. Building the app imports them, because the app imports
every view, and each view imports its model. A conftest that uses Base
without building the app should also import app.main, as alembic/env.py
does; see Compose in one place.
Pass a path if alembic.ini is somewhere else:
fr.testing.configure_tests(
app=app,
base=Base,
alembic_upgrade="backend/alembic.ini",
)
Choose how tests clean up#
The db_cleanup= argument of configure_tests() decides what Restly does
with the database around each test. The default fits most suites. The other
two modes are for the cases that the default cannot serve.
"rollback", the default, runs each test in a transaction and rolls it
back at the end, with the savepoints that
Internals describes. Nothing is ever committed.
This makes it the fastest mode, and it keeps the reference data that your
migrations added.
"delete" empties the tables before each test instead, and lets writes
commit for real. It is slower, and it needs a database of its own. But the
rows of the last test are still there when the run ends, so you can look at
them.
"none" cleans nothing, and leaves the cleanup to you. Use it for a suite
that drives a browser or another process. Such a process cannot see data
that is not committed, and parallel workers that share one database would
delete each other’s rows.
Details
To change the mode for one run without editing the suite:
pytest --restly-db-cleanup=delete
RESTLY_DB_CLEANUP=delete pytest
The flag wins over the environment variable, and the variable wins over the
argument. Each mode other than the default shows in pytest’s header, so a
flag left from a debugging session cannot change the suite without notice.
pytest prints that header only at normal verbosity, so -q hides it.
Keep reference data in delete mode#
Delete mode empties all tables that base= declares. This includes the
tables that your migrations filled with reference data, and nothing puts
those rows back. Name these tables, and Restly leaves them alone:
fr.testing.configure_tests(
app=app,
base=Base,
alembic_upgrade=True,
db_cleanup="delete",
db_cleanup_exclude=["country", "role"],
)
Every test shares the excluded tables, so a write to one of them stays for
the next test. A name that is not a table raises an error, because a typo
would otherwise empty the table that you wanted to protect. The SaaS example
excludes its country table, which a migration fills.
Use the test clients#
Both clients check the status code of each response from get, post,
put, patch and delete. request, head and options do not check it.
When the status does not match, they raise an AssertionError that includes
the response body.
The assert_status_code argument of each request method sets the check:
|
Accepted status |
|---|---|
|
any success status, such as |
an int, such as |
only that status |
|
any status: the client does not check |
Pass the exact code when the status is what the test is about, such as an error:
def test_not_found(restly_client):
restly_client.get("/users/999", assert_status_code=404)
The default does not check which success status a route returns. To test
that a create returns 201, pass assert_status_code=201.
To test a request as a different user, override the dependency that gives the user; Request Context shows how.
Details
RestlyTestClient is the
sync client behind the restly_client fixture. The fixture creates it and
enters it for you. Use the client directly when you test without the
fixtures:
from fastapi_restly.testing import RestlyTestClient
with RestlyTestClient(app) as client:
response = client.get("/users")
Use it in a with block. Starlette runs the lifespan of an application
only inside that block, so a client used outside one skips what lifespan=
sets up.
Although it is sync, RestlyTestClient also tests async FastAPI routes and
AsyncRestView endpoints.
AsyncRestlyTestClient
has the same request methods and checks for async tests. The
restly_async_client fixture creates it and runs the app’s lifespan for you.
Write async tests#
You probably do not need them. restly_client is sync, and it runs async
routes and AsyncRestView endpoints well. So you can write the whole suite
of an async application with def tests, and usually you should. Async tests
give little here. They need an event loop setting, they give confusing
collection errors when that setting is wrong, and they stop you from querying
the database in pdb (see Look at the database).
Write an async test when the test must await something itself:
restly_async_session or fr.open_async_session() to add rows directly, or
one of your own coroutines. Use restly_async_client for HTTP in the same
test.
async def test_direct_setup_and_request(restly_async_client, restly_async_session):
restly_async_session.add(User(name="Alice"))
await restly_async_session.commit()
response = await restly_async_client.get("/users")
assert response.json()["total_count"] == 1
Put pytest-asyncio into
auto mode,
which collects async def tests without a marker on each one:
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
Without that, or an anyio setup that does the same, async tests are not collected or give confusing errors. Sync tests keep working next to them. The pytest-asyncio reference covers its other settings.
Details
The sync client runs the application on its own event loop, and the async
fixtures run on pytest’s loop. So in an application that has only an async
database, a rollback test that uses restly_client together with
restly_async_session stops while pytest sets up the fixtures, with a
RestlyConfigurationError. The error tells you to use
restly_async_client, and fr.open_async_session() fails with the same
advice when it opens the session. Under "delete" and "none", drivers
such as asyncpg, which tie pooled connections to one event loop, need the
same split.
Look at the database during a test#
Under the default mode, psql finds no rows while a test runs, and none
after it. The rows are in a transaction that is not committed, on one
connection that no other process can read, and the rollback removes them
when the test ends. There are two ways to see them anyway.
From inside the test. pytest --pdb stops at the failure while the
transaction is still open, before any fixture is torn down, and
restly_client works from there:
(Pdb) restly_client.get("/users").json()
{'data': [{'id': 1, 'name': 'Jane'}], 'total_count': 1, ...}
A query through your own API like this works in a sync suite and in an async
one, because the client is sync in both. A direct query on a session works
only in a sync suite, with restly_session.execute(...). In an async suite,
restly_async_session.execute(...) returns a coroutine that nothing awaits,
and its sync_session raises MissingGreenlet.
With ordinary tools. Run once in delete mode: the rows are committed, and they are still there when the run ends:
pytest --restly-db-cleanup=delete -k test_the_broken_one
psql myapp_test -c 'select * from "user"'
Delete mode cleans before each test, not after, so the rows of the failing test stay. Use a file database or a server for this: an in-memory database is gone when pytest exits.
Pytest fixture reference#
These are the fixtures that the plugin registers, with their scope and their exact behavior.
restly_app#
Scope: function
Returns the app that you passed to configure_tests(app=...). Without one, it
returns a bare FastAPI(), and every request answers 404. You can also
override this fixture in your conftest.py to supply the app.
restly_client#
Scope: function
A RestlyTestClient for the restly_app fixture. In a
configured suite, its requests follow the
cleanup mode: the default rolls them back with
the rest of the test, and "delete" and "none" let them commit. In a suite
that never called configure_tests() and uses no session fixture,
nothing rolls them back: a test with only the client commits real rows to
the configured database. For backward compatibility, a test that asks for
restly_session still runs under rollback isolation, also for its requests
through restly_client. restly_async_session does the same for requests
through restly_async_client.
For an application with only an async database, under rollback, Restly opens the transaction on the same event loop that the sync client uses to run the application. For this reason, an asyncpg engine with a normal pool works, although the test function itself is sync.
restly_async_client#
Scope: function
An AsyncRestlyTestClient for restly_app, with the same
default status checks as restly_client. It runs the application’s lifespan,
and it uses the same event loop as restly_async_session (and, under
rollback, the same transaction). Use it when a test needs both HTTP and
direct async database access.
restly_session#
Scope: function
Under the default rollback mode, a SQLAlchemy Session on a pinned
connection whose outer transaction is never committed. Each request during
the test builds its own real session, which joins that transaction through a
savepoint (SQLAlchemy’s create_savepoint mode). So commit() and
rollback() work as in production, but nothing stays after the test. The
fixture skips the test if no sync session source is configured.
In a suite that called configure_tests(), a custom session_generator
follows these rules. The same table applies to the async
async_session_generator and the async sessionmaker.
Cleanup mode |
What happens to the generator |
|---|---|
|
Restly replaces the generator through |
|
If either custom generator is configured, pytest stops before the tests run, with a configuration error. This happens even when a matching sessionmaker exists, because Restly cannot check that the generator writes to the database that it would clean. Use the sessionmaker as the session source of the application, or choose |
|
Restly does not touch the generator. The session fixture uses the matching sessionmaker if there is one. With only a generator, it skips, because it has no sessionmaker to build the fixture’s session from. |
To find the bind, the generator must take no arguments and yield a session with one bind. If the generator needs request dependencies, chooses binds at run time, or builds a session that needs other arguments, configure its test engine or sessionmaker explicitly. Point the generator at a test database too, because the setup runs its body and its cleanup.
Pass app= to
configure_tests(), so that
ordinary FastAPI dependencies such as Depends(get_db) use the same isolated
request session as Restly. Without the managed setup, use restly_client
with restly_session, or their async versions. The client installs the
override for the test and removes it after. Your own overrides on the app
stay in place, and they must isolate their own sessions.
Under rollback, fr.open_session() uses the same factory as SessionDep.
So it also gives a session on the test’s pinned connection, even when the
test does not ask for restly_session. This makes unit tests that only use
the database easy to write; the fixture only gives you a ready Session.
Under a cleanup mode other than rollback, the
fixture gives a plain session on the configured database instead, because a
rollback would undo the writes that those modes exist to commit.
You can read a committed write back in the same test:
def test_user_created(restly_session):
user = User(name="Alice")
restly_session.add(user)
restly_session.commit()
result = restly_session.get(User, user.id)
assert result.name == "Alice"
restly_async_session#
Scope: function
The async version of restly_session. To await it in a test body, you
write an async test. In an async-only project it
needs only fr.configure(async_database_url=...). It skips the test if no
async session source is configured. It treats a configured
async_session_generator (and fr.open_async_session()) as the generator table
above says, with the async sessionmaker where the sync fixture uses the sync
one.
In an async-only rollback test, use it with restly_async_client, not with
restly_client. Then every use of the pinned connection stays on pytest’s
event loop.
Under rollback, as on the sync side, fr.open_async_session() is isolated
even when the test does not ask for this fixture. Use that form when a unit
test opens several short sessions, or when your application code already
opens its own.
It works like restly_session, with await:
async def test_user_created(restly_async_session):
user = User(name="Bob")
restly_async_session.add(user)
await restly_async_session.commit()
result = await restly_async_session.get(User, user.id)
assert result.name == "Bob"
Note (rollback only): When both sync and async session sources are configured, Restly runs the async fixture over the test’s pinned sync connection. The two fixtures then share one transaction, and a write that one of them commits is visible to the other in the same test. The async driver itself is not used. Under
"delete"and"none", each fixture uses its own configured sessionmaker. There is no shared connection and no outer transaction, and a write reaches the other fixture only after a real commit, under the normal isolation rules of the database.
restly_project_root#
Scope: function
Goes up from the test file until it finds a pyproject.toml, and returns that
directory as a Path. Use it to find project files from a test, such as
migration configs or test data. It starts from the test file, not from the
working directory, so it returns the same root wherever you start pytest. In
a monorepo, each test finds the root of its own sub-project.
Internals#
Savepoints and rollback#
The default mode follows SQLAlchemy’s own recipe for test suites,
joining a session into an external transaction.
For each test, the plugin opens one connection, begins a transaction on it,
and points Restly’s session factory at that connection in create_savepoint
mode. Every session built during the test joins that transaction through a
savepoint: the sessions that your code opens, and the sessions of the
requests. The public client and session fixtures use this setup; none of them
owns it.
So a commit() releases a savepoint, and does not reach the database. A
rollback() undoes only the work of its own session. Both work as they do in
production. The outer transaction is never committed: closing the connection
at the end of the test undoes everything at once.
This also makes it fast. The cleanup is one rollback on a connection that is already open. It costs the same for one row or a thousand, and it does not get slower as your schema grows.
Explicit transaction blocks also work as in production: with restly_session.begin(): ... and its async form commit on success and roll
back on an error. They stay inside the outer transaction, so nothing remains
after the test.
See also#
Request Context: test a request as a different user.
Database & Configuration: the session sources that the fixtures record.
fr.testing.configure_tests(): every argument.