Test Doubles et Mocking en ABAP Cloud : Le guide complet

Catégorie
ABAP-Statements
Publié
Auteur
Johannes

Les Test Doubles sont des objets de substitution qui remplacent dans les tests unitaires les vraies dependances (base de donnees, APIs externes, autres classes). Ils permettent des tests isoles, rapides et fiables sans effets de bord sur les systemes de production.

Le probleme : Les dependances dans les tests

Sans Test Doubles

" Test unitaire problematique
METHOD test_calculate_discount.
" Probleme 1 : Acces BD dans le test
SELECT SINGLE * FROM zcustomer
WHERE customer_id = '000042"
INTO @DATA(ls_customer).
" Probleme 2 : Depend des donnees BD
DATA(lo_cut) = NEW zcl_discount_calculator( ).
DATA(lv_discount) = lo_cut->calculate( ls_customer ).
" Probleme 3 : Le test echoue si les donnees manquent
cl_abap_unit_assert=>assert_equals(
exp = 10
act = lv_discount
).
ENDMETHOD.

Problemes :

  • Lent : L’acces BD prend 50-500ms par test
  • Fragile : Echoue si le client 000042 n’existe pas
  • Effets de bord : Le test pourrait modifier la BD
  • Non isole : Teste la BD ET la logique metier en meme temps

Avec Test Doubles

" Test unitaire isole
METHOD test_calculate_discount.
" Test Double : Fake Customer (pas de BD !)
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 rapide, fiable, isole
cl_abap_unit_assert=>assert_equals(
exp = 15 " Client Premium → 15% de remise
act = lv_discount
).
ENDMETHOD.

Avantages :

  • Rapide : 0.1ms au lieu de 50ms
  • Fiable : Donnees de test controlees
  • Isole : Seule la logique metier est testee
  • Pas d’effets de bord : Pas de modifications BD

Types de Test Doubles

┌────────────────────────────────────────────────────────┐
│ Test Doubles │
├────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────┐│
│ │ Dummy │ │ Stub │ │ Spy │ │ Mock ││
│ └──────────┘ └──────────┘ └──────────┘ └────────┘│
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ Remplir les Retourne des Enregistre Verifie │
│ parametres valeurs les appels le │
│ predefinies comportement│
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Fake │ │
│ │ Implementation fonctionnelle (simplifiee) │ │
│ └──────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────┘

Dummy

Objets necessaires uniquement pour remplir des parametres (valeur sans importance).

METHOD process_order.
IMPORTING io_logger TYPE REF TO zif_logger. " ← Jamais utilise
" Logique metier sans 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 ).
" Le logger n'est jamais appele → Dummy suffit
ENDMETHOD.

Stub

Retourne des reponses predefinies aux appels de methodes.

" Interface
INTERFACE zif_customer_repository.
METHODS get_customer
IMPORTING iv_id TYPE kunnr
RETURNING VALUE(rs_customer) TYPE zcustomer.
ENDINTERFACE.
" Stub pour les tests
CLASS zcl_customer_repository_stub DEFINITION.
PUBLIC SECTION.
INTERFACES zif_customer_repository.
DATA ms_customer TYPE zcustomer. " Reponse predefinie
ENDCLASS.
CLASS zcl_customer_repository_stub IMPLEMENTATION.
METHOD zif_customer_repository~get_customer.
" Toujours la meme reponse, quel que soit l'ID
rs_customer = ms_customer.
ENDMETHOD.
ENDCLASS.
" Test :
METHOD test_with_premium_customer.
" Arrange : Stub avec client Premium
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

Enregistre les appels (combien de fois, avec quels parametres).

CLASS zcl_logger_spy DEFINITION.
PUBLIC SECTION.
INTERFACES zif_logger.
DATA mt_logged_messages TYPE string_table. " Enregistrement
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 : A-t-il ete logue ?
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

Verifie le comportement (la methode a-t-elle ete appelee avec les bons parametres).

" ABAP n'a pas de framework de mocking natif comme Mockito (Java)
" → Mock manuel ou utiliser 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 : Verifier le 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

Implementation fonctionnelle mais simplifiee.

" Production : Repository base sur la BD
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 : Repository en memoire pour les 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.
" Le Fake utilise une table interne au lieu de la BD
rs_customer = VALUE #( mt_customers[ customer_id = iv_id ] OPTIONAL ).
ENDMETHOD.
ENDCLASS.
" Test :
METHOD test_with_multiple_customers.
" Arrange : Fake avec donnees de test
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 (pour 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.

Creer le Test Environment

METHOD class_setup.
" CDS Test Environment pour les 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' ) " Dependances !
)
).
" Alternative : SQL Test Environment (pour les tables)
mo_sql_test_environment = cl_osql_test_environment=>create(
i_dependency_list = VALUE #(
( 'ZTRAVEL' )
( 'ZBOOKING' )
)
).
ENDMETHOD.

Inserer des donnees de test

METHOD setup.
" Par test : Donnees de test fraiches
mo_environment->clear_doubles( ).
" Donnees de test pour ZI_Customer (Dependance !)
mo_environment->insert_test_data(
i_data = VALUE i_customer(
( Customer = '000042' CustomerName = 'Max Mustermann' CustomerClass = 'A' )
( Customer = '000099' CustomerName = 'Lisa Exemple' CustomerClass = 'B' )
)
).
" Donnees de test pour 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 avec EML

METHOD test_create_travel.
" Arrange : Donnees de test deja dans 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 : Pas d'erreur
cl_abap_unit_assert=>assert_initial(
act = commit_failed-travel
msg = 'Travel creation should succeed"
).
" Assert : Travel a ete cree (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.

Tester la validation

METHOD test_validate_dates.
" Arrange : EndDate < BeginDate (ERREUR !)
" Act
MODIFY ENTITIES OF zi_travel
ENTITY Travel
CREATE FIELDS ( BeginDate EndDate )
WITH VALUE #(
( %cid = 'T1"
BeginDate = '20250615' " 15 juin
EndDate = '20250601' " 1er juin → FAUX !
CustomerId = '000042"
AgencyId = '000001' )
)
FAILED DATA(failed).
COMMIT ENTITIES
RESPONSE OF zi_travel
FAILED DATA(commit_failed)
REPORTED DATA(commit_reported).
" Assert : Erreur attendue
cl_abap_unit_assert=>assert_not_initial(
act = commit_failed-travel
msg = 'Validation should fail for invalid dates"
).
" Assert : Message d'erreur present
cl_abap_unit_assert=>assert_bound(
act = commit_reported-travel[ 1 ]-%msg
msg = 'Error message should be present"
).
" Assert : Champ EndDate marque
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.
" Apres chaque test : Nettoyage
ROLLBACK ENTITIES.
mo_environment->clear_doubles( ).
ENDMETHOD.
METHOD class_teardown.
" Apres tous les tests : Detruire l'environnement
mo_environment->destroy( ).
mo_sql_test_environment->destroy( ).
ENDMETHOD.

Dependency Injection

Probleme : Couplage fort

" Non testable : Dependance codee en dur
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.
" Probleme : Dependance BD directe
SELECT SINGLE * FROM vbak WHERE vbeln = @iv_order_id INTO @DATA(ls_order).
" Probleme : Acces logger direct
zcl_logger=>log( |Processing order { iv_order_id }| ).
" Logique metier
" ...
ENDMETHOD.
ENDCLASS.
" Test unitaire impossible :
" - Necessite une vraie BD avec des donnees de test
" - Le logger ecrit dans la table de log productive

Solution : Constructor Injection

" Testable : Dependances en tant qu'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 : Utiliser les dependances injectees
DATA(ls_order) = mo_repository->get_order( iv_order_id ).
mo_logger->log( |Processing order { iv_order_id }| ).
" Logique metier (maintenant testable !)
" ...
ENDMETHOD.
ENDCLASS.

Test unitaire avec 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.
" Creer Stubs & Spies
mo_repository_stub = NEW zcl_order_repository_stub( ).
mo_logger_spy = NEW zcl_logger_spy( ).
" Class Under Test avec 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 avec donnees de test
mo_repository_stub->ms_order = VALUE #(
vbeln = '0000012345"
kunnr = '000042"
).
" Act
mo_cut->process_order( iv_order_id = '0000012345' ).
" Assert : Le logger a ete appele (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 (pour le code legacy)

Probleme : Le code legacy sans DI n’est pas testable.

Solution : Test Seams = Points d’injection pour les tests.

Exemple : Code non testable

" Code legacy : Acces BD directement dans le 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.
" Dependance BD directe
SELECT SINGLE customer_class FROM zcustomer
WHERE customer_id = @iv_customer_id
INTO @DATA(lv_class).
" Logique metier
rv_discount = COND #(
WHEN lv_class = 'A' THEN 15
WHEN lv_class = 'B' THEN 10
ELSE 5
).
ENDMETHOD.
ENDCLASS.

Inserer un Test Seam

" Avec Test Seam
CLASS zcl_legacy_processor IMPLEMENTATION.
METHOD calculate_discount.
DATA lv_class TYPE zclass.
" Test Seam : Peut etre remplace dans les tests
TEST-SEAM customer_lookup.
SELECT SINGLE customer_class FROM zcustomer
WHERE customer_id = @iv_customer_id
INTO @lv_class.
END-TEST-SEAM.
" Logique metier (maintenant testable !)
rv_discount = COND #(
WHEN lv_class = 'A' THEN 15
WHEN lv_class = 'B' THEN 10
ELSE 5
).
ENDMETHOD.
ENDCLASS.

Test avec 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.

Remarques importantes / Bonnes pratiques

  • Test Doubles > Vraies dependances : Toujours utiliser des Doubles pour BD, HTTP, Email
  • Dependency Injection : Pattern de design #1 pour du code testable
  • CDS Test Environment : Indispensable pour les tests RAP/CDS
  • Design base sur les interfaces : Chaque dependance en tant qu’interface → interchangeable
  • Test-First : TDD (Test-Driven Development) recommande
  • 1 Test = 1 Assertion : Focalise, facile a deboguer
  • Pattern AAA : Arrange → Act → Assert (clairement structure)
  • Nommage : test_<scenario> (ex. test_approve_travel_with_valid_status)
  • Rapide & Isole : Les tests doivent s’executer en < 1 seconde
  • Pas d’effets de bord : Les tests ne doivent rien ecrire dans la BD/systeme de fichiers
  • Test Seams comme solution de secours : Uniquement pour le code legacy, nouveau code = DI
  • Couverture >= 80% : Minimum pour le code productif
  • Continu : Tests a chaque commit (CI/CD)

Ressources supplementaires