Behave + Requests: BDD Testing for REST APIs

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 context object, not in global state
  • Use before_scenario hooks 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.ini

behave.ini keeps configuration out of the command line:

[behave]
paths = features
stdout_capture = false
stderr_capture = false
log_capture = false

The 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 401

Step 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 = None

CI 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-capture

Common 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.

Read more

Start now free