Test APIs with RestlyTestClient and Fixtures#

FastAPI-Restly ships a test client with sensible status-code assertions and a small pytest plugin that gives every test a clean database. You configure the suite once, and then write tests that only talk to your API.

Setup#

Install the testing extra:

pip install "fastapi-restly[testing]"

The testing extra is independent of standard, which is runtime-only. Neither carries a database driver, since Restly does not choose one for you, so install the driver your test database needs as well. The examples below use SQLite through aiosqlite:

pip install aiosqlite

The extra registers a pytest11 entry point, so pytest auto-loads the Restly fixtures. If your project disables plugin autoloading, register the plugin manually in conftest.py:

pytest_plugins = ["fastapi_restly.pytest_fixtures"]

The application still owns its database configuration. Make that configuration selectable, for example through an environment-backed setting:

# myapp/main.py
import os

import fastapi_restly as fr

fr.configure(
    async_database_url=os.environ.get(
        "DATABASE_URL", "sqlite+aiosqlite:///./app.db"
    )
)

Then select the test value before importing the application, and configure the suite from the application configuration already in force:

# conftest.py
import os

os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///./test.db"

import fastapi_restly as fr

from myapp.main import app  # noqa: E402
from myapp.models import Base  # noqa: E402

fr.testing.configure_tests(
    app=app,
    base=Base,
    create_all=True,
)

A .env file fits this pattern as long as explicit process environment values take precedence over file values. Avoid loading dotenv with an “override existing variables” option in application code; that makes the test unable to select its database before import. If import-time settings are awkward, pass an explicit test settings object to an app factory instead.

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 completes the setup for a managed suite. configure_tests() opts every test into schema setup and the selected database cleanup. Without that call, the client fixtures still run but do not start database isolation. For backward compatibility, a test that explicitly requests restly_session still runs under rollback isolation, including its requests through restly_client. restly_async_session does the same for requests through restly_async_client. A client-only test without configure_tests() commits normally.

Your tests can stay synchronous, even when your application and its only database driver are async. See writing async tests when a test also needs direct async database access.

Async-only PostgreSQL#

An application configured only with async PostgreSQL can keep its ordinary pooled engine in tests; you do not need a test-only NullPool engine. Let the application own the engine and dispose it when the application shuts down:

# myapp/main.py
import os
from contextlib import asynccontextmanager

from fastapi import FastAPI
from sqlalchemy.ext.asyncio import create_async_engine

import fastapi_restly as fr

engine = create_async_engine(
    os.environ["DATABASE_URL"],
    pool_pre_ping=True,
)
fr.configure(async_engine=engine)


@asynccontextmanager
async def lifespan(app: FastAPI):
    yield
    await engine.dispose()


app = FastAPI(lifespan=lifespan)

Use a production URL such as postgresql+asyncpg://... in deployment and set the same setting to a disposable PostgreSQL database before importing the app in conftest.py. Then call configure_tests() as shown above. Restly neither rebuilds the engine nor changes its pool. The application lifespan still owns the dispose call, in production and in tests alike.

What you get#

Four things, which is about what any database-backed suite needs.

One database configuration. configure_tests() records the session sources your application already configured; it does not create or replace them. If the database configuration changes later, during collection or lifespan startup, the suite fails instead of letting schema setup, cleanup and requests disagree. Selecting a disposable test database remains the application’s responsibility. An environment variable, an explicit test settings object, or an app factory are all good ways to do that.

A schema that is already there. Tables are created once, before the first test, either from your models with create_all=True or by running your migrations with alembic_upgrade=True.

A cleanup policy for every test. With the default cleanup, everything a test writes is rolled back when it finishes, so no test sees another’s rows and the suite does not care what order it runs in. There is no teardown to write and no database to rebuild between tests, which is what keeps a suite fast as it grows. See savepoints and rollback for how that works, and cleaning up between tests for the two cases it cannot serve.

Clients wired to your app. restly_client sends requests from ordinary def tests. restly_async_client sends them from async def tests on the same event loop as restly_async_session. Both run the app’s lifespan and roll their requests back with everything else under the default cleanup.

A first test#

A test needs nothing but the client:

# test_users.py
def test_create_and_fetch_user(restly_client):
    response = restly_client.post(  # asserts 201 automatically
        "/users/", json={"name": "Jane", "email": "jane@example.com"}
    )
    user_id = response.json()["id"]

    data = restly_client.get(f"/users/{user_id}").json()  # asserts 200
    assert data["name"] == "Jane"

With the default cleanup, the user this test creates is gone by the time the next test runs. To work against the database directly instead of through the API, ask for a session fixture, or use fr.open_session() / fr.open_async_session() directly in a unit test. Database isolation belongs to the test, not to the client fixture, so either form receives the same cleanup; see the fixture reference.

Test databases and migrations#

base=Base names your models. It is what configure_tests() cleans between tests, and what it builds the schema from if you ask it to. Pass your declarative base, or its MetaData.

Who builds the schema is a separate choice, and there are three answers.

From your models, with create_all=True, which builds the tables the way fr.db.create_all() does. This is the quickest route, and the right one when migrations are not part of what you are testing. Point it at a database you are willing to lose: a leftover file from an older run keeps its stale tables, since creating a schema never drops one.

From your migrations, with alembic_upgrade=True, which runs alembic upgrade head before the first test. Restly resolves alembic.ini relative to your project rather than to the directory you happened to run pytest from, and sets sqlalchemy.url on the config to the database configured by the application. An env.py that reads that setting therefore migrates your test database, not whatever it would otherwise resolve on its own.

If the application configures only an async URL such as postgresql+asyncpg://... or sqlite+aiosqlite://..., its Alembic env.py must be able to open that async URL. Create the environment with Alembic’s async template (alembic init -t async ...) or adapt an existing env.py using Alembic’s asyncio recipe. The standard synchronous template cannot open it. When the application configures both synchronous and async database URLs, Restly passes the synchronous URL to Alembic.

Pass a path if the config lives somewhere else:

fr.testing.configure_tests(
    app=app,
    base=Base,
    alembic_upgrade="backend/alembic.ini",
)

Yourself, by passing neither. Restly then leaves the schema alone, which is what you want when your suite already builds it or your migrations are not Alembic. Build it once per session rather than per test. Under the default rollback mode, a table created inside a test belongs to that test’s transaction and is discarded with it.

See Migrations with Alembic for production migration setup.

Cleaning up between tests#

The db_cleanup= argument to configure_tests() decides what Restly does with the database around each test. The default suits most suites; the other two exist for cases it cannot serve.

"rollback", the default, wraps each test in a transaction and rolls it back at the end, through the savepoints described below. Nothing is ever committed, which is what makes it the fastest option, and it leaves reference data your migrations seeded untouched.

"delete" empties the tables before each test instead, and lets writes commit for real. It is slower and wants a database of its own, but the rows the last test wrote are still there when the run ends, which is what makes them inspectable.

"none" cleans nothing and leaves that to you. Neither of the others fits a suite that drives a browser or another process, which cannot see uncommitted data and whose parallel workers would clean the shared database out from under each other.

In an application configured with only an async database, use restly_client when a test only sends HTTP requests and restly_async_client when it also needs direct async database access. The synchronous client runs the application on a different event loop from async test fixtures. Under rollback, a test that requests it together with restly_async_session stops during fixture setup with a RestlyConfigurationError. The error directs you to restly_async_client, and fr.open_async_session() fails with the same guidance at the moment it opens the session. Drivers that bind pooled connections to one event loop, such as asyncpg, require the same separation under "delete" and "none".

Switch mode for one run without editing the suite:

pytest --restly-db-cleanup=delete
RESTLY_DB_CLEANUP=delete pytest

The flag beats the environment variable, which beats the argument. Any mode other than the default announces itself in pytest’s header, so a flag left over from a debugging session cannot quietly change what a suite does. pytest prints that header at normal verbosity only, so -q hides it.

Reference data and cleaning#

Delete mode empties the tables base= declares, including the ones your migrations seeded with reference data, and nothing puts those rows back. Name them and they are left alone:

fr.testing.configure_tests(
    app=app,
    base=Base,
    alembic_upgrade=True,
    db_cleanup="delete",
    db_cleanup_exclude=["country", "role"],
)

Excluded tables are shared by every test, so a write to one does carry over. Naming a table that does not exist raises, since a typo would otherwise empty the very table you meant to protect.

Test clients#

RestlyTestClient is the synchronous client behind the restly_client fixture. The fixture constructs and enters it for you; use it directly when testing without the fixtures:

from fastapi_restly.testing import RestlyTestClient

with RestlyTestClient(app) as client:
    response = client.get("/users/")

Enter it. Starlette runs an application’s lifespan startup and shutdown only inside the context manager, so a client built and used outside one skips whatever lifespan= sets up. The restly_client fixture enters it for you, once per test.

Although it is synchronous, RestlyTestClient still tests async FastAPI routes and AsyncRestView endpoints. AsyncRestlyTestClient provides the same request methods and assertions for async tests; the restly_async_client fixture constructs it and runs the app lifespan for you.

Both clients assert a default status code for each request and, on mismatch, raise an AssertionError that includes the response body:

Method

Default expected status

get

200

post

201

put

200

patch

200

delete

204

AsyncRestView and RestView do not generate PUT routes; the client’s put exists for React Admin views and custom routes.

Override the expectation when testing error paths:

def test_not_found(restly_client):
    restly_client.get("/users/999", assert_status_code=404)

Passing assert_status_code=None relaxes the check to “any status below 400”; it does not skip the assertion. Pass the error code you expect when you know it. To make a request with no Restly status assertion at all, use the generic request() method:

response = restly_client.request("GET", "/users/999")

The async client offers the same escape hatch with await restly_async_client.request(...).

Writing async tests#

You probably do not need them. restly_client is synchronous and drives async routes and AsyncRestView endpoints perfectly well, so a suite for an async application can be written entirely with def and usually should be. Async tests buy little here, and they cost you an event-loop setting, a class of confusing collection errors when it is wrong, and the ability to query the database from pdb (see inspecting the database).

Write them when a test has to await something itself: restly_async_session or fr.open_async_session() to set up rows directly, or one of your own coroutines. Use restly_async_client for HTTP in the same test. In an async-only rollback suite, a test that combines restly_client and restly_async_session stops during fixture setup with a RestlyConfigurationError: the synchronous client and the async fixture use different event loops and cannot share one test transaction. The error tells you to use restly_async_client.

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 marking each one:

[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"

Without that, or an equivalent anyio setup, async tests fail to collect or produce confusing errors. Sync tests keep working alongside them. The pytest-asyncio reference covers the rest of its settings.

Pytest fixture reference#

These are the fixtures the plugin registers, with their scope and exact behavior.

restly_app#

Scope: function

Returns the app you passed to configure_tests(app=...). Without one it returns a bare FastAPI(), and every request answers 404; override this fixture in your conftest.py if you would rather supply the app that way.

restly_client#

Scope: function

A RestlyTestClient wrapping the restly_app fixture. In a configured suite, requests follow its cleanup mode: the default rolls them back with the rest of the test, while "delete" and "none" let them commit. On its own, in a suite that never called configure_tests() and requests no session fixture, nothing rolls them back: a client-only test commits real rows to the configured database.

For an async-only application under rollback, Restly opens the transaction on the same event loop the synchronous client uses to run the application. This is why a normally pooled asyncpg engine works even though the test function itself is synchronous.

restly_async_client#

Scope: function

An AsyncRestlyTestClient wrapping restly_app, with the same default status assertions as restly_client. It runs the application’s lifespan and shares the event loop used by restly_async_session (and, under rollback, the same transaction). Use it whenever 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 that joins that transaction through a savepoint (SQLAlchemy’s create_savepoint mode), so commit() and rollback() behave as in production while nothing persists past the test. The fixture skips automatically if no sync session source is configured at all.

In a suite that called configure_tests(), a custom sync_session_generator follows these rules. The same table applies to the async session_generator and async sessionmaker.

Cleanup mode

Generator behavior

"rollback"

Restly bypasses the generator and builds isolated sessions from the matching sessionmaker. If only the generator is configured, pytest stops before running tests with a configuration error that names the missing sessionmaker. Code in the generator body, such as SET search_path, does not run.

"delete"

If either custom generator is configured, pytest stops before running tests with a configuration error, even when a matching sessionmaker exists. Restly cannot verify that the generator writes to the database it would clean. Use the sessionmaker as the application session source, or choose "none".

"none"

Restly leaves the generator untouched. The public session fixture uses the matching sessionmaker when one exists; with only a generator it skips because there is no sessionmaker from which to construct the fixture session.

Under rollback, fr.open_session() resolves the same factory SessionDep does, so it too yields a session on the test’s pinned connection even when restly_session is not requested. This makes database-only unit tests a first-class use case; the public fixture is only a convenient ready-made Session.

Under any cleanup mode other than rollback this fixture yields a plain session on the configured database instead, since rolling it back would undo the writes those modes exist to commit.

A committed write can be read back within 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. Awaiting it in a test body means writing an async test. In async-only projects it needs only fr.configure(async_database_url=...). It skips automatically if no async session source is configured at all. It handles a configured session_generator (and fr.open_async_session()) according to the generator table above, using the async sessionmaker where the sync fixture uses the synchronous one.

Pair it with restly_async_client, not restly_client, in an async-only rollback test. This keeps every use of the pinned connection on pytest’s event loop.

Under rollback, as on the sync side, fr.open_async_session() is isolated even when this public fixture is not requested. Use that form when a unit test naturally owns several short-lived sessions or when application service code already opens its own.

Usage mirrors 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 synchronous and async session sources are configured, Restly runs the async fixture over the test’s pinned synchronous connection. The two fixtures therefore share one transaction, and a write committed through either is visible to the other within the test. The async driver itself is not exercised. Under "delete" and "none", each fixture uses its configured sessionmaker independently; there is no shared connection or outer transaction, and writes cross between them only after a real commit, subject to the database’s ordinary isolation rules.

restly_project_root#

Scope: function

Walks up from the requesting test file until it finds a pyproject.toml and returns that directory as a Path. This is a convenience for locating project files (migration configs, test data) from tests. Because discovery is anchored to the test file rather than the working directory, it returns the same root regardless of where pytest was invoked, and in a monorepo each test resolves to its own sub-project’s root.

Savepoints and rollback#

The default mode follows SQLAlchemy’s own recipe for test suites, joining a session into an external transaction. The plugin’s internal test scope 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, including direct sessions and the ones requests build, joins that transaction through a savepoint. Public client and session fixtures consume this scope; none of them owns it.

A commit() therefore releases a savepoint rather than reaching the database, and a rollback() discards only that session’s own work, so both behave the way they do in production. The outer transaction is never committed: closing the connection at the end of the test undoes everything at once.

That is also what makes it fast. Cleanup is one rollback on a connection that is already open, so it costs the same whether a test wrote one row or a thousand, and it does not slow down as your schema grows.

Explicit transaction blocks behave as in production too: with restly_session.begin(): ... and its async form commit on success and roll back on error, scoped to the outer transaction so nothing survives the test.

Inspecting the database#

Opening psql while a test runs under the default mode finds nothing, and so does opening it afterwards. The rows live in an uncommitted transaction on a single connection, which no other process can read, and the rollback removes them when the test ends. 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 tears down, and restly_client works from there:

(Pdb) restly_client.get("/users/").json()
{'data': [{'id': 1, 'name': 'Jane'}], 'total_count': 1, ...}

Querying through your own API like this works whether the suite is sync or async, because the client is synchronous either way. Querying a session directly works only in a sync suite, with restly_session.execute(...). In an async one restly_async_session.execute(...) hands back a coroutine that nothing awaits, and reaching for its sync_session raises MissingGreenlet.

With ordinary tools. Run once in delete mode and the rows are committed, and still there when the run ends:

pytest --restly-db-cleanup=delete -k test_the_broken_one
psql myapp_test -c 'select * from "user"'

Cleaning happens before each test rather than after, which is what leaves the failing test’s rows in place. Point the suite at a file or a server for this; an in-memory database is gone as soon as pytest exits.