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 TestMETHOD 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 TestMETHOD 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 reichtENDMETHOD.Stub
Liefert vordefinierte Antworten auf Methodenaufrufe.
" InterfaceINTERFACE zif_customer_repository. METHODS get_customer IMPORTING iv_id TYPE kunnr RETURNING VALUE(rs_customer) TYPE zcustomer.ENDINTERFACE.
" Stub für TestsCLASS zcl_customer_repository_stub DEFINITION. PUBLIC SECTION. INTERFACES zif_customer_repository. DATA ms_customer TYPE zcustomer. " Vordefinierte AntwortENDCLASS.
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. " AufzeichnungENDCLASS.
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 RepositoryCLASS 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 TestsCLASS 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 hardcodedCLASS 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-TabelleLösung: Constructor Injection
" ✅ Testbar: Abhängigkeiten als InterfacesINTERFACE 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 CodeCLASS 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 SeamCLASS 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
- ABAP Unit Testing: /abap-unit-testing/
- RAP Basics: /rap-basics/
- EML Guide: /eml-entity-manipulation-language/
- ABAP Cloud: /abap-cloud-definition/