The Full-Stack FastAPI template ships with a sensible testing setup out of the box: pytest for the backend (unit and integration tests under app/tests) and Playwright for end-to-end tests that drive the React front end in a real browser. Between those two layers there is a useful gap, and behaviour-driven tests fit it well.
This is a hands-on guide to adding a behave acceptance-test suite to the template. We will write Gherkin specifications for the flows that define the application's contract (logging in, item ownership, password recovery) and back them with Python step definitions that exercise the real FastAPI app. I am not trying to replace pytest or Playwright here, just to add a layer of executable specifications: tests that read like a description of how the system should behave, that a non-developer can follow, and that the CI server can run on every push.
One note up front, in the spirit of not asserting things I cannot stand behind: the import paths and one or two status codes below reflect the template as of mid-2026. Check them against your own checkout. The structure is stable, but details move.
Why behave, alongside what's already there
It helps to be precise about what each layer is for, because the fastest way to make BDD a waste of effort is to use it to re-test what pytest already covers.
- pytest: unit and integration tests. Developer-facing, fine-grained, fast. It answers "does this function, this CRUD helper, this endpoint behave correctly given these inputs?" The template already does this well.
- behave: acceptance tests / executable specifications. Written in business-readable Gherkin, exercised at the API boundary. It answers "does the system honour its rules? Can a user only see their own items, is a wrong password rejected, does recovery send an email?" It doubles as living documentation of those rules.
- Playwright: full-browser end-to-end. The slowest and most realistic layer; it answers "can a human complete this journey through the actual UI?"
A good Gherkin scenario is a conversation a developer and a stakeholder can both read, and that the CI server can run. If a scenario reads like implementation detail, it belongs in pytest, not in a feature file.
behave sits in the middle: faster and more readable than Playwright, higher-level and more contract-focused than a pytest endpoint test. For a template that other teams fork as the starting point for real products, that readable contract is the part most worth having.
The shape of a behave suite
A behave suite is three kinds of file. Feature files written in Gherkin describe behaviour; step files in Python implement the Given/When/Then phrases; and environment.py holds the hooks that set up and tear down state. Add them under the backend, next to the existing tests:
backend/
├── app/
│ └── tests/ # existing pytest suite — leave it alone
└── features/ # new: the behave acceptance suite
├── environment.py # hooks: app client + per-scenario DB isolation
├── authentication.feature
├── items.feature
├── password_recovery.feature
└── steps/
├── auth_steps.py
├── item_steps.py
└── common_steps.py # shared Then steps (status codes, etc.)
Install the two dependencies you need. The template manages the backend environment with uv, so add them there (adjust if your fork uses pip or Poetry):
uv add --dev behave
# httpx ships with the FastAPI test client already, but pin it explicitly
# if you also want to talk to side-effect services like MailCatcher.
uv add --dev httpx
environment.py: a real app, isolated per scenario
The decision that matters most in any acceptance suite is test isolation: each scenario must start from a clean, known state, or scenarios contaminate one another and failures become non-reproducible. We get there with two moves. First, build one TestClient around the real application, so requests run in-process with no network and no separate server to manage. Second, wrap every scenario in a database transaction that we roll back afterwards, so nothing a scenario writes survives it.
# features/environment.py
from fastapi.testclient import TestClient
from sqlmodel import Session
from app.main import app
from app.core.db import engine
from app.api.deps import get_db
def before_all(context):
# One in-process client for the whole run. No server, no network.
context.client = TestClient(app)
def before_scenario(context, scenario):
# Bind a session to a single connection + transaction, and tell SQLAlchemy
# to keep app-level commits inside a SAVEPOINT so the outer transaction can
# still roll the whole scenario back. (Requires SQLAlchemy 2.0, which
# SQLModel uses. If your version predates join_transaction_mode, recreate
# the schema or truncate tables between scenarios instead.)
context.connection = engine.connect()
context.transaction = context.connection.begin()
context.session = Session(
bind=context.connection,
join_transaction_mode="create_savepoint",
)
# Make every endpoint use this scenario-scoped session.
app.dependency_overrides[get_db] = lambda: (yield context.session)
context.state = {} # scratch space: tokens, ids created during the scenario
def after_scenario(context, scenario):
app.dependency_overrides.clear()
context.session.close()
context.transaction.rollback() # undo everything the scenario did
context.connection.close()
Because the dependency override points the app's get_db at our transaction-bound session, the endpoints write to the same place we later roll back. Each scenario is hermetic, and the suite leaves the database exactly as it found it.
The first feature: authentication
Start with the flow everything else depends on. The template authenticates via an OAuth2 password grant at {API}/login/access-token, which is a form post of username and password that returns a JWT. Notice how the Gherkin describes intent, never status codes or JSON shapes:
# features/authentication.feature
Feature: Authentication
As an API client
I want to exchange credentials for a token
So that I can call protected endpoints
Scenario: A registered user logs in
Given a registered user "ada@example.com" with password "s3cret-passphrase"
When they request an access token with that email and password
Then the response status is 200
And the response contains an access token
Scenario: Login is rejected for a wrong password
Given a registered user "ada@example.com" with password "s3cret-passphrase"
When they request an access token with email "ada@example.com" and password "nope"
Then the response status is 400
Scenario: The token grants access to the current-user endpoint
Given a registered user "ada@example.com" with password "s3cret-passphrase"
And they have logged in
When they request their own profile
Then the returned email is "ada@example.com"
The step file translates each phrase into a request against context.client. Parameters in {curly braces} are parsed straight out of the sentence:
# features/steps/auth_steps.py
from behave import given, when, then
from app.core.config import settings
API = settings.API_V1_STR # "/api/v1" by default
@given('a registered user "{email}" with password "{password}"')
def step_register(context, email, password):
# The template exposes open signup; if your deployment disables it,
# create the user via the superuser endpoint instead.
resp = context.client.post(
f"{API}/users/signup",
json={"email": email, "password": password, "full_name": "Test User"},
)
assert resp.status_code == 200, resp.text
context.state["user"] = {"email": email, "password": password}
def _login(context, email, password):
context.response = context.client.post(
f"{API}/login/access-token",
data={"username": email, "password": password}, # form, not JSON
)
@when('they request an access token with that email and password')
def step_login_same(context):
user = context.state["user"]
_login(context, user["email"], user["password"])
@when('they request an access token with email "{email}" and password "{password}"')
def step_login(context, email, password):
_login(context, email, password)
@given('they have logged in')
def step_logged_in(context):
user = context.state["user"]
_login(context, user["email"], user["password"])
context.state["token"] = context.response.json()["access_token"]
@when('they request their own profile')
def step_me(context):
token = context.state["token"]
context.response = context.client.get(
f"{API}/users/me",
headers={"Authorization": f"Bearer {token}"},
)
@then('the response contains an access token')
def step_has_token(context):
assert context.response.json().get("access_token")
@then('the returned email is "{email}"')
def step_email(context, email):
assert context.response.status_code == 200, context.response.text
assert context.response.json()["email"] == email
The status-code and generic assertions go in common_steps.py so every feature can reuse them:
# features/steps/common_steps.py
from behave import then
@then('the response status is {code:d}')
def step_status(context, code):
assert context.response.status_code == code, (
f"expected {code}, got {context.response.status_code}: {context.response.text}"
)
A richer feature: item ownership
Authentication is table stakes. The rule I most want documented is authorisation: a user may read their own items but not someone else's. This is precisely the kind of business rule that benefits from a readable, executable spec, and it is easy to regress silently in a refactor. A Background removes repetition, and the scenarios state the rule plainly:
# features/items.feature
Feature: Items belong to their owner
Users manage their own items and must not see anyone else's.
Background:
Given a registered, logged-in user "owner@example.com"
Scenario: A user creates and reads back their own item
When they create an item titled "Quarterly report"
Then the response status is 200
And "Quarterly report" appears in their item list
Scenario: A user cannot read another user's item
Given another user owns an item "Private notes"
When the user requests that item by id
Then access is denied
# features/steps/item_steps.py
from behave import given, when, then
from app.core.config import settings
API = settings.API_V1_STR
def _auth(context):
return {"Authorization": f"Bearer {context.state['token']}"}
@given('a registered, logged-in user "{email}"')
def step_logged_in_user(context, email):
password = "s3cret-passphrase"
context.client.post(
f"{API}/users/signup",
json={"email": email, "password": password, "full_name": "Owner"},
)
resp = context.client.post(
f"{API}/login/access-token",
data={"username": email, "password": password},
)
context.state["token"] = resp.json()["access_token"]
@when('they create an item titled "{title}"')
def step_create_item(context, title):
context.response = context.client.post(
f"{API}/items/",
headers=_auth(context),
json={"title": title, "description": "created in a scenario"},
)
@then('"{title}" appears in their item list')
def step_item_listed(context, title):
resp = context.client.get(f"{API}/items/", headers=_auth(context))
assert resp.status_code == 200, resp.text
titles = [item["title"] for item in resp.json()["data"]]
assert title in titles, titles
@given('another user owns an item "{title}"')
def step_other_users_item(context, title):
# Create a second user, log in as them, create the item, capture its id.
other_pw = "another-passphrase"
context.client.post(
f"{API}/users/signup",
json={"email": "intruder@example.com", "password": other_pw, "full_name": "Other"},
)
other_token = context.client.post(
f"{API}/login/access-token",
data={"username": "intruder@example.com", "password": other_pw},
).json()["access_token"]
created = context.client.post(
f"{API}/items/",
headers={"Authorization": f"Bearer {other_token}"},
json={"title": title, "description": "not yours"},
)
context.state["other_item_id"] = created.json()["id"]
@when('the user requests that item by id')
def step_request_other_item(context):
item_id = context.state["other_item_id"]
context.response = context.client.get(f"{API}/items/{item_id}", headers=_auth(context))
@then('access is denied')
def step_denied(context):
# The template returns 400 "Not enough permissions" for cross-owner reads.
# Asserting the behaviour (not a specific code) keeps the spec robust if
# the template later switches to 403/404 — adjust the set to your version.
assert context.response.status_code in {400, 403, 404}, context.response.text
Notice the deliberate choice in the final step: the scenario asserts that access is denied, and the implementation tolerates the handful of status codes that legitimately express denial. That keeps the specification about behaviour, while a comment records what the current template actually returns. It is the difference between a spec that survives a refactor and one that breaks on a cosmetic change.
Testing a side effect: password recovery
Some behaviour is only real if something happens outside the request. Password recovery is the classic example: the endpoint returns 200, but the behaviour that matters is that an email goes out. The template wires local mail to MailCatcher, which exposes an HTTP API, so a step can assert the message actually landed:
# features/password_recovery.feature
Feature: Password recovery sends an email
Scenario: Requesting recovery for a known user delivers an email
Given a registered user "ada@example.com" with password "s3cret-passphrase"
When a password recovery is requested for "ada@example.com"
Then the response status is 200
And an email is delivered to "ada@example.com"
# features/steps/recovery_steps.py
import httpx
from behave import when, then
from app.core.config import settings
API = settings.API_V1_STR
MAILCATCHER = "http://localhost:1080" # MailCatcher's HTTP API in the dev stack
@when('a password recovery is requested for "{email}"')
def step_request_recovery(context, email):
context.response = context.client.post(f"{API}/password-recovery/{email}")
@then('an email is delivered to "{email}"')
def step_email_delivered(context, email):
# Requires emails to be enabled (SMTP configured at MailCatcher) and the
# dev stack running. Where that isn't available in CI, assert at the
# boundary instead — that the endpoint returned 200 and a token was issued.
messages = httpx.get(f"{MAILCATCHER}/messages").json()
recipients = [r for m in messages for r in m.get("recipients", [])]
assert any(email in r for r in recipients), recipients
This is the kind of test you cannot write cleanly at the unit level and would be slow and brittle to write through the browser. It is a natural fit for the acceptance layer: real endpoint, real side effect, asserted in plain language.
Running it, and wiring it into CI
From the backend directory, behave discovers the features/ folder automatically:
# run the whole suite
uv run behave
# run only scenarios tagged @smoke (tag them in the .feature files with @smoke)
uv run behave --tags=@smoke
# emit JUnit XML so CI can show per-scenario results
uv run behave --junit --junit-directory=reports/behave
In the template's GitHub Actions pipeline, add behave as its own step next to the pytest job, against the same Postgres service the backend tests already use. Because scenarios roll themselves back, the suite is safe to run repeatedly against a shared test database:
# .github/workflows/test-backend.yml (excerpt — add alongside the pytest step)
- name: Acceptance tests (behave)
working-directory: backend
run: uv run behave --junit --junit-directory=reports/behave
env:
POSTGRES_SERVER: localhost
POSTGRES_USER: postgres
POSTGRES_PASSWORD: changethis
POSTGRES_DB: app
# reuse the same env the existing backend test job sets
- name: Upload behave results
if: always()
uses: actions/upload-artifact@v4
with:
name: behave-results
path: backend/reports/behave
How much BDD is the right amount
BDD is not free. Every Gherkin phrase needs a step implementation, the indirection between sentence and code can obscure as much as it reveals, and a sprawling step library becomes its own maintenance burden. The failure mode is predictable: teams translate every pytest case into Gherkin, and end up with a slower, harder-to-read copy of a suite they already had.
To keep it worthwhile, reserve behave for behaviour that has contract value: authentication and authorisation rules, multi-step flows, anything a non-developer might need to confirm or sign off. Keep edge cases, error-message wording, and fine-grained validation in pytest, where they belong. Write scenarios about what the system does, never how, so the spec survives refactors. And share aggressively at the step level, as the common status-code step above does, so the library stays small.
Applied that way to the Full-Stack FastAPI template, behave gives you something the existing suites don't: a readable, runnable description of the application's contract (who can log in, who can see which items, what recovery actually does), exercised against the real app and checked on every push. "We think it works" becomes a specification anyone can read and the CI server can prove.