Test Doubles are substitute objects that replace real dependencies (database, external APIs, other classes) in unit tests. They enable isolated, fast, and reliable tests without side effects on production systems.
The Problem: Dependencies in Tests
Without Test Doubles
" Bad: Problematic unit testMETHOD test_calculate_discount. " Problem 1: DB access in test SELECT SINGLE * FROM zcustomer WHERE customer_id = '000042' INTO @DATA(ls_customer).
" Problem 2: Dependent on DB data DATA(lo_cut) = NEW zcl_discount_calculator( ). DATA(lv_discount) = lo_cut->calculate( ls_customer ).
" Problem 3: Test breaks when data is missing cl_abap_unit_assert=>assert_equals( exp = 10 act = lv_discount ).ENDMETHOD.Problems:
- Slow: DB access takes 50-500ms per test
- Fragile: Breaks if customer 000042 doesn’t exist
- Side effects: Test could modify DB
- Not isolated: Tests DB AND business logic simultaneously
With Test Doubles
" Good: Isolated unit testMETHOD test_calculate_discount. " Test Double: Fake Customer (no 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 is fast, reliable, isolated cl_abap_unit_assert=>assert_equals( exp = 15 " Premium customer → 15% discount act = lv_discount ).ENDMETHOD.Benefits:
- Fast: 0.1ms instead of 50ms
- Reliable: Test data controlled
- Isolated: Only business logic is tested
- No side effects: No DB changes
Test Double Types
┌────────────────────────────────────────────────────────┐│ Test Doubles │├────────────────────────────────────────────────────────┤│ ││ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────┐││ │ Dummy │ │ Stub │ │ Spy │ │ Mock │││ └──────────┘ └──────────┘ └──────────┘ └────────┘││ │ │ │ │ ││ ▼ ▼ ▼ ▼ ││ Fill Returns Records Verifies ││ parameters predefined calls behavior ││ values ││ ││ ┌──────────────────────────────────────────────────┐ ││ │ Fake │ ││ │ Working implementation (simplified) │ ││ └──────────────────────────────────────────────────┘ │└────────────────────────────────────────────────────────┘Dummy
Objects needed only to fill parameters (value doesn’t matter).
METHOD process_order. IMPORTING io_logger TYPE REF TO zif_logger. " ← Never used
" Business logic without 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 is never called → Dummy is sufficientENDMETHOD.Stub
Returns predefined responses to method calls.
" InterfaceINTERFACE zif_customer_repository. METHODS get_customer IMPORTING iv_id TYPE kunnr RETURNING VALUE(rs_customer) TYPE zcustomer.ENDINTERFACE.
" Stub for testsCLASS zcl_customer_repository_stub DEFINITION. PUBLIC SECTION. INTERFACES zif_customer_repository. DATA ms_customer TYPE zcustomer. " Predefined responseENDCLASS.
CLASS zcl_customer_repository_stub IMPLEMENTATION. METHOD zif_customer_repository~get_customer. " Always same response, regardless of ID rs_customer = ms_customer. ENDMETHOD.ENDCLASS.
" Test:METHOD test_with_premium_customer. " Arrange: Stub with premium customer 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
Records calls (how often, with which parameters).
CLASS zcl_logger_spy DEFINITION. PUBLIC SECTION. INTERFACES zif_logger. DATA mt_logged_messages TYPE string_table. " RecordingENDCLASS.
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: Was it logged? 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
Verifies behavior (was method called with correct parameters).
" ABAP has no native mocking framework like Mockito (Java)" → Use manual mock or ABAP Test Double Framework
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: Verify mock cl_abap_unit_assert=>assert_true( act = lo_mock->mv_send_called msg = 'Should send email on approval' ).
cl_abap_unit_assert=>assert_equals( 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
Working but simplified implementation.
" Production: DB-based 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 for 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 uses internal table instead of DB rs_customer = VALUE #( mt_customers[ customer_id = iv_id ] OPTIONAL ). ENDMETHOD.ENDCLASS.
" Test:METHOD test_with_multiple_customers. " Arrange: Fake with test data 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 (for 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.Create Test Environment
METHOD class_setup. " CDS Test Environment for 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' ) " Dependencies! ) ).
" Alternative: SQL Test Environment (for tables) mo_sql_test_environment = cl_osql_test_environment=>create( i_dependency_list = VALUE #( ( 'ZTRAVEL' ) ( 'ZBOOKING' ) ) ).ENDMETHOD.Insert Test Data
METHOD setup. " Per test: Fresh test data mo_environment->clear_doubles( ).
" Test data for ZI_Customer (dependency!) mo_environment->insert_test_data( i_data = VALUE i_customer( ( Customer = '000042' CustomerName = 'Max Mustermann' CustomerClass = 'A' ) ( Customer = '000099' CustomerName = 'Lisa Beispiel' CustomerClass = 'B' ) ) ).
" Test data for 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 with EML
METHOD test_create_travel. " Arrange: Test data already 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: No error cl_abap_unit_assert=>assert_initial( act = commit_failed-travel msg = 'Travel creation should succeed' ).
" Assert: Travel was created (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.Test Validation
METHOD test_validate_dates. " Arrange: EndDate < BeginDate (ERROR!)
" Act MODIFY ENTITIES OF zi_travel ENTITY Travel CREATE FIELDS ( BeginDate EndDate ) WITH VALUE #( ( %cid = 'T1' BeginDate = '20250615' " June 15 EndDate = '20250601' " June 1 → WRONG! CustomerId = '000042' AgencyId = '000001' ) ) FAILED DATA(failed).
COMMIT ENTITIES RESPONSE OF zi_travel FAILED DATA(commit_failed) REPORTED DATA(commit_reported).
" Assert: Error expected cl_abap_unit_assert=>assert_not_initial( act = commit_failed-travel msg = 'Validation should fail for invalid dates' ).
" Assert: Error message present cl_abap_unit_assert=>assert_bound( act = commit_reported-travel[ 1 ]-%msg msg = 'Error message should be present' ).
" Assert: EndDate field marked 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. " After each test: Clean up ROLLBACK ENTITIES. mo_environment->clear_doubles( ).ENDMETHOD.
METHOD class_teardown. " After all tests: Destroy environment mo_environment->destroy( ). mo_sql_test_environment->destroy( ).ENDMETHOD.Dependency Injection
Problem: Tight Coupling
" Not testable: Dependency is 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: Direct DB dependency SELECT SINGLE * FROM vbak WHERE vbeln = @iv_order_id INTO @DATA(ls_order).
" Problem: Direct logger access zcl_logger=>log( |Processing order { iv_order_id }| ).
" Business logic " ... ENDMETHOD.ENDCLASS.
" Unit test impossible:" - Needs real DB with test data" - Logger writes to production log tableSolution: Constructor Injection
" Testable: Dependencies as 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: Use injected dependencies DATA(ls_order) = mo_repository->get_order( iv_order_id ). mo_logger->log( |Processing order { iv_order_id }| ).
" Business logic (now testable!) " ... ENDMETHOD.
ENDCLASS.Unit Test with 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. " Create stubs & spies mo_repository_stub = NEW zcl_order_repository_stub( ). mo_logger_spy = NEW zcl_logger_spy( ).
" Class Under Test with 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 with test data mo_repository_stub->ms_order = VALUE #( vbeln = '0000012345' kunnr = '000042' ).
" Act mo_cut->process_order( iv_order_id = '0000012345' ).
" Assert: Logger was called (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 (for Legacy Code)
Problem: Legacy code without DI is not testable.
Solution: Test Seams = Injection points for tests.
Example: Non-testable Code
" Legacy code: DB access directly in 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. " Direct DB dependency SELECT SINGLE customer_class FROM zcustomer WHERE customer_id = @iv_customer_id INTO @DATA(lv_class).
" Business logic rv_discount = COND #( WHEN lv_class = 'A' THEN 15 WHEN lv_class = 'B' THEN 10 ELSE 5 ). ENDMETHOD.ENDCLASS.Insert Test Seam
" With Test SeamCLASS zcl_legacy_processor IMPLEMENTATION. METHOD calculate_discount. DATA lv_class TYPE zclass.
" Test Seam: Can be replaced in tests TEST-SEAM customer_lookup. SELECT SINGLE customer_class FROM zcustomer WHERE customer_id = @iv_customer_id INTO @lv_class. END-TEST-SEAM.
" Business logic (now testable!) rv_discount = COND #( WHEN lv_class = 'A' THEN 15 WHEN lv_class = 'B' THEN 10 ELSE 5 ). ENDMETHOD.ENDCLASS.Test with 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.Best Practices
- Test Doubles > Real Dependencies: Always use doubles for database, HTTP, email
- Dependency Injection: Design pattern #1 for testable code
- CDS Test Environment: Must-have for RAP/CDS tests
- Interface-based Design: Every dependency as interface → replaceable
- Test-First: TDD (Test-Driven Development) recommended
- 1 Test = 1 Assertion: Focused, easy to debug
- AAA Pattern: Arrange → Act → Assert (clearly structured)
- Naming:
test_<scenario>(e.g.,test_approve_travel_with_valid_status) - Fast & Isolated: Tests must run < 1 second
- No Side Effects: Tests must not write to DB/filesystem
- Test Seams as Last Resort: Only for legacy code, new code = DI
- Coverage >= 80%: Minimum for production code
- Continuous: Run tests with every commit (CI/CD)
Further Resources
- ABAP Unit Testing: /en/abap-unit-testing/
- RAP Basics: /en/rap-basics/
- EML Guide: /en/eml-entity-manipulation-language/
- ABAP Cloud: /en/abap-cloud-definition/