Robot Framework CI/CD Integration and Test Reporting
Getting Robot Framework into CI and making the results useful are two different problems. Running RF in CI is straightforward — robot tests/ and done. Making those results actionable, fast, and properly gated takes more work. This post covers both.
What Robot Framework Produces
Every RF execution generates three output files:
output.xml— machine-readable execution data. Everything else is derived from this.log.html— detailed execution log with keyword-by-keyword traces, screenshots (if captured), timestamps, and pass/fail status for every keyword. This is what you open when debugging a failure.report.html— high-level summary: test counts, pass rates, elapsed time, tag-based breakdowns.
These files are your primary artifacts. Preserve them in CI — they're the only way to investigate failures without re-running tests.
GitHub Actions Integration
Basic setup:
# .github/workflows/robot-tests.yml
name: Robot Framework Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Cache pip packages
uses: actions/cache@v3
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
- name: Install dependencies
run: pip install -r requirements.txt
- name: Set up Chrome
uses: browser-actions/setup-chrome@v1
- name: Run Robot Framework tests
env:
BASE_URL: ${{ secrets.STAGING_URL }}
API_KEY: ${{ secrets.API_KEY }}
run: |
robot \
--variable BROWSER:headlesschrome \
--variable BASE_URL:$BASE_URL \
--outputdir results/ \
--xunit xunit-results.xml \
tests/
continue-on-error: true # Don't fail the step — we gate via the next step
- name: Check test results
run: |
python -c "
import xml.etree.ElementTree as ET
tree = ET.parse('results/output.xml')
root = tree.getroot()
stats = root.find('.//statistics/total/stat')
passed = int(stats.get('pass', 0))
failed = int(stats.get('fail', 0))
total = passed + failed
print(f'Results: {passed}/{total} passed')
if failed > 0:
print(f'FAILED: {failed} tests failed')
exit(1)
"
- name: Upload test results
uses: actions/upload-artifact@v3
if: always() # Upload even on failure
with:
name: robot-test-results
path: |
results/log.html
results/report.html
results/output.xml
retention-days: 30
- name: Publish JUnit results
uses: mikepenz/action-junit-report@v3
if: always()
with:
report_paths: results/xunit-results.xmlThe --xunit flag generates JUnit-compatible XML that GitHub's native test reporting can display. Combined with mikepenz/action-junit-report, you get inline test results on the PR.
Multi-environment Strategy
Testing against multiple environments from one workflow:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
environment: [staging, production]
browser: [headlesschrome, headlessfirefox]
fail-fast: false # Run all combinations even if one fails
steps:
- uses: actions/checkout@v4
- name: Run tests
env:
BASE_URL: ${{ matrix.environment == 'staging' && secrets.STAGING_URL || secrets.PROD_URL }}
run: |
robot \
--variable BROWSER:${{ matrix.browser }} \
--variable BASE_URL:$BASE_URL \
--outputdir results/${{ matrix.environment }}-${{ matrix.browser }}/ \
--include smoke \
tests/
- name: Upload results
uses: actions/upload-artifact@v3
if: always()
with:
name: results-${{ matrix.environment }}-${{ matrix.browser }}
path: results/${{ matrix.environment }}-${{ matrix.browser }}/Jenkins Integration
For Jenkins, the Robot Framework plugin provides native result visualization:
// Jenkinsfile
pipeline {
agent any
environment {
STAGING_URL = credentials('staging-url')
API_KEY = credentials('api-key')
}
stages {
stage('Setup') {
steps {
sh 'pip install -r requirements.txt'
}
}
stage('Run Tests') {
steps {
sh """
robot \
--variable BROWSER:headlesschrome \
--variable BASE_URL:${STAGING_URL} \
--outputdir results/ \
tests/ || true
"""
}
}
stage('Publish Results') {
steps {
step([
$class: 'RobotPublisher',
outputPath: 'results',
outputFileName: 'output.xml',
reportFileName: 'report.html',
logFileName: 'log.html',
disableArchiveOutput: false,
passThreshold: 90.0, // Build UNSTABLE below 90%
unstableThreshold: 80.0, // Build FAILED below 80%
otherFiles: '**/*.png'
])
}
}
}
post {
always {
archiveArtifacts artifacts: 'results/**', allowEmptyArchive: true
}
}
}The passThreshold and unstableThreshold give you graduated failure modes — don't flip to red on a single flaky test, but do fail the build if the suite is significantly broken.
Understanding log.html vs report.html vs output.xml
log.html is for debugging. It shows:
- Every keyword executed, with arguments and return values
- Nested keyword call trees (expand to see inner calls)
- Timestamps for every step
- Embedded screenshots on failure (if configured)
- Full error messages and stack traces
- PASS/FAIL status at every level
When a test fails in CI, log.html is the first file you open. Download it from the artifacts, open in a browser, find the failed test, expand the keyword tree.
report.html is for status reporting. It shows:
- Total pass/fail counts
- Tag-based breakdowns (how many
smoketests passed, how manyregressiontests failed) - Execution time per suite
- Trend data if configured
Share report.html with stakeholders. Open log.html yourself.
output.xml is machine-readable source data. It's the input for:
rebot(RF's report regeneration tool)- Allure integration
- Custom reporting scripts
- Merging results from parallel runs
pabot: Parallel Execution
Robot Framework runs tests serially by default. For large suites, pabot (Parallel Robot Framework Executor) runs test suites in parallel:
pip install robotframework-pabotBasic parallel execution:
# Run test suites in parallel (one suite per process)
pabot --processes 4 tests/
# Run individual test cases in parallel
pabot --testlevelsplit --processes 8 tests/
# Specify output directory
pabot --processes 4 --outputdir results/ tests/pabot's key feature: shared resources
When tests run in parallel, they can conflict over shared resources (same user account, same test data). pabot provides a resource allocation system:
# tests/checkout/checkout_test.robot
*** Settings ***
Library pabot.PabotLib
*** Test Cases ***
Complete Checkout Flow
Acquire Lock checkout-user-slot-1
# Only one parallel process holds this lock at a time
Login As Test User checkout1@example.com password
# ... test steps ...
[Teardown] Release Lock checkout-user-slot-1For suite-level setup that should only run once across all parallel processes:
*** Settings ***
Library pabot.PabotLib
Suite Setup Run Setup Only Once Initialize Test Database
Suite Teardown Run Teardown Only Once Cleanup Test Databasepabot GitHub Actions:
- name: Run tests in parallel
run: |
pabot \
--processes ${{ runner.cpu_count }} \
--variable BROWSER:headlesschrome \
--outputdir results/ \
tests/Allure Integration
Allure produces richer reports than RF's built-in output — better for stakeholders, supports history trending, and integrates with Allure TestOps.
pip install allure-robotframeworkGenerate Allure results alongside RF output:
robot \
--listener allure_robotframework \
--outputdir results/ \
tests/This creates output/allure directory with Allure-format JSON files. Serve the report:
allure serve output/allureOr generate static HTML:
allure generate output/allure -o allure-report/ --cleanGitHub Actions with Allure:
- name: Run tests with Allure
run: |
robot \
--listener allure_robotframework \
--outputdir results/ \
tests/
continue-on-error: true
- name: Generate Allure report
uses: simple-elf/allure-report-action@master
if: always()
with:
allure_results: output/allure
allure_history: allure-history
- name: Deploy Allure report to GitHub Pages
uses: peaceiris/actions-gh-pages@v3
if: always()
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_branch: gh-pages
publish_dir: allure-historyThis publishes Allure reports to GitHub Pages with run-over-run history.
Allure annotations in test files:
*** Settings ***
Library allure_robotframework
*** Test Cases ***
User Can Complete Purchase
[Tags] allure.label:epic:E-commerce allure.label:feature:Checkout
[Documentation] Complete purchase flow from cart to confirmation
Allure.Step Add item to cart
Add Item To Cart product_id=123
Allure.Step Proceed to checkout
Go To Checkout
Allure.Step Complete payment
Enter Payment Details ${TEST_CARD}
Submit Order
Allure.Step Verify confirmation
Order Confirmation Should Be DisplayedTest Result Thresholds and Gating
Don't accept all failures equally. Gate your build on pass rates, not binary pass/fail:
# scripts/check_results.py
import sys
import xml.etree.ElementTree as ET
def check_results(output_xml, pass_threshold=95, unstable_threshold=85):
tree = ET.parse(output_xml)
root = tree.getroot()
stats = root.find('.//statistics/total/stat')
passed = int(stats.get('pass', 0))
failed = int(stats.get('fail', 0))
total = passed + failed
if total == 0:
print("No tests found — failing build")
return 2
pass_rate = (passed / total) * 100
print(f"Pass rate: {pass_rate:.1f}% ({passed}/{total})")
if pass_rate >= pass_threshold:
print("PASS: Above threshold")
return 0
elif pass_rate >= unstable_threshold:
print(f"UNSTABLE: Pass rate below {pass_threshold}%")
return 1
else:
print(f"FAIL: Pass rate below {unstable_threshold}%")
return 2
if __name__ == '__main__':
exit(check_results('results/output.xml'))Tag-specific thresholds — don't let one flaky non-critical test block deployment:
def check_by_tag(output_xml):
tree = ET.parse(output_xml)
root = tree.getroot()
results = {}
for stat in root.findall('.//statistics/tag/stat'):
tag = stat.get('name')
passed = int(stat.get('pass', 0))
failed = int(stat.get('fail', 0))
results[tag] = {'passed': passed, 'failed': failed}
# Critical tests must all pass
smoke = results.get('smoke', {})
if smoke.get('failed', 0) > 0:
print(f"FAIL: {smoke['failed']} smoke tests failed")
return False
# Non-critical tests can have some failures
regression = results.get('regression', {})
reg_total = regression.get('passed', 0) + regression.get('failed', 0)
if reg_total > 0:
reg_pass_rate = regression['passed'] / reg_total
if reg_pass_rate < 0.90:
print(f"FAIL: Regression pass rate {reg_pass_rate:.0%} below 90%")
return False
return TrueMerging Parallel Results
pabot produces separate output-N.xml files when running in parallel. Merge them:
# pabot does this automatically, but for manual merges:
rebot --outputdir merged/ output-1.xml output-2.xml output-3.xml
# With custom output names
rebot \
--outputdir merged/ \
--output combined.xml \
--report combined-report.html \
--log combined-log.html \
output-*.xmlSlack Notifications
Post results to Slack after CI runs:
- name: Notify Slack on failure
if: failure()
uses: 8398a7/action-slack@v3
with:
status: ${{ job.status }}
fields: repo,message,commit,author,action,eventName,ref,workflow
text: |
Robot Framework tests failed on ${{ github.ref }}
Download results: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}Or a custom notification with pass/fail counts:
- name: Parse test results
id: results
if: always()
run: |
python -c "
import xml.etree.ElementTree as ET
tree = ET.parse('results/output.xml')
stats = tree.getroot().find('.//statistics/total/stat')
print(f'passed={stats.get(\"pass\", 0)}')
print(f'failed={stats.get(\"fail\", 0)}')
" >> $GITHUB_OUTPUT
- name: Notify Slack
if: always()
run: |
STATUS="${{ job.status }}"
PASSED="${{ steps.results.outputs.passed }}"
FAILED="${{ steps.results.outputs.failed }}"
curl -X POST ${{ secrets.SLACK_WEBHOOK_URL }} \
-H 'Content-type: application/json' \
-d "{
\"text\": \"Test results: ${PASSED} passed, ${FAILED} failed | Status: ${STATUS}\",
\"attachments\": [{
\"color\": \"$([ '$STATUS' = 'success' ] && echo 'good' || echo 'danger')\",
\"text\": \"<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View full results>\"
}]
}"Rerunning Failed Tests
For flaky test suites, rerun only the failures:
# First run
robot --outputdir results/ tests/
# Rerun failed tests
robot \
--rerunfailed results/output.xml \
--outputdir results/rerun/ \
tests/
# Merge original and rerun results
rebot \
--outputdir results/final/ \
--rerunfailedsuites results/output.xml \
results/output.xml \
results/rerun/output.xmlThe merged output marks a test as passed if it passed in either run. This reduces noise from genuine flakiness while still catching real failures.
In GitHub Actions:
- name: Run tests
run: robot --outputdir results/ tests/ || true
- name: Rerun failed tests
run: |
if [ -f results/output.xml ]; then
robot \
--rerunfailed results/output.xml \
--outputdir results/rerun/ \
tests/ || true
fi
- name: Merge results
run: |
rebot \
--outputdir results/final/ \
results/output.xml \
results/rerun/output.xml 2>/dev/null || \
cp -r results/ results/final/Performance: What Slows Down RF in CI
Browser startup: Each Open Browser call takes 2-4 seconds. Tests that open a new browser for every test case are slow. Use suite-level browser management where tests are stable enough:
*** Settings ***
Suite Setup Open Test Browser
Suite Teardown Close Browser
Test Setup Go To ${BASE_URL}Sequential execution: RF's default is single-threaded. A 200-test suite that takes 3 minutes per test = 10 hours. pabot with 8 processes: ~75 minutes. The math is straightforward.
Docker image cold starts: Pre-build a Docker image with all dependencies installed rather than running pip install on every CI run. This alone can save 2-4 minutes per run.
FROM python:3.11-slim
RUN apt-get update && apt-get install -y chromium-driver
COPY requirements.txt .
RUN pip install -r requirements.txtThe combination of pabot + pre-built Docker image + result merging covers the most impactful CI optimizations for Robot Framework suites.