Test Doubles und Mocking in ABAP Cloud: Der komplette Guide

kategorie
ABAP-Statements
Veröffentlicht
autor
Johannes

Test Doubles sind Ersatzobjekte, die in Unit Tests echte Abhängigkeiten (Datenbank, externe APIs, andere Klassen) ersetzen. Sie ermöglichen isolierte, schnelle und zuverlässige Tests ohne Side-Effects auf productive Systeme.

Das Problem: Abhängigkeiten in Tests

Ohne Test Doubles

" ❌ Problematischer Unit Test
METHOD test_calculate_discount.
" Problem 1: DB-Zugriff in Test
SELECT SINGLE * FROM zcustomer
WHERE customer_id = '000042'
INTO @DATA(ls_customer).
" Problem 2: Abhängig von DB-Daten
DATA(lo_cut) = NEW zcl_discount_calculator( ).
DATA(lv_discount) = lo_cut->calculate( ls_customer ).
" Problem 3: Test bricht bei fehlenden Daten
cl_abap_unit_assert=>assert_equals(
exp = 10
act = lv_discount
).
ENDMETHOD.

Probleme:

  • 🔴 Langsam: DB-Zugriff dauert 50-500ms pro Test
  • 🔴 Fragil: Bricht wenn Customer 000042 nicht existiert
  • 🔴 Side-Effects: Test könnte DB ändern
  • 🔴 Nicht isoliert: Testet DB UND Geschäftslogik gleichzeitig

Mit Test Doubles

" ✅ Isolierter Unit Test
METHOD test_calculate_discount.
" Test Double: Fake Customer (keine DB!)
DATA(ls_customer) = VALUE zcustomer(
customer_id = '000042'
customer_class = 'A' " Premium
total_revenue = 100000
).
DATA(lo_cut) = NEW zcl_discount_calculator( ).
DATA(lv_discount) = lo_cut->calculate( ls_customer ).
" Test ist schnell, zuverlässig, isoliert
cl_abap_unit_assert=>assert_equals(
exp = 15 " Premium-Kunde → 15% Rabatt
act = lv_discount
).
ENDMETHOD.

Vorteile:

  • Schnell: 0.1ms statt 50ms
  • Zuverlässig: Testdaten kontrolliert
  • Isoliert: Nur Geschäftslogik wird getestet
  • Keine Side-Effects: Keine DB-Änderungen

Test Double-Typen

┌────────────────────────────────────────────────────────┐
│ Test Doubles │
├────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────┐│
│ │ Dummy │ │ Stub │ │ Spy │ │ Mock ││
│ └──────────┘ └──────────┘ └──────────┘ └────────┘│
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ Parameter Liefert Zeichnet auf Verifiziert│
│ füllen vordefinierte Aufrufe Verhalten │
│ Werte │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Fake │ │
│ │ Funktionierende Implementierung (vereinfacht) │ │
│ └──────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────┘

Dummy

Objekte die nur benötigt werden, um Parameter zu füllen (Wert unwichtig).

METHOD process_order.
IMPORTING io_logger TYPE REF TO zif_logger. " ← Wird nie genutzt
" Geschäftslogik ohne Logger
" ...
ENDMETHOD.
" Test:
METHOD test_process_order.
DATA(lo_dummy_logger) = NEW zcl_null_logger( ). " Dummy
mo_cut->process_order( io_logger = lo_dummy_logger ).
" Logger wird nie aufgerufen → Dummy reicht
ENDMETHOD.

Stub

Liefert vordefinierte Antworten auf Methodenaufrufe.

" Interface
INTERFACE zif_customer_repository.
METHODS get_customer
IMPORTING iv_id TYPE kunnr
RETURNING VALUE(rs_customer) TYPE zcustomer.
ENDINTERFACE.
" Stub für Tests
CLASS zcl_customer_repository_stub DEFINITION.
PUBLIC SECTION.
INTERFACES zif_customer_repository.
DATA ms_customer TYPE zcustomer. " Vordefinierte Antwort
ENDCLASS.
CLASS zcl_customer_repository_stub IMPLEMENTATION.
METHOD zif_customer_repository~get_customer.
" Immer gleiche Antwort, egal welche ID
rs_customer = ms_customer.
ENDMETHOD.
ENDCLASS.
" Test:
METHOD test_with_premium_customer.
" Arrange: Stub mit Premium-Kunde
DATA(lo_stub) = NEW zcl_customer_repository_stub( ).
lo_stub->ms_customer = VALUE #(
customer_id = '000042'
customer_class = 'A'
).
DATA(lo_cut) = NEW zcl_discount_calculator( io_repository = lo_stub ).
" Act
DATA(lv_discount) = lo_cut->calculate_for_customer( '000042' ).
" Assert
cl_abap_unit_assert=>assert_equals( exp = 15 act = lv_discount ).
ENDMETHOD.

Spy

Zeichnet Aufrufe auf (wie oft, mit welchen Parametern).

CLASS zcl_logger_spy DEFINITION.
PUBLIC SECTION.
INTERFACES zif_logger.
DATA mt_logged_messages TYPE string_table. " Aufzeichnung
ENDCLASS.
CLASS zcl_logger_spy IMPLEMENTATION.
METHOD zif_logger~log.
APPEND iv_message TO mt_logged_messages.
ENDMETHOD.
ENDCLASS.
" Test:
METHOD test_logs_discount_calculation.
" Arrange: Spy
DATA(lo_spy) = NEW zcl_logger_spy( ).
DATA(lo_cut) = NEW zcl_discount_calculator( io_logger = lo_spy ).
" Act
lo_cut->calculate( ... ).
" Assert: Wurde geloggt?
cl_abap_unit_assert=>assert_equals(
exp = 1
act = lines( lo_spy->mt_logged_messages )
msg = 'Should log discount calculation'
).
cl_abap_unit_assert=>assert_that(
act = lo_spy->mt_logged_messages[ 1 ]
exp = cl_abap_matcher_text=>contains( 'Discount calculated' )
).
ENDMETHOD.

Mock

Verifiziert Verhalten (wurde Methode mit korrekten Parametern aufgerufen).

" ABAP hat kein natives Mocking-Framework wie Mockito (Java)
" → Manuelles Mock oder ABAP Test Double Framework nutzen
CLASS zcl_email_sender_mock DEFINITION.
PUBLIC SECTION.
INTERFACES zif_email_sender.
DATA mv_send_called TYPE abap_bool.
DATA mv_recipient TYPE string.
DATA mv_subject TYPE string.
ENDCLASS.
CLASS zcl_email_sender_mock IMPLEMENTATION.
METHOD zif_email_sender~send.
mv_send_called = abap_true.
mv_recipient = iv_recipient.
mv_subject = iv_subject.
ENDMETHOD.
ENDCLASS.
" Test:
METHOD test_sends_approval_email.
" Arrange: Mock
DATA(lo_mock) = NEW zcl_email_sender_mock( ).
DATA(lo_cut) = NEW zcl_approval_processor( io_email_sender = lo_mock ).
" Act
lo_cut->approve_order( iv_order_id = '12345' ).
" Assert: Mock verifizieren
cl_abap_unit_assert=>assert_true(
act = lo_mock->mv_send_called
msg = 'Should send email on approval'
).
cl_abap_unit_assert=>assert_equals(
exp = 'manager@company.com'
act = lo_mock->mv_recipient
msg = 'Should send to manager'
).
cl_abap_unit_assert=>assert_that(
act = lo_mock->mv_subject
exp = cl_abap_matcher_text=>contains( '12345' )
msg = 'Subject should contain order ID'
).
ENDMETHOD.

Fake

Funktionierende, aber vereinfachte Implementierung.

" Production: DB-basierte Repository
CLASS zcl_customer_repository DEFINITION.
PUBLIC SECTION.
INTERFACES zif_customer_repository.
ENDCLASS.
CLASS zcl_customer_repository IMPLEMENTATION.
METHOD zif_customer_repository~get_customer.
SELECT SINGLE * FROM zcustomer
WHERE customer_id = @iv_id
INTO @rs_customer.
ENDMETHOD.
ENDCLASS.
" Fake: In-Memory Repository für Tests
CLASS zcl_customer_repository_fake DEFINITION.
PUBLIC SECTION.
INTERFACES zif_customer_repository.
DATA mt_customers TYPE STANDARD TABLE OF zcustomer WITH KEY customer_id.
ENDCLASS.
CLASS zcl_customer_repository_fake IMPLEMENTATION.
METHOD zif_customer_repository~get_customer.
" Fake nutzt interne Tabelle statt DB
rs_customer = VALUE #( mt_customers[ customer_id = iv_id ] OPTIONAL ).
ENDMETHOD.
ENDCLASS.
" Test:
METHOD test_with_multiple_customers.
" Arrange: Fake mit Testdaten
DATA(lo_fake) = NEW zcl_customer_repository_fake( ).
lo_fake->mt_customers = VALUE #(
( customer_id = '001' customer_class = 'A' )
( customer_id = '002' customer_class = 'B' )
( customer_id = '003' customer_class = 'C' )
).
DATA(lo_cut) = NEW zcl_customer_manager( io_repository = lo_fake ).
" Act & Assert
DATA(lv_count) = lo_cut->count_premium_customers( ).
cl_abap_unit_assert=>assert_equals( exp = 1 act = lv_count ).
ENDMETHOD.

CDS Test Environment (für RAP/CDS)

Setup

CLASS ltc_travel DEFINITION FINAL FOR TESTING
DURATION SHORT RISK LEVEL HARMLESS.
PRIVATE SECTION.
CLASS-DATA mo_environment TYPE REF TO if_cds_test_environment.
CLASS-DATA mo_sql_test_environment TYPE REF TO if_osql_test_environment.
CLASS-METHODS:
class_setup,
class_teardown.
METHODS:
setup,
teardown,
test_create_travel FOR TESTING,
test_validate_dates FOR TESTING.
ENDCLASS.

Test Environment erstellen

METHOD class_setup.
" CDS Test Environment für RAP Business Objects
mo_environment = cl_cds_test_environment=>create_for_multiple_cds(
i_for_entities = VALUE #(
( i_for_entity = 'ZI_Travel' )
( i_for_entity = 'ZI_Booking' )
( i_for_entity = 'ZI_Customer' ) " Abhängigkeiten!
)
).
" Alternative: SQL Test Environment (für Tabellen)
mo_sql_test_environment = cl_osql_test_environment=>create(
i_dependency_list = VALUE #(
( 'ZTRAVEL' )
( 'ZBOOKING' )
)
).
ENDMETHOD.

Testdaten einfügen

METHOD setup.
" Pro Test: Fresh Testdaten
mo_environment->clear_doubles( ).
" Testdaten für ZI_Customer (Abhängigkeit!)
mo_environment->insert_test_data(
i_data = VALUE i_customer(
( Customer = '000042' CustomerName = 'Max Mustermann' CustomerClass = 'A' )
( Customer = '000099' CustomerName = 'Lisa Beispiel' CustomerClass = 'B' )
)
).
" Testdaten für ZI_Travel
mo_environment->insert_test_data(
i_data = VALUE zi_travel(
( TravelId = '00000001'
CustomerId = '000042'
AgencyId = '000001'
BeginDate = '20250601'
EndDate = '20250615'
Status = 'O' )
)
).
ENDMETHOD.

Test mit EML

METHOD test_create_travel.
" Arrange: Testdaten bereits in setup
" Act: CREATE via EML
MODIFY ENTITIES OF zi_travel
ENTITY Travel
CREATE FIELDS ( CustomerId AgencyId BeginDate EndDate )
WITH VALUE #(
( %cid = 'T1'
CustomerId = '000042'
AgencyId = '000001'
BeginDate = '20250701'
EndDate = '20250715' )
)
MAPPED DATA(mapped)
FAILED DATA(failed)
REPORTED DATA(reported).
COMMIT ENTITIES
RESPONSE OF zi_travel
FAILED DATA(commit_failed)
REPORTED DATA(commit_reported).
" Assert: Kein Fehler
cl_abap_unit_assert=>assert_initial(
act = commit_failed-travel
msg = 'Travel creation should succeed'
).
" Assert: Travel wurde erstellt (via Read)
READ ENTITIES OF zi_travel
ENTITY Travel
ALL FIELDS
WITH VALUE #( ( %cid = 'T1' ) )
RESULT DATA(lt_travel).
cl_abap_unit_assert=>assert_not_initial(
act = lt_travel
msg = 'Travel should exist after creation'
).
cl_abap_unit_assert=>assert_equals(
exp = '000042'
act = lt_travel[ 1 ]-CustomerId
msg = 'Customer ID should match'
).
ENDMETHOD.

Validation testen

METHOD test_validate_dates.
" Arrange: EndDate < BeginDate (FEHLER!)
" Act
MODIFY ENTITIES OF zi_travel
ENTITY Travel
CREATE FIELDS ( BeginDate EndDate )
WITH VALUE #(
( %cid = 'T1'
BeginDate = '20250615' " 15. Juni
EndDate = '20250601' " 1. Juni → FALSCH!
CustomerId = '000042'
AgencyId = '000001' )
)
FAILED DATA(failed).
COMMIT ENTITIES
RESPONSE OF zi_travel
FAILED DATA(commit_failed)
REPORTED DATA(commit_reported).
" Assert: Fehler erwartet
cl_abap_unit_assert=>assert_not_initial(
act = commit_failed-travel
msg = 'Validation should fail for invalid dates'
).
" Assert: Fehlermeldung vorhanden
cl_abap_unit_assert=>assert_bound(
act = commit_reported-travel[ 1 ]-%msg
msg = 'Error message should be present'
).
" Assert: EndDate-Feld markiert
cl_abap_unit_assert=>assert_equals(
exp = if_abap_behv=>mk-on
act = commit_reported-travel[ 1 ]-%element-EndDate
msg = 'EndDate field should be marked as error'
).
ENDMETHOD.

Teardown

METHOD teardown.
" Nach jedem Test: Aufräumen
ROLLBACK ENTITIES.
mo_environment->clear_doubles( ).
ENDMETHOD.
METHOD class_teardown.
" Nach allen Tests: Environment zerstören
mo_environment->destroy( ).
mo_sql_test_environment->destroy( ).
ENDMETHOD.

Dependency Injection

Problem: Tight Coupling

" ❌ Nicht testbar: Abhängigkeit ist hardcoded
CLASS zcl_order_processor DEFINITION.
PRIVATE SECTION.
METHODS process_order IMPORTING iv_order_id TYPE vbeln.
ENDCLASS.
CLASS zcl_order_processor IMPLEMENTATION.
METHOD process_order.
" Problem: Direkte DB-Abhängigkeit
SELECT SINGLE * FROM vbak WHERE vbeln = @iv_order_id INTO @DATA(ls_order).
" Problem: Direkter Logger-Zugriff
zcl_logger=>log( |Processing order { iv_order_id }| ).
" Geschäftslogik
" ...
ENDMETHOD.
ENDCLASS.
" Unit Test unmöglich:
" - Braucht echte DB mit Testdaten
" - Logger schreibt in productive Log-Tabelle

Lösung: Constructor Injection

" ✅ Testbar: Abhängigkeiten als Interfaces
INTERFACE zif_order_repository.
METHODS get_order
IMPORTING iv_id TYPE vbeln
RETURNING VALUE(rs_order) TYPE vbak.
ENDINTERFACE.
INTERFACE zif_logger.
METHODS log IMPORTING iv_message TYPE string.
ENDINTERFACE.
CLASS zcl_order_processor DEFINITION.
PUBLIC SECTION.
METHODS constructor
IMPORTING
io_repository TYPE REF TO zif_order_repository
io_logger TYPE REF TO zif_logger.
METHODS process_order
IMPORTING iv_order_id TYPE vbeln.
PRIVATE SECTION.
DATA mo_repository TYPE REF TO zif_order_repository.
DATA mo_logger TYPE REF TO zif_logger.
ENDCLASS.
CLASS zcl_order_processor IMPLEMENTATION.
METHOD constructor.
mo_repository = io_repository.
mo_logger = io_logger.
ENDMETHOD.
METHOD process_order.
" Dependency Injection: Nutze injizierte Abhängigkeiten
DATA(ls_order) = mo_repository->get_order( iv_order_id ).
mo_logger->log( |Processing order { iv_order_id }| ).
" Geschäftslogik (nun testbar!)
" ...
ENDMETHOD.
ENDCLASS.

Unit Test mit DI

CLASS ltc_order_processor DEFINITION FINAL FOR TESTING
DURATION SHORT RISK LEVEL HARMLESS.
PRIVATE SECTION.
DATA mo_cut TYPE REF TO zcl_order_processor.
DATA mo_repository_stub TYPE REF TO zcl_order_repository_stub.
DATA mo_logger_spy TYPE REF TO zcl_logger_spy.
METHODS:
setup,
test_process_order FOR TESTING.
ENDCLASS.
CLASS ltc_order_processor IMPLEMENTATION.
METHOD setup.
" Stubs & Spies erstellen
mo_repository_stub = NEW zcl_order_repository_stub( ).
mo_logger_spy = NEW zcl_logger_spy( ).
" Class Under Test mit Test Doubles
mo_cut = NEW zcl_order_processor(
io_repository = mo_repository_stub
io_logger = mo_logger_spy
).
ENDMETHOD.
METHOD test_process_order.
" Arrange: Stub mit Testdaten
mo_repository_stub->ms_order = VALUE #(
vbeln = '0000012345'
kunnr = '000042'
).
" Act
mo_cut->process_order( iv_order_id = '0000012345' ).
" Assert: Logger wurde aufgerufen (Spy)
cl_abap_unit_assert=>assert_not_initial(
act = mo_logger_spy->mt_logged_messages
msg = 'Logger should be called'
).
cl_abap_unit_assert=>assert_that(
act = mo_logger_spy->mt_logged_messages[ 1 ]
exp = cl_abap_matcher_text=>contains( '0000012345' )
msg = 'Log should contain order ID'
).
ENDMETHOD.
ENDCLASS.

Test Seams (für Legacy-Code)

Problem: Legacy-Code ohne DI ist nicht testbar.

Lösung: Test Seams = Injektionspunkte für Tests.

Beispiel: Nicht-testbarer Code

" ❌ Legacy-Code: DB-Zugriff direkt im Code
CLASS zcl_legacy_processor DEFINITION.
PUBLIC SECTION.
METHODS calculate_discount
IMPORTING iv_customer_id TYPE kunnr
RETURNING VALUE(rv_discount) TYPE p.
ENDCLASS.
CLASS zcl_legacy_processor IMPLEMENTATION.
METHOD calculate_discount.
" Direkte DB-Abhängigkeit
SELECT SINGLE customer_class FROM zcustomer
WHERE customer_id = @iv_customer_id
INTO @DATA(lv_class).
" Geschäftslogik
rv_discount = COND #(
WHEN lv_class = 'A' THEN 15
WHEN lv_class = 'B' THEN 10
ELSE 5
).
ENDMETHOD.
ENDCLASS.

Test Seam einfügen

" ✅ Mit Test Seam
CLASS zcl_legacy_processor IMPLEMENTATION.
METHOD calculate_discount.
DATA lv_class TYPE zclass.
" Test Seam: Kann in Tests ersetzt werden
TEST-SEAM customer_lookup.
SELECT SINGLE customer_class FROM zcustomer
WHERE customer_id = @iv_customer_id
INTO @lv_class.
END-TEST-SEAM.
" Geschäftslogik (nun testbar!)
rv_discount = COND #(
WHEN lv_class = 'A' THEN 15
WHEN lv_class = 'B' THEN 10
ELSE 5
).
ENDMETHOD.
ENDCLASS.

Test mit Test Seam

CLASS ltc_legacy_processor DEFINITION FINAL FOR TESTING
DURATION SHORT RISK LEVEL HARMLESS.
PRIVATE SECTION.
DATA mo_cut TYPE REF TO zcl_legacy_processor.
METHODS:
setup,
test_premium_customer FOR TESTING,
test_standard_customer FOR TESTING.
ENDCLASS.
CLASS ltc_legacy_processor IMPLEMENTATION.
METHOD setup.
mo_cut = NEW zcl_legacy_processor( ).
ENDMETHOD.
METHOD test_premium_customer.
" Arrange: Test Seam Injection
TEST-INJECTION customer_lookup.
lv_class = 'A'. " Premium
END-TEST-INJECTION.
" Act
DATA(lv_discount) = mo_cut->calculate_discount( '000042' ).
" Assert
cl_abap_unit_assert=>assert_equals(
exp = 15
act = lv_discount
msg = 'Premium customer should get 15% discount'
).
ENDMETHOD.
METHOD test_standard_customer.
" Arrange
TEST-INJECTION customer_lookup.
lv_class = 'C'. " Standard
END-TEST-INJECTION.
" Act
DATA(lv_discount) = mo_cut->calculate_discount( '000099' ).
" Assert
cl_abap_unit_assert=>assert_equals(
exp = 5
act = lv_discount
msg = 'Standard customer should get 5% discount'
).
ENDMETHOD.
ENDCLASS.

Wichtige Hinweise / Best Practice

  • Test Doubles > Echte Abhängigkeiten: Immer Doubles nutzen für Datenbank, HTTP, Email
  • Dependency Injection: Design-Pattern #1 für testbaren Code
  • CDS Test Environment: Must-Have für RAP/CDS-Tests
  • Interface-based Design: Jede Abhängigkeit als Interface → austauschbar
  • Test-First: TDD (Test-Driven Development) empfohlen
  • 1 Test = 1 Assertion: Fokussiert, einfach zu debuggen
  • AAA-Pattern: Arrange → Act → Assert (klar strukturiert)
  • Naming: test_<scenario> (z.B. test_approve_travel_with_valid_status)
  • Fast & Isolated: Tests müssen < 1 Sekunde laufen
  • No Side-Effects: Tests dürfen nichts in DB/Filesystem schreiben
  • Test Seams als Notlösung: Nur für Legacy-Code, Neucode = DI
  • Coverage ≥ 80%: Minimum für produktiven Code
  • Continuous: Tests bei jedem Commit laufen (CI/CD)

Weitere Ressourcen