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 prettyFeature: 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.004splain
No colors, no special formatting. Good for log files and grep:
behave --format plainprogress
One character per step. Shows overall progress without scrolling pages of output:
behave --format progress
# ...F..progress3
Shows scenario-level progress:
behave --format progress3json
Machine-readable JSON output for downstream tooling:
behave --format json --outfile reports/results.jsonThe 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.jsonrerun
Writes a rerun file listing only the failed scenarios:
behave --format rerun --outfile reports/failed.txt
# Later, rerun only failed scenarios:
behave @reports/failed.txtThis 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.xmlEach 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/junitGitHub 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
slackOr run directly:
behave --format features.formatters.slack_formatter:SlackFormatterMarkdown 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 = trueA 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.