Los Unit Tests automatizados son un componente central del desarrollo de software moderno. En SAP BTP ABAP Environment puedes ejecutar ABAP Unit Tests a traves de APIs e integrarlos en pipelines CI/CD. Este articulo te muestra como configurar y usar el ABAP Unit Runner.
Por que tests automatizados en BTP?
En un entorno cloud, el testing continuo es indispensable:
| Aspecto | Manual | Automatizado |
|---|---|---|
| Ejecucion | Bajo demanda en ADT | En cada commit |
| Consistencia | Variable | Siempre igual |
| Velocidad | Lenta | Rapida |
| CI/CD | No posible | Completamente integrado |
| Trazabilidad | Limitada | Completamente documentada |
+----------------------------------------------------------------+| Pipeline CI/CD con ABAP Unit Runner |+----------------------------------------------------------------+| || Git Push --> Build --> ABAP Unit --> ATC --> Deploy || Runner API API || | || v || +----------+ || | BTP | || | ABAP | || | System | || +----------+ || |+----------------------------------------------------------------+Requisitos previos
Antes de poder usar el ABAP Unit Runner necesitas:
- SAP BTP ABAP Environment (instancia Steampunk)
- Communication User con permisos correspondientes
- Communication Arrangement para servicios ADT
- Service Key para acceso API
Configurar Communication Arrangement
El Communication Arrangement permite el acceso externo a las APIs ADT:
+----------------------------------------------------------------+| Communication Arrangement: SAP_COM_0763 |+----------------------------------------------------------------+| || Scenario: SAP_COM_0763 (ABAP Development Tools) || || Communication System: Z_CICD_SYSTEM || +-- Host: github.actions.runner || +-- Business System ID: CICD_RUNNER || || Communication User: CICD_USER || +-- Autenticacion: Basic / OAuth 2.0 || +-- Password/Client Secret: ******** || || Inbound Services: || [x] ADT Core Services || [x] ADT ABAP Unit || [x] ADT ATC || |+----------------------------------------------------------------+Paso a paso:
- Abrir Fiori Launchpad -> Communication Arrangements
- Nuevo -> Seleccionar Scenario SAP_COM_0763
- Crear Communication System o seleccionar existente
- Configurar Communication User con password
- Activar Inbound Services
ADT REST API para Unit Tests
La ADT REST API permite la ejecucion programatica de ABAP Unit Tests.
Endpoint API
POST https://<system-url>/sap/bc/adt/abapunit/testrunsFormato del Request
El request usa un formato XML para configurar la ejecucion de tests:
<?xml version="1.0" encoding="UTF-8"?><aunit:runConfiguration xmlns:aunit="http://www.sap.com/adt/aunit"> <external> <coverage active="true" branchCoverage="true"/> </external> <options> <uriType value="semantic"/> <testDeterminationStrategy sameProgram="true" assignedTests="false"/> <testRiskCoverage> <harmless active="true"/> <dangerous active="true"/> <critical active="true"/> </testRiskCoverage> <durationCoverage short="true" medium="true" long="true"/> <withNavigationUri enabled="true"/> </options> <adtcore:objectSets xmlns:adtcore="http://www.sap.com/adt/core"> <objectSet kind="inclusive"> <adtcore:objectReferences> <adtcore:objectReference adtcore:uri="/sap/bc/adt/vit/wb/object_type/devck/object_name/Z_FLIGHT_BOOKING"/> </adtcore:objectReferences> </objectSet> </adtcore:objectSets></aunit:runConfiguration>Opciones de configuracion
| Opcion | Valores | Descripcion |
|---|---|---|
| coverage | true/false | Activar Code Coverage |
| branchCoverage | true/false | Branch Coverage adicional |
| harmless | true/false | Tests con Risk Level HARMLESS |
| dangerous | true/false | Tests con Risk Level DANGEROUS |
| critical | true/false | Tests con Risk Level CRITICAL |
| short | true/false | Tests con Duration SHORT |
| medium | true/false | Tests con Duration MEDIUM |
| long | true/false | Tests con Duration LONG |
Ejemplo cURL
# Ejecutar ABAP Unit Tests para un paquetecurl -X POST \ "https://my-system.abap.eu10.hana.ondemand.com/sap/bc/adt/abapunit/testruns" \ -u "CICD_USER:password" \ -H "Content-Type: application/vnd.sap.adt.abapunit.testruns.config.v4+xml" \ -H "Accept: application/vnd.sap.adt.abapunit.testruns.result.v1+xml" \ -d @aunit_config.xml \ -o aunit_results.xmlEvaluar resultados de tests
Formato del Response
La API devuelve los resultados en formato XML:
<?xml version="1.0" encoding="UTF-8"?><aunit:runResult xmlns:aunit="http://www.sap.com/adt/aunit"> <program adtcore:name="ZCL_FLIGHT_BOOKING" adtcore:type="CLAS"> <testClasses> <testClass adtcore:name="LTC_BOOKING" adtcore:uri="/sap/bc/adt/oo/classes/zcl_flight_booking/source/testclasses#start=10,0"> <testMethods> <testMethod adtcore:name="TEST_VALID_BOOKING" executionTime="00:00:00.015" uriType="semantic"> <alerts/> </testMethod> <testMethod adtcore:name="TEST_INVALID_DATE" executionTime="00:00:00.008" uriType="semantic"> <alerts/> </testMethod> <testMethod adtcore:name="TEST_MISSING_PASSENGER" executionTime="00:00:00.012" uriType="semantic"> <alerts> <alert kind="failure" severity="critical"> <title>Assertion failed</title> <details>Expected: 'ERROR', Actual: 'OK'</details> <stack> <stackEntry uri="/sap/bc/adt/oo/classes/zcl_flight_booking/source/testclasses#start=45,4"/> </stack> </alert> </alerts> </testMethod> </testMethods> </testClass> </testClasses> </program> <coverage> <statementCoverage percentage="78.5"/> <branchCoverage percentage="65.2"/> </coverage></aunit:runResult>Parsing de resultados en Bash
#!/bin/bashRESULTS_FILE="aunit_results.xml"
# Total de testsTOTAL_TESTS=$(grep -c '<testMethod' "$RESULTS_FILE" || echo "0")
# Tests exitosos (sin alerts)PASSED_TESTS=$(grep -c '<alerts/>' "$RESULTS_FILE" || echo "0")
# Tests fallidosFAILED_TESTS=$((TOTAL_TESTS - PASSED_TESTS))
# Extraer CoverageSTATEMENT_COVERAGE=$(grep -oP 'statementCoverage percentage="\K[^"]+' "$RESULTS_FILE" || echo "0")BRANCH_COVERAGE=$(grep -oP 'branchCoverage percentage="\K[^"]+' "$RESULTS_FILE" || echo "0")
echo "================================"echo "ABAP Unit Test Report"echo "================================"echo "Total Tests: $TOTAL_TESTS"echo "Passed: $PASSED_TESTS"echo "Failed: $FAILED_TESTS"echo ""echo "Statement Coverage: ${STATEMENT_COVERAGE}%"echo "Branch Coverage: ${BRANCH_COVERAGE}%"echo "================================"
# Exit Code para CI/CDif [ "$FAILED_TESTS" -gt 0 ]; then echo "::error::$FAILED_TESTS test(s) failed!" exit 1fi
# Verificar threshold de CoverageTHRESHOLD=70if (( $(echo "$STATEMENT_COVERAGE < $THRESHOLD" | bc -l) )); then echo "::warning::Coverage ${STATEMENT_COVERAGE}% is below threshold ${THRESHOLD}%"fi
exit 0Parsing de resultados con Python
import xml.etree.ElementTree as ETimport sysimport json
def parse_aunit_results(xml_file): tree = ET.parse(xml_file) root = tree.getroot()
ns = { 'aunit': 'http://www.sap.com/adt/aunit', 'adtcore': 'http://www.sap.com/adt/core' }
results = { 'total': 0, 'passed': 0, 'failed': 0, 'errors': [], 'coverage': {} }
# Contar tests for test_method in root.findall('.//aunit:testMethod', ns): results['total'] += 1 alerts = test_method.find('aunit:alerts', ns)
if alerts is not None and len(alerts) == 0: results['passed'] += 1 else: results['failed'] += 1 # Recopilar detalles de error for alert in alerts.findall('aunit:alert', ns): results['errors'].append({ 'test': test_method.get('{http://www.sap.com/adt/core}name'), 'kind': alert.get('kind'), 'title': alert.findtext('aunit:title', '', ns), 'details': alert.findtext('aunit:details', '', ns) })
# Extraer Coverage coverage = root.find('.//aunit:coverage', ns) if coverage is not None: stmt = coverage.find('aunit:statementCoverage', ns) branch = coverage.find('aunit:branchCoverage', ns) if stmt is not None: results['coverage']['statement'] = float(stmt.get('percentage', 0)) if branch is not None: results['coverage']['branch'] = float(branch.get('percentage', 0))
return results
if __name__ == '__main__': results = parse_aunit_results(sys.argv[1]) print(json.dumps(results, indent=2))
# Salir con codigo de error si hay tests fallidos sys.exit(1 if results['failed'] > 0 else 0)Integracion CI/CD con GitHub Actions
Workflow completo de GitHub Actions
name: ABAP Unit Tests
on: push: branches: [ main, develop ] pull_request: branches: [ main ]
env: ABAP_PACKAGE: Z_FLIGHT_BOOKING COVERAGE_THRESHOLD: 70
jobs: unit-tests: name: Run ABAP Unit Tests runs-on: ubuntu-latest
steps: - name: Checkout Repository uses: actions/checkout@v4
- name: Setup Python uses: actions/setup-python@v5 with: python-version: '3.11'
- name: Create Test Configuration run: | cat > aunit_config.xml << 'EOF' <?xml version="1.0" encoding="UTF-8"?> <aunit:runConfiguration xmlns:aunit="http://www.sap.com/adt/aunit"> <external> <coverage active="true" branchCoverage="true"/> </external> <options> <uriType value="semantic"/> <testDeterminationStrategy sameProgram="true" assignedTests="false"/> <testRiskCoverage> <harmless active="true"/> <dangerous active="true"/> <critical active="true"/> </testRiskCoverage> <durationCoverage short="true" medium="true" long="true"/> <withNavigationUri enabled="true"/> </options> <adtcore:objectSets xmlns:adtcore="http://www.sap.com/adt/core"> <objectSet kind="inclusive"> <adtcore:objectReferences> <adtcore:objectReference adtcore:uri="/sap/bc/adt/vit/wb/object_type/devck/object_name/${{ env.ABAP_PACKAGE }}"/> </adtcore:objectReferences> </objectSet> </adtcore:objectSets> </aunit:runConfiguration> EOF
- name: Run ABAP Unit Tests id: run-tests run: | HTTP_CODE=$(curl -s -w "%{http_code}" \ -X POST \ "${{ secrets.ABAP_ENDPOINT }}/sap/bc/adt/abapunit/testruns" \ -u "${{ secrets.ABAP_USER }}:${{ secrets.ABAP_PASSWORD }}" \ -H "Content-Type: application/vnd.sap.adt.abapunit.testruns.config.v4+xml" \ -H "Accept: application/vnd.sap.adt.abapunit.testruns.result.v1+xml" \ -d @aunit_config.xml \ -o aunit_results.xml)
if [ "$HTTP_CODE" != "200" ]; then echo "::error::API call failed with HTTP $HTTP_CODE" cat aunit_results.xml exit 1 fi
echo "API call successful"
- name: Parse Test Results id: parse run: | python << 'PYTHON_SCRIPT' import xml.etree.ElementTree as ET import os
tree = ET.parse('aunit_results.xml') root = tree.getroot() ns = {'aunit': 'http://www.sap.com/adt/aunit', 'adtcore': 'http://www.sap.com/adt/core'}
total = len(root.findall('.//aunit:testMethod', ns)) passed = len([m for m in root.findall('.//aunit:testMethod', ns) if len(m.find('aunit:alerts', ns)) == 0]) failed = total - passed
# Coverage coverage = root.find('.//aunit:coverage', ns) stmt_cov = 0 if coverage is not None: stmt = coverage.find('aunit:statementCoverage', ns) if stmt is not None: stmt_cov = float(stmt.get('percentage', 0))
# GitHub Output with open(os.environ['GITHUB_OUTPUT'], 'a') as f: f.write(f"total={total}\n") f.write(f"passed={passed}\n") f.write(f"failed={failed}\n") f.write(f"coverage={stmt_cov}\n")
print(f"Total: {total}, Passed: {passed}, Failed: {failed}") print(f"Statement Coverage: {stmt_cov}%")
if failed > 0: print("::error::Some tests failed!") exit(1) PYTHON_SCRIPT
- name: Check Coverage Threshold run: | COVERAGE=${{ steps.parse.outputs.coverage }} THRESHOLD=${{ env.COVERAGE_THRESHOLD }}
if (( $(echo "$COVERAGE < $THRESHOLD" | bc -l) )); then echo "::warning::Coverage ${COVERAGE}% is below threshold ${THRESHOLD}%" fi
- name: Upload Test Results uses: actions/upload-artifact@v4 with: name: aunit-results path: aunit_results.xml
- name: Create Test Summary if: always() run: | cat >> $GITHUB_STEP_SUMMARY << EOF ## ABAP Unit Test Results
| Metric | Value | |--------|-------| | Total Tests | ${{ steps.parse.outputs.total }} | | Passed | ${{ steps.parse.outputs.passed }} | | Failed | ${{ steps.parse.outputs.failed }} | | Coverage | ${{ steps.parse.outputs.coverage }}% |
EOFWorkflow reutilizable para multiples paquetes
name: ABAP Unit Tests (Reusable)
on: workflow_call: inputs: package: required: true type: string description: 'Paquete ABAP a testear' coverage_threshold: required: false type: number default: 70 description: 'Porcentaje minimo de coverage' include_dangerous: required: false type: boolean default: true description: 'Incluir tests RISK LEVEL DANGEROUS' secrets: ABAP_ENDPOINT: required: true ABAP_USER: required: true ABAP_PASSWORD: required: true outputs: total: description: 'Numero total de tests' value: ${{ jobs.test.outputs.total }} passed: description: 'Numero de tests exitosos' value: ${{ jobs.test.outputs.passed }} coverage: description: 'Porcentaje de statement coverage' value: ${{ jobs.test.outputs.coverage }}
jobs: test: runs-on: ubuntu-latest outputs: total: ${{ steps.parse.outputs.total }} passed: ${{ steps.parse.outputs.passed }} coverage: ${{ steps.parse.outputs.coverage }}
steps: - name: Run ABAP Unit Tests for ${{ inputs.package }} run: | curl -s -X POST \ "${{ secrets.ABAP_ENDPOINT }}/sap/bc/adt/abapunit/testruns" \ -u "${{ secrets.ABAP_USER }}:${{ secrets.ABAP_PASSWORD }}" \ -H "Content-Type: application/vnd.sap.adt.abapunit.testruns.config.v4+xml" \ -H "Accept: application/vnd.sap.adt.abapunit.testruns.result.v1+xml" \ -d '<aunit:runConfiguration xmlns:aunit="http://www.sap.com/adt/aunit"> <external><coverage active="true"/></external> <options> <uriType value="semantic"/> <testRiskCoverage> <harmless active="true"/> <dangerous active="${{ inputs.include_dangerous }}"/> </testRiskCoverage> <durationCoverage short="true" medium="true" long="true"/> </options> <adtcore:objectSets xmlns:adtcore="http://www.sap.com/adt/core"> <objectSet kind="inclusive"> <adtcore:objectReferences> <adtcore:objectReference adtcore:uri="/sap/bc/adt/vit/wb/object_type/devck/object_name/${{ inputs.package }}"/> </adtcore:objectReferences> </objectSet> </adtcore:objectSets> </aunit:runConfiguration>' \ -o results.xml
- name: Parse Results id: parse run: | # Logica de parsing aqui (como arriba) echo "total=10" >> $GITHUB_OUTPUT echo "passed=9" >> $GITHUB_OUTPUT echo "coverage=85.5" >> $GITHUB_OUTPUTEjecutar tests especificos
Tests para clases individuales
# Solo tests para una clase especificacurl -X POST \ "$ABAP_ENDPOINT/sap/bc/adt/abapunit/testruns" \ -u "$USER:$PASSWORD" \ -H "Content-Type: application/vnd.sap.adt.abapunit.testruns.config.v4+xml" \ -H "Accept: application/vnd.sap.adt.abapunit.testruns.result.v1+xml" \ -d '<?xml version="1.0" encoding="UTF-8"?> <aunit:runConfiguration xmlns:aunit="http://www.sap.com/adt/aunit"> <options><uriType value="semantic"/></options> <adtcore:objectSets xmlns:adtcore="http://www.sap.com/adt/core"> <objectSet kind="inclusive"> <adtcore:objectReferences> <adtcore:objectReference adtcore:uri="/sap/bc/adt/oo/classes/zcl_flight_booking"/> </adtcore:objectReferences> </objectSet> </adtcore:objectSets> </aunit:runConfiguration>'Tests con Risk Level especifico
<!-- Solo tests HARMLESS (para feedback rapido) --><testRiskCoverage> <harmless active="true"/> <dangerous active="false"/> <critical active="false"/></testRiskCoverage>
<!-- Todos los tests incluyendo DANGEROUS (Nightly Build) --><testRiskCoverage> <harmless active="true"/> <dangerous active="true"/> <critical active="true"/></testRiskCoverage>Tests con Duration especifica
<!-- Solo tests SHORT (para Pull Requests) --><durationCoverage short="true" medium="false" long="false"/>
<!-- Todos los tests (para Nightly Build) --><durationCoverage short="true" medium="true" long="true"/>Analizar Code Coverage
Obtener detalles de Coverage
La informacion de Coverage esta incluida en el Response:
<coverage> <statementCoverage percentage="78.5"/> <branchCoverage percentage="65.2"/> <procedureCoverage percentage="92.0"/></coverage>Generar reporte de Coverage
import xml.etree.ElementTree as ETimport sys
def generate_coverage_report(xml_file, threshold=70): tree = ET.parse(xml_file) root = tree.getroot() ns = {'aunit': 'http://www.sap.com/adt/aunit'}
coverage = root.find('.//aunit:coverage', ns)
if coverage is None: print("No se encontraron datos de Coverage") return 1
metrics = {} for child in coverage: tag = child.tag.replace('{http://www.sap.com/adt/aunit}', '') metrics[tag] = float(child.get('percentage', 0))
print("=" * 50) print("CODE COVERAGE REPORT") print("=" * 50)
for metric, value in metrics.items(): status = "[OK]" if value >= threshold else "[X]" print(f"{status} {metric}: {value:.1f}%")
print("=" * 50) print(f"Threshold: {threshold}%")
# Generar badge para README stmt_cov = metrics.get('statementCoverage', 0) if stmt_cov >= 80: color = 'brightgreen' elif stmt_cov >= 60: color = 'yellow' else: color = 'red'
badge_url = f"https://img.shields.io/badge/coverage-{stmt_cov:.0f}%25-{color}" print(f"\nBadge URL: {badge_url}")
return 0 if stmt_cov >= threshold else 1
if __name__ == '__main__': threshold = int(sys.argv[2]) if len(sys.argv) > 2 else 70 sys.exit(generate_coverage_report(sys.argv[1], threshold))Troubleshooting
Errores frecuentes
| Error | Causa | Solucion |
|---|---|---|
| 401 Unauthorized | Credenciales incorrectas | Verificar user/password |
| 403 Forbidden | Permisos faltantes | Verificar Communication Arrangement |
| 404 Not Found | Paquete no existe | Verificar nombre del paquete |
| 500 Internal Error | Error del sistema | Verificar logs del sistema |
| Timeout | Tests muy largos | Usar filtro de Duration |
Tips de debugging
# Output verbose para debuggingcurl -v -X POST \ "$ABAP_ENDPOINT/sap/bc/adt/abapunit/testruns" \ -u "$USER:$PASSWORD" \ -H "Content-Type: application/vnd.sap.adt.abapunit.testruns.config.v4+xml" \ -H "Accept: application/vnd.sap.adt.abapunit.testruns.result.v1+xml" \ -d @config.xml 2>&1 | tee debug.logAumentar timeout
# Establecer timeout a 5 minutoscurl --max-time 300 \ -X POST \ "$ABAP_ENDPOINT/sap/bc/adt/abapunit/testruns" \ ...Mejores practicas
| Aspecto | Recomendacion |
|---|---|
| Estructura de tests | Categorizar tests por Risk Level y Duration |
| CI/CD | SHORT/HARMLESS para PRs, todos para Nightly |
| Coverage | Minimo 70%, objetivo 80%+ |
| Manejo de errores | Salida de errores detallada en pipeline |
| Caching | Almacenar Service Keys de forma segura (Secrets) |
| Paralelizacion | Testear multiples paquetes en paralelo |
| Artefactos | Guardar resultados como artefactos |
| Notificacion | Notificar a Slack/Teams en caso de errores |
Recursos adicionales
- Unit Tests para servicios RAP - Testing especifico de RAP
- CI/CD con ABAP Cloud - Ejemplos completos de pipeline
- ABAP Unit Testing en ABAP Cloud - Fundamentos de Unit Testing
- Automatizacion ATC en BTP - Integrar ATC en CI/CD