Behave + Requests: BDD Testing for REST APIs
Behave and the Python requests library are a natural pairing for API testing. You write scenarios in plain Gherkin that non-developers can read, while step definitions handle the HTTP calls. This post walks through project setup, reusable context objects, authentication flows, and error-case testing — all with working code.
Key Takeaways
- Store the base URL and session in Behave's
contextobject, not in global state - Use
before_scenariohooks to reset shared state between tests - Scenario Outlines let you run the same API flow against multiple payloads without duplication
- Assert on status code first, then parse JSON — this gives clearer failure messages
- Keep step definitions thin; push HTTP logic into a helper module so steps read like prose
Why BDD and REST APIs Belong Together
API contracts are specifications. A specification written in Gherkin — Given a resource exists, When I request it, Then I receive the expected JSON — is far closer to the original requirement than a unittest method named test_get_user_returns_200. When a product manager writes acceptance criteria, they are already writing BDD scenarios without knowing it.
The practical payoff: your feature files become living documentation that tracks the actual API behaviour, and any breaking change surfaces as a failing scenario with a human-readable description of what broke.
Project Layout
api-tests/
├── features/
│ ├── environment.py # hooks: before_all, before_scenario, after_scenario
│ ├── steps/
│ │ ├── auth_steps.py
│ │ ├── user_steps.py
│ │ └── common_steps.py
│ ├── users.feature
│ └── auth.feature
├── helpers/
│ └── api_client.py
└── behave.inibehave.ini keeps configuration out of the command line:
[behave]
paths = features
stdout_capture = false
stderr_capture = false
log_capture = falseThe API Client Helper
Step definitions should read like English. HTTP boilerplate belongs in a helper:
# helpers/api_client.py
import requests
class APIClient:
def __init__(self, base_url: str, timeout: int = 10):
self.base_url = base_url.rstrip("/")
self.session = requests.Session()
self.timeout = timeout
def set_token(self, token: str):
self.session.headers.update({"Authorization": f"Bearer {token}"})
def clear_auth(self):
self.session.headers.pop("Authorization", None)
def get(self, path: str, **kwargs):
return self.session.get(
f"{self.base_url}{path}", timeout=self.timeout, **kwargs
)
def post(self, path: str, json=None, **kwargs):
return self.session.post(
f"{self.base_url}{path}", json=json, timeout=self.timeout, **kwargs
)
def put(self, path: str, json=None, **kwargs):
return self.session.put(
f"{self.base_url}{path}", json=json, timeout=self.timeout, **kwargs
)
def delete(self, path: str, **kwargs):
return self.session.delete(
f"{self.base_url}{path}", timeout=self.timeout, **kwargs
)Environment Hooks
environment.py is Behave's lifecycle file. Use it to wire up the client and tear down state:
# features/environment.py
import os
from helpers.api_client import APIClient
BASE_URL = os.environ.get("API_BASE_URL", "http://localhost:8000")
def before_all(context):
context.client = APIClient(BASE_URL)
def before_scenario(context, scenario):
# Reset response state so scenarios don't leak into each other
context.response = None
context.response_json = None
context.client.clear_auth()
def after_scenario(context, scenario):
if scenario.status == "failed":
if context.response is not None:
print(f"\n[DEBUG] Last response: {context.response.status_code}")
try:
print(f"[DEBUG] Body: {context.response.json()}")
except Exception:
print(f"[DEBUG] Body: {context.response.text}")Writing Feature Files
User CRUD
# features/users.feature
Feature: User management API
As an API consumer
I want to create, read, update and delete users
So that I can manage user accounts programmatically
Background:
Given I am authenticated as an admin
Scenario: Create a new user
When I POST to "/users" with:
| field | value |
| username | alice |
| email | alice@example.com |
| role | viewer |
Then the response status should be 201
And the response should contain "id"
And the response field "username" should equal "alice"
Scenario: Retrieve an existing user
Given a user exists with username "bob"
When I GET "/users/bob"
Then the response status should be 200
And the response field "email" should equal "bob@example.com"
Scenario: Update a user's role
Given a user exists with username "carol"
When I PUT to "/users/carol" with:
| field | value |
| role | editor |
Then the response status should be 200
And the response field "role" should equal "editor"
Scenario: Delete a user
Given a user exists with username "dave"
When I DELETE "/users/dave"
Then the response status should be 204
Scenario: Request a non-existent user
When I GET "/users/ghost-user-99999"
Then the response status should be 404
And the response field "error" should equal "not_found"Authentication Feature
# features/auth.feature
Feature: Authentication
Scenario: Login with valid credentials
When I POST to "/auth/login" with:
| field | value |
| username | admin |
| password | secret123 |
Then the response status should be 200
And the response should contain "access_token"
Scenario: Login with wrong password
When I POST to "/auth/login" with:
| field | value |
| username | admin |
| password | wrong |
Then the response status should be 401
And the response field "error" should equal "invalid_credentials"
Scenario: Access protected endpoint without token
Given I am not authenticated
When I GET "/users"
Then the response status should be 401Step Definitions
Common Steps
# features/steps/common_steps.py
from behave import given, when, then
import json
@given('I am not authenticated')
def step_not_authenticated(context):
context.client.clear_auth()
@when('I GET "{path}"')
def step_get(context, path):
context.response = context.client.get(path)
try:
context.response_json = context.response.json()
except Exception:
context.response_json = None
@when('I DELETE "{path}"')
def step_delete(context, path):
context.response = context.client.delete(path)
try:
context.response_json = context.response.json()
except Exception:
context.response_json = None
@when('I POST to "{path}" with')
def step_post_with_table(context, path):
payload = {row["field"]: row["value"] for row in context.table}
context.response = context.client.post(path, json=payload)
try:
context.response_json = context.response.json()
except Exception:
context.response_json = None
@when('I PUT to "{path}" with')
def step_put_with_table(context, path):
payload = {row["field"]: row["value"] for row in context.table}
context.response = context.client.put(path, json=payload)
try:
context.response_json = context.response.json()
except Exception:
context.response_json = None
@then('the response status should be {status:d}')
def step_check_status(context, status):
actual = context.response.status_code
assert actual == status, (
f"Expected status {status}, got {actual}. "
f"Body: {context.response.text[:500]}"
)
@then('the response should contain "{key}"')
def step_response_contains_key(context, key):
assert context.response_json is not None, "Response body is not JSON"
assert key in context.response_json, (
f"Key '{key}' not found in response: {context.response_json}"
)
@then('the response field "{field}" should equal "{expected}"')
def step_response_field_equals(context, field, expected):
assert context.response_json is not None, "Response body is not JSON"
actual = str(context.response_json.get(field, ""))
assert actual == expected, (
f"Field '{field}': expected '{expected}', got '{actual}'"
)Auth Steps
# features/steps/auth_steps.py
from behave import given
import os
ADMIN_USER = os.environ.get("ADMIN_USER", "admin")
ADMIN_PASS = os.environ.get("ADMIN_PASS", "secret123")
@given('I am authenticated as an admin')
def step_auth_as_admin(context):
response = context.client.post(
"/auth/login",
json={"username": ADMIN_USER, "password": ADMIN_PASS},
)
assert response.status_code == 200, (
f"Admin login failed: {response.status_code} {response.text}"
)
token = response.json()["access_token"]
context.client.set_token(token)User Steps
# features/steps/user_steps.py
from behave import given
USER_FIXTURES = {
"bob": {"username": "bob", "email": "bob@example.com", "role": "viewer"},
"carol": {"username": "carol", "email": "carol@example.com", "role": "viewer"},
"dave": {"username": "dave", "email": "dave@example.com", "role": "viewer"},
}
@given('a user exists with username "{username}"')
def step_user_exists(context, username):
data = USER_FIXTURES.get(username, {
"username": username,
"email": f"{username}@example.com",
"role": "viewer",
})
response = context.client.post("/users", json=data)
# 201 = created, 409 = already exists — both are fine for a Given step
assert response.status_code in (201, 409), (
f"Could not ensure user '{username}' exists: {response.status_code}"
)
# Store the user id in context for later steps
if response.status_code == 201:
context.created_user_id = response.json().get("id")Scenario Outlines for Data-Driven API Tests
When you need to verify the same endpoint with multiple payloads, Scenario Outlines eliminate copy-paste:
Feature: User creation validation
Scenario Outline: Invalid user creation is rejected
Given I am authenticated as an admin
When I POST to "/users" with:
| field | value |
| username | <name> |
| email | <email> |
| role | <role> |
Then the response status should be 422
And the response field "error" should equal "<error>"
Examples:
| name | email | role | error |
| | valid@test.com | viewer | missing_username|
| alice | not-an-email | viewer | invalid_email |
| alice | valid@test.com | emperor | invalid_role |
| a | valid@test.com | viewer | username_too_short |Running the Tests
# Run all API scenarios
behave features/
# Run only scenarios tagged @smoke
behave features/ --tags=@smoke
# Run a single feature file
behave features/users.feature
# Verbose output with step timing
behave features/ --verbose
# Stop on first failure
behave features/ --stop
# Against a staging environment
API_BASE_URL=https://staging.example.com behave features/Tag individual scenarios to create a smoke suite:
@smoke
Scenario: Create a new user
...
@smoke @auth
Scenario: Login with valid credentials
...Handling Pagination and Complex Responses
For list endpoints that return paginated JSON:
@then('the response should contain {count:d} users')
def step_response_user_count(context, count):
data = context.response_json
# Handle both {"users": [...]} and direct arrays
items = data.get("users", data) if isinstance(data, dict) else data
assert len(items) == count, f"Expected {count} users, got {len(items)}"
@then('the first user\'s "{field}" should equal "{expected}"')
def step_first_item_field(context, field, expected):
data = context.response_json
items = data.get("users", data) if isinstance(data, dict) else data
assert items, "Response contains no items"
actual = str(items[0].get(field, ""))
assert actual == expected, f"First item field '{field}': expected '{expected}', got '{actual}'"Testing File Uploads
Scenario: Upload a user avatar
Given I am authenticated as an admin
When I upload the file "fixtures/avatar.png" to "/users/alice/avatar"
Then the response status should be 200
And the response should contain "avatar_url"@when('I upload the file "{filepath}" to "{path}"')
def step_upload_file(context, filepath, path):
import os
full_path = os.path.join(os.path.dirname(__file__), "..", filepath)
with open(full_path, "rb") as f:
context.response = context.client.session.post(
f"{context.client.base_url}{path}",
files={"file": ("avatar.png", f, "image/png")},
timeout=context.client.timeout,
)
try:
context.response_json = context.response.json()
except Exception:
context.response_json = NoneCI Integration
# .github/workflows/api-tests.yml
name: API Tests
on: [push, pull_request]
jobs:
api-tests:
runs-on: ubuntu-latest
services:
api:
image: myapp:latest
ports: ["8000:8000"]
env:
DATABASE_URL: sqlite:///test.db
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install behave requests
- run: |
API_BASE_URL=http://localhost:8000 \
ADMIN_USER=admin \
ADMIN_PASS=secret123 \
behave features/ --no-captureCommon Pitfalls
Leaking state between scenarios. If a POST in one scenario creates a resource that causes a 409 in the next, your before_scenario hook isn't resetting enough. Use database truncation or a dedicated test schema and clean it in before_scenario.
Hard-coded IDs. Never write When I GET "/users/42" — the ID will differ between environments. Create the resource in a Given step and store the returned ID in context.
Asserting on the full response body. Prefer field-level assertions. They survive schema additions without breaking.
Not checking status before parsing JSON. If the server returns a 500 HTML error page, response.json() raises an exception that hides the real failure. Always assert status code first.
Wrapping Up
Behave with requests gives you API tests that are readable by anyone on the team, executable in CI, and maintainable because the step library grows reusable pieces rather than duplicated HTTP calls. Start with the environment.py pattern shown here, keep step definitions thin, and use Scenario Outlines wherever you have more than two examples of the same flow.