← All posts How-To

Adding BDD Acceptance Tests to FastAPI with behave

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.

← Back to all posts