Behave + Django: BDD Testing with the Django Test Runner

Behave + Django: BDD Testing with the Django Test Runner

behave-django hooks Behave into Django's test infrastructure — you get the test database, transactions, fixtures, and the Django test client without any manual wiring. This post covers the full setup, database strategies, authentication scenarios, and how to run Behave alongside your existing Django test suite.

Key Takeaways

  • behave-django gives you context.test (a Django TestCase instance), context.client (the test client), and context.response wired together
  • Wrap each scenario in a transaction and roll back instead of truncating — it's 10x faster
  • Use @django_db from pytest-django if you mix pytest-BDD with Django; use behave-django if you stay in Behave
  • Django fixtures (JSON/YAML files) load with context.fixtures = ["mydata.json"] in environment.py
  • Authentication: create a user in before_scenario, log in with context.client.force_login() — never hard-code credentials

Why behave-django Instead of Plain Behave?

Plain Behave has no concept of a Django application. You can import Django models in step definitions, but you have to manage the database connection, test runner lifecycle, and settings yourself. behave-django solves this by acting as a thin adapter: it starts the Django test runner, creates the test database, wraps each scenario in a transaction, and exposes Django's test client through context.

Install both:

pip install behave behave-django

Add to INSTALLED_APPS:

# settings.py
INSTALLED_APPS = [
    ...
    "behave_django",
]

Project Layout

myproject/
├── manage.py
├── myapp/
│   ├── models.py
│   ├── views.py
│   └── urls.py
├── features/
│   ├── environment.py
│   ├── steps/
│   │   ├── auth_steps.py
│   │   ├── user_steps.py
│   │   └── common_steps.py
│   ├── auth.feature
│   ├── users.feature
│   └── articles.feature
└── fixtures/
    └── initial_data.json

Run Behave through manage.py:

python manage.py behave
python manage.py behave --tags=@smoke
python manage.py behave features/auth.feature

This uses Django's test database — it creates a test_<DATABASES default NAME> and tears it down at the end.

environment.py

# features/environment.py
from django.contrib.auth import get_user_model

User = get_user_model()

def before_all(context):
    # Use Django's test client by default
    context.browser = None  # placeholder for Selenium if needed

def before_scenario(context, scenario):
    # behave-django gives us context.test — a live TestCase instance
    # and context.client — Django's test client
    context.response = None

    # Load fixtures if the scenario tag requests them
    if "fixtures" in scenario.tags:
        context.fixtures = ["initial_data.json"]

def after_scenario(context, scenario):
    if scenario.status == "failed" and context.response:
        print(f"\n[DEBUG] URL: {context.response.request.get('PATH_INFO', '?')}")
        print(f"[DEBUG] Status: {context.response.status_code}")
        try:
            import json
            data = json.loads(context.response.content)
            print(f"[DEBUG] Body: {data}")
        except Exception:
            print(f"[DEBUG] Body (raw): {context.response.content[:300]}")

Feature Files

Authentication Feature

# features/auth.feature
Feature: User authentication

  Scenario: Log in with valid credentials
    Given a user exists with email "alice@example.com" and password "secret123"
    When I submit the login form with email "alice@example.com" and password "secret123"
    Then I should be redirected to the dashboard
    And I should see "Welcome, alice"

  Scenario: Log in with wrong password
    Given a user exists with email "bob@example.com" and password "correcthorse"
    When I submit the login form with email "bob@example.com" and password "wrongpassword"
    Then I should see the login form again
    And I should see "Invalid credentials"

  Scenario: Access protected page while logged out
    Given I am not logged in
    When I visit "/dashboard/"
    Then I should be redirected to "/accounts/login/"

  Scenario: Log out
    Given I am logged in as "alice@example.com"
    When I POST to "/accounts/logout/"
    Then I should be redirected to "/"
    And I should not see "Welcome"

Article Management Feature

# features/articles.feature
Feature: Article management

  Background:
    Given I am logged in as an editor

  Scenario: Create a new article
    When I POST to "/articles/create/" with:
      | field   | value                       |
      | title   | My First Article            |
      | content | This is the article body.   |
      | status  | draft                       |
    Then the response status should be 302
    And an article titled "My First Article" should exist in the database
    And the article status should be "draft"

  Scenario: Publish an article
    Given an article titled "Draft Post" exists with status "draft"
    When I POST to "/articles/draft-post/publish/"
    Then the response status should be 302
    And the article "Draft Post" status should be "published"

  Scenario: Non-editor cannot publish
    Given I am logged in as a viewer
    And an article titled "Draft Post" exists with status "draft"
    When I POST to "/articles/draft-post/publish/"
    Then the response status should be 403

Step Definitions

Authentication Steps

# features/steps/auth_steps.py
from behave import given, when, then
from behave import parsers
from django.contrib.auth import get_user_model

User = get_user_model()

ROLE_EMAILS = {
    "editor": "editor@test.com",
    "viewer": "viewer@test.com",
    "admin": "admin@test.com",
}

def _get_or_create_user(email, password="testpass123", **kwargs):
    user, created = User.objects.get_or_create(
        email=email,
        defaults={"username": email.split("@")[0], **kwargs},
    )
    if created:
        user.set_password(password)
        user.save()
    return user

@given(parsers.parse('a user exists with email "{email}" and password "{password}"'))
def create_user(context, email, password):
    context.current_user = _get_or_create_user(email, password)

@given(parsers.parse('I am logged in as "{email}"'))
def login_as_email(context, email):
    user = _get_or_create_user(email)
    context.client.force_login(user)
    context.current_user = user

@given("I am logged in as an editor")
def login_as_editor(context):
    user = _get_or_create_user(
        ROLE_EMAILS["editor"],
        is_staff=False,
        # Assumes a UserProfile or Group with 'editor' permission
    )
    user.groups.clear()
    from django.contrib.auth.models import Group
    editor_group, _ = Group.objects.get_or_create(name="editors")
    user.groups.add(editor_group)
    context.client.force_login(user)
    context.current_user = user

@given("I am logged in as a viewer")
def login_as_viewer(context):
    user = _get_or_create_user(ROLE_EMAILS["viewer"])
    context.client.force_login(user)
    context.current_user = user

@given("I am not logged in")
def not_logged_in(context):
    context.client.logout()

@when(parsers.parse('I submit the login form with email "{email}" and password "{password}"'))
def submit_login_form(context, email, password):
    context.response = context.client.post(
        "/accounts/login/",
        {"email": email, "password": password},
        follow=False,
    )

@when(parsers.parse('I visit "{path}"'))
def visit_path(context, path):
    context.response = context.client.get(path, follow=False)

@when(parsers.parse('I POST to "{path}"'))
def post_to_path(context, path):
    context.response = context.client.post(path, follow=False)

@when(parsers.parse('I POST to "{path}" with'))
def post_with_table(context, path):
    data = {row["field"]: row["value"] for row in context.table}
    context.response = context.client.post(path, data, follow=False)

@then(parsers.parse('I should be redirected to "{url}"'))
def check_redirect(context, url):
    assert context.response.status_code in (301, 302), (
        f"Expected redirect, got {context.response.status_code}"
    )
    location = context.response.get("Location", "")
    assert url in location, f"Expected redirect to '{url}', got '{location}'"

@then("I should be redirected to the dashboard")
def check_redirect_dashboard(context):
    assert context.response.status_code in (301, 302)

@then(parsers.parse('I should see "{text}"'))
def check_content(context, text):
    # Follow the redirect if present
    if context.response.status_code in (301, 302):
        location = context.response.get("Location", "/")
        context.response = context.client.get(location)
    content = context.response.content.decode()
    assert text in content, f"Could not find '{text}' in response"

@then(parsers.parse('I should not see "{text}"'))
def check_no_content(context, text):
    if context.response.status_code in (301, 302):
        location = context.response.get("Location", "/")
        context.response = context.client.get(location)
    content = context.response.content.decode()
    assert text not in content, f"Should not see '{text}' but found it in response"

@then("I should see the login form again")
def check_login_form(context):
    assert context.response.status_code == 200
    content = context.response.content.decode()
    assert 'name="password"' in content, "Login form not found in response"

@then(parsers.parse("the response status should be {code:d}"))
def check_status(context, code):
    assert context.response.status_code == code, (
        f"Expected {code}, got {context.response.status_code}"
    )

Article Steps

# features/steps/article_steps.py
from behave import given, then
from behave import parsers
from myapp.models import Article

@given(parsers.parse('an article titled "{title}" exists with status "{status}"'))
def create_article(context, title, status):
    Article.objects.get_or_create(
        title=title,
        defaults={
            "content": "Placeholder content.",
            "slug": title.lower().replace(" ", "-"),
            "status": status,
            "author": context.current_user,
        },
    )

@then(parsers.parse('an article titled "{title}" should exist in the database'))
def check_article_exists(context, title):
    assert Article.objects.filter(title=title).exists(), (
        f"No article with title '{title}' found in the database"
    )

@then(parsers.parse('the article status should be "{status}"'))
def check_article_status(context, status):
    article = Article.objects.filter().order_by("-created_at").first()
    assert article is not None, "No articles found"
    assert article.status == status, f"Expected '{status}', got '{article.status}'"

@then(parsers.parse('the article "{title}" status should be "{status}"'))
def check_named_article_status(context, title, status):
    article = Article.objects.filter(title=title).first()
    assert article is not None, f"Article '{title}' not found"
    article.refresh_from_db()
    assert article.status == status, f"Expected '{status}', got '{article.status}'"

Database Strategies

Transaction Rollback (Default, Fastest)

behave-django wraps each scenario in a transaction and rolls back at the end. Database changes never persist between scenarios. No manual cleanup needed.

This is the default when you run python manage.py behave. To opt into it explicitly:

# environment.py
def before_scenario(context, scenario):
    context.test.databases = ["default"]  # which databases to wrap

TestCase with Truncation

For scenarios that use TRUNCATE or raw SQL, or when testing with SELECT FOR UPDATE (which conflicts with open transactions), switch to truncation:

# environment.py
def before_scenario(context, scenario):
    if "no_transaction" in scenario.tags:
        # This scenario manages its own transactions
        context.test._rollback_atomics(context.test.cls_atomics)

Tag specific scenarios: @no_transaction.

Loading Fixtures

# environment.py
def before_scenario(context, scenario):
    context.fixtures = []
    for tag in scenario.tags:
        if tag.startswith("fixture:"):
            fixture_name = tag.split(":", 1)[1]
            context.fixtures.append(fixture_name)
@fixture:initial_products
Scenario: Browse products
  When I visit "/products/"
  Then I should see "Widget Pro"

Generate fixture files from existing data:

python manage.py dumpdata myapp.Product --indent 2 > fixtures/initial_products.json

Factory Boy Instead of Fixtures

JSON fixtures are brittle — they break when you add non-nullable columns. Use factory_boy for programmatic fixture creation:

pip install factory-boy
# factories.py
import factory
from django.contrib.auth import get_user_model
from myapp.models import Article

class UserFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = get_user_model()

    username = factory.Sequence(lambda n: f"user{n}")
    email = factory.LazyAttribute(lambda obj: f"{obj.username}@test.com")
    password = factory.PostGenerationMethodCall("set_password", "testpass123")

class ArticleFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = Article

    title = factory.Sequence(lambda n: f"Article {n}")
    slug = factory.LazyAttribute(lambda obj: obj.title.lower().replace(" ", "-"))
    content = factory.Faker("paragraphs", nb=3, ext_word_list=None)
    author = factory.SubFactory(UserFactory)
    status = "draft"

Use factories in step definitions:

from factories import ArticleFactory, UserFactory

@given(parsers.parse('an article titled "{title}" exists with status "{status}"'))
def create_article(context, title, status):
    ArticleFactory(title=title, status=status, author=context.current_user)

Testing Django REST Framework APIs

For DRF endpoints, use the APIClient from rest_framework.test:

# environment.py
from rest_framework.test import APIClient as DRFClient

def before_all(context):
    pass

def before_scenario(context, scenario):
    context.client = DRFClient()
    context.response = None
# steps/api_steps.py
from behave import given, when, then, parsers
from django.contrib.auth import get_user_model

User = get_user_model()

@given("I am authenticated via token")
def auth_via_token(context):
    user = User.objects.create_user(
        username="apiuser", password="pass", email="api@test.com"
    )
    from rest_framework.authtoken.models import Token
    token, _ = Token.objects.get_or_create(user=user)
    context.client.credentials(HTTP_AUTHORIZATION=f"Token {token.key}")
    context.current_user = user

@when(parsers.parse('I GET the API endpoint "{path}"'))
def api_get(context, path):
    context.response = context.client.get(path, format="json")

@when(parsers.parse('I POST JSON to "{path}"'))
def api_post_json(context, path):
    data = {row["field"]: row["value"] for row in context.table}
    context.response = context.client.post(path, data, format="json")

@then(parsers.parse('the JSON field "{field}" should equal "{expected}"'))
def json_field_equals(context, field, expected):
    data = context.response.json()
    actual = str(data.get(field, ""))
    assert actual == expected, f"Field '{field}': expected '{expected}', got '{actual}'"

Running in CI

# .github/workflows/bdd-tests.yml
name: BDD Tests

on: [push, pull_request]

jobs:
  behave:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: test
          POSTGRES_DB: myproject_test
        ports: ["5432:5432"]
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
    env:
      DATABASE_URL: postgresql://postgres:test@localhost/myproject_test
      DJANGO_SETTINGS_MODULE: myproject.settings.test
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install -r requirements-test.txt
      - run: python manage.py migrate --run-syncdb
      - run: python manage.py behave --no-capture --verbosity 2

Common Pitfalls

DatabaseWrapper errors in step definitions. If you access django.db.connection directly outside the test transaction, you may see connection errors. Always go through the ORM or context.client — never open raw connections in steps.

force_login vs login. force_login(user) skips password validation — use it in tests. client.login(username=..., password=...) goes through the full authentication backend, which is slower and can fail if your backend requires HTTP-only cookies.

Fixture conflicts. If two scenarios both create a user with username="admin" and the field has a unique constraint, the second scenario fails with an IntegrityError even inside a transaction. Use get_or_create or factory_boy sequences to avoid collisions.

Not calling refresh_from_db(). After a POST that updates a model, your in-memory object is stale. Always call obj.refresh_from_db() before asserting on database state.

Conclusion

behave-django eliminates the boilerplate between Behave and Django's test infrastructure. With transaction rollback per scenario, force_login for authentication, and factory_boy for data setup, you get fast, isolated, human-readable BDD tests that slot into any Django project. The feature files become acceptance criteria that both developers and product managers can read and verify.

Read more

Start now free