Behave Reporting: Formatters, Allure, JUnit XML, and CI Integration

Behave Reporting: Formatters, Allure, JUnit XML, and CI Integration

Test reports serve two audiences: developers triaging failures and stakeholders tracking quality. Behave's built-in formatters handle the first reasonably well. For the second — and for modern CI pipelines — you need JUnit XML, Allure, or a custom formatter. This post covers the full reporting stack.

Built-in Formatters

Behave ships with several formatters selected via --format:

pretty (default)

The standard colorized output for local development:

behave --format pretty
Feature: Shopping cart  # features/shopping_cart.feature:1

  Scenario: Add item to cart          # features/shopping_cart.feature:5
    Given I have an empty cart        # features/steps/cart_steps.py:8 0.001s
    When I add "Laptop" to the cart   # features/steps/cart_steps.py:13 0.002s
    Then the cart total should be 999 # features/steps/cart_steps.py:18 0.001s

1 feature passed, 0 failed, 0 skipped
1 scenario passed, 0 failed, 0 skipped
3 steps passed, 0 failed, 0 skipped, 0 undefined
Took 0m0.004s

plain

No colors, no special formatting. Good for log files and grep:

behave --format plain

progress

One character per step. Shows overall progress without scrolling pages of output:

behave --format progress
# ...F..

progress3

Shows scenario-level progress:

behave --format progress3

json

Machine-readable JSON output for downstream tooling:

behave --format json --outfile reports/results.json

The JSON structure:

[
  {
    "keyword": "Feature",
    "name": "Shopping cart",
    "filename": "features/shopping_cart.feature",
    "line": 1,
    "elements": [
      {
        "keyword": "Scenario",
        "name": "Add item to cart",
        "line": 5,
        "status": "passed",
        "steps": [
          {
            "keyword": "Given",
            "name": "I have an empty cart",
            "result": {
              "status": "passed",
              "duration": 0.001234
            }
          }
        ]
      }
    ]
  }
]

json.pretty

Formatted JSON, easier to read in diffs:

behave --format json.pretty --outfile reports/results.json

rerun

Writes a rerun file listing only the failed scenarios:

behave --format rerun --outfile reports/failed.txt
# Later, rerun only failed scenarios:
behave @reports/failed.txt

This is the fastest feedback loop when debugging failures: run the full suite, then iterate only on failures.

JUnit XML Output

JUnit XML is the lingua franca of CI test reporting. Jenkins, GitHub Actions, GitLab CI, CircleCI, and most other systems can parse it natively.

Enable with --junit:

behave --junit --junit-directory reports/junit/

This generates one XML file per feature file:

reports/junit/
├── TESTS-features.shopping_cart.xml
└── TESTS-features.checkout.xml

Each file follows the standard JUnit schema:

<?xml version="1.0" encoding="UTF-8"?>
<testsuite name="shopping_cart" tests="3" errors="0" failures="1" skipped="0"
           time="0.045">
  <testcase classname="shopping_cart" name="Add item to cart" time="0.012">
  </testcase>
  <testcase classname="shopping_cart" name="Add multiple items" time="0.015">
  </testcase>
  <testcase classname="shopping_cart" name="Out of stock error" time="0.018">
    <failure type="AssertionError" message="Expected error 'Item out of stock', got None">
      Traceback (most recent call last):
        ...
      AssertionError: Expected error 'Item out of stock', got None
    </failure>
  </testcase>
</testsuite>

Multiple Output Formats Together

Use multiple --format and --outfile flags to generate several report types in one run:

behave \
  --format pretty \
  --format json.pretty --outfile reports/results.json \
  --junit --junit-directory reports/junit/

Or put it in behave.ini:

[behave]
format   = pretty
          json.pretty
outfile  = reports/results.json
junit    = true
junit_directory = reports/junit

GitHub Actions Integration

# .github/workflows/test.yml
name: BDD Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.12'
      
      - name: Install dependencies
        run: pip install -r requirements.txt
      
      - name: Run Behave tests
        run: |
          mkdir -p reports/junit
          behave --junit --junit-directory reports/junit/ --format json --outfile reports/results.json
      
      - name: Publish test results
        uses: EnricoMi/publish-unit-test-result-action@v2
        if: always()
        with:
          files: reports/junit/*.xml
      
      - name: Upload reports
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: test-reports
          path: reports/

Allure Integration

Allure is the most capable test reporting framework available for Behave. It produces interactive HTML reports with:

  • Step-by-step timeline
  • Attachments (screenshots, logs, JSON payloads)
  • History and trend graphs across runs
  • Severity and feature categorization

Installation

pip install allure-behave

# Allure CLI (for generating HTML from results)
# macOS:
brew install allure
# Linux:
wget https://github.com/allure-framework/allure2/releases/latest/download/allure-2.x.x.tgz
tar -xzf allure-*.tgz
sudo mv allure-*/bin/allure /usr/local/bin/

Running with Allure

# Generate raw results
behave -f allure_behave.formatter:AllureFormatter -o reports/allure-results/

# Generate HTML report from results
allure generate reports/allure-results/ -o reports/allure-report/ --clean

# Open report
allure open reports/allure-report/

Or serve directly without generating static files:

allure serve reports/allure-results/

Allure Decorators in Steps

Allure has Python decorators for richer reporting:

import allure
from behave import given, when, then


@given('the product catalog is loaded')
def step_load_catalog(context):
    with allure.step("Loading product catalog from database"):
        context.catalog = context.db.load_catalog()
    
    allure.attach(
        str(len(context.catalog.products)),
        name="Products loaded",
        attachment_type=allure.attachment_type.TEXT
    )


@when('I search for "{query}"')
def step_search(context, query):
    with allure.step(f"Searching for: {query}"):
        context.search_results = context.api.search(query)
        
        # Attach request/response for debugging
        allure.attach(
            context.search_results.request.body or '',
            name="Request body",
            attachment_type=allure.attachment_type.JSON
        )
        allure.attach(
            context.search_results.text,
            name="Response body",
            attachment_type=allure.attachment_type.JSON
        )


@then('results should include "{name}"')
def step_results_include(context, name):
    names = [r['name'] for r in context.search_results.json()['items']]
    assert name in names, f"'{name}' not found in: {names}"

Screenshots on Failure with Allure

In environment.py:

import allure

def after_step(context, step):
    if step.status == 'failed' and hasattr(context, 'driver'):
        screenshot = context.driver.get_screenshot_as_png()
        allure.attach(
            screenshot,
            name=f"Screenshot: {step.name}",
            attachment_type=allure.attachment_type.PNG
        )

def after_scenario(context, scenario):
    if scenario.status == 'failed' and hasattr(context, 'driver'):
        # Full-page screenshot
        context.driver.execute_script(
            "document.body.style.overflow = 'visible';"
        )
        screenshot = context.driver.get_screenshot_as_png()
        allure.attach(
            screenshot,
            name=f"Final state: {scenario.name}",
            attachment_type=allure.attachment_type.PNG
        )
        
        # Browser logs
        logs = context.driver.get_log('browser')
        if logs:
            allure.attach(
                '\n'.join(f"[{l['level']}] {l['message']}" for l in logs),
                name="Browser console logs",
                attachment_type=allure.attachment_type.TEXT
            )

Allure Labels and Severity

Map Gherkin tags to Allure metadata:

def before_scenario(context, scenario):
    # Map tags to Allure severity
    severity_map = {
        'critical': allure.severity_level.CRITICAL,
        'high': allure.severity_level.NORMAL,
        'low': allure.severity_level.MINOR,
    }
    for tag, severity in severity_map.items():
        if tag in scenario.tags:
            allure.dynamic.severity(severity)
    
    # Map feature tags
    if 'cart' in scenario.tags:
        allure.dynamic.feature('Shopping Cart')
    elif 'checkout' in scenario.tags:
        allure.dynamic.feature('Checkout')
    
    # Story from scenario name
    allure.dynamic.story(scenario.name)

Gherkin tags become Allure labels in the report, making it easy to filter by feature, severity, or story.

Custom Formatters

Write a custom formatter to produce any output format you need. Formatters extend behave.formatter.base.Formatter:

# features/formatters/slack_formatter.py

from behave.formatter.base import Formatter
import requests
import os


class SlackFormatter(Formatter):
    """Posts test results summary to Slack."""
    
    name = "slack"
    description = "Posts results to Slack"
    
    def __init__(self, stream, config):
        super().__init__(stream, config)
        self.webhook_url = os.environ.get('SLACK_WEBHOOK_URL', '')
        self.passed = 0
        self.failed = 0
        self.failures = []
    
    def scenario(self, scenario):
        self._current_scenario = scenario
    
    def result(self, step):
        if step.status == 'failed':
            self.failed += 1
            self.failures.append({
                'scenario': self._current_scenario.name,
                'step': step.name,
                'error': str(step.exception)[:200] if step.exception else ''
            })
        elif step.status == 'passed':
            self.passed += 1
    
    def close(self):
        if not self.webhook_url:
            return
        
        total = self.passed + self.failed
        emoji = ":white_check_mark:" if self.failed == 0 else ":x:"
        
        blocks = [
            {
                "type": "header",
                "text": {"type": "plain_text", "text": f"{emoji} BDD Test Results"}
            },
            {
                "type": "section",
                "text": {
                    "type": "mrkdwn",
                    "text": (
                        f"*Passed:* {self.passed}/{total}  "
                        f"*Failed:* {self.failed}/{total}"
                    )
                }
            }
        ]
        
        if self.failures:
            failure_text = "\n".join(
                f"• *{f['scenario']}*\n  `{f['step']}`\n  {f['error']}"
                for f in self.failures[:5]
            )
            blocks.append({
                "type": "section",
                "text": {"type": "mrkdwn", "text": f"*Failures:*\n{failure_text}"}
            })
        
        requests.post(self.webhook_url, json={"blocks": blocks})

Register it in behave.ini:

[behave]
format = pretty
         slack

Or run directly:

behave --format features.formatters.slack_formatter:SlackFormatter

Markdown Formatter Example

# features/formatters/markdown_formatter.py

from behave.formatter.base import Formatter
from datetime import datetime


class MarkdownFormatter(Formatter):
    name = "markdown"
    description = "Produces a Markdown test report"
    
    def __init__(self, stream, config):
        super().__init__(stream, config)
        self.stream = stream
        self._current_feature = None
        self._current_scenario = None
        self.started_at = datetime.now()
        
        self.stream.write(f"# Test Report\n\n")
        self.stream.write(f"Run at: {self.started_at.isoformat()}\n\n")
    
    def feature(self, feature):
        self._current_feature = feature
        self.stream.write(f"## {feature.name}\n\n")
    
    def scenario(self, scenario):
        self._current_scenario = scenario
        self.stream.write(f"### {scenario.name}\n\n")
        self.stream.write("| Step | Status | Duration |\n")
        self.stream.write("|------|--------|----------|\n")
    
    def result(self, step):
        status_emoji = {
            'passed': '✅',
            'failed': '❌',
            'skipped': '⏭️',
            'undefined': '❓',
        }.get(step.status.name, '?')
        
        duration = f"{step.duration:.3f}s" if step.duration else "-"
        self.stream.write(
            f"| {step.keyword} {step.name} | {status_emoji} {step.status.name} | {duration} |\n"
        )
        
        if step.status.name == 'failed' and step.exception:
            self.stream.write(f"\n```\n{step.error_message}\n```\n\n")
    
    def eof(self):
        self.stream.write("\n")

Screenshots on Failure (Without Allure)

If you're not using Allure, capture screenshots in after_step and save them to disk:

# features/environment.py

import os
from datetime import datetime

SCREENSHOT_DIR = "reports/screenshots"


def before_all(context):
    os.makedirs(SCREENSHOT_DIR, exist_ok=True)


def after_step(context, step):
    if step.status == 'failed' and hasattr(context, 'driver'):
        timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
        scenario_slug = context.scenario.name.replace(' ', '_').replace('/', '_')
        filename = f"{SCREENSHOT_DIR}/{timestamp}_{scenario_slug}.png"
        
        try:
            context.driver.save_screenshot(filename)
            print(f"\nScreenshot saved: {filename}")
        except Exception as e:
            print(f"\nFailed to save screenshot: {e}")

Reference the screenshot path in JUnit XML by embedding it in the failure message:

def after_step(context, step):
    if step.status == 'failed' and hasattr(context, 'driver'):
        path = save_screenshot(context)
        # Append to error message so it shows in JUnit XML
        if step.exception:
            step.exception.args = (
                str(step.exception.args[0]) + f"\nScreenshot: {path}",
            )

Behave.ini Reference for Reporting

[behave]
# Output formats
format          = pretty

# JUnit XML
junit           = true
junit_directory = reports/junit

# JSON
outfile         = reports/results.json

# Timing
show_timings    = true

# Capture stdout/stderr from steps (shown on failure)
stdout_capture  = true
stderr_capture  = true
log_capture     = true
log_level       = WARNING

# Stop on first failure (disable for full reports)
stop            = false

# Show skipped scenarios
show_skipped    = true

A solid reporting setup for CI is: pretty for console, JUnit XML for the CI test result panel, and either Allure or artifact uploads for deep dives into failures. Add screenshots for any browser-based tests — they're the single highest-value debugging tool when a test fails in CI but not locally.

Read more

Start now free