Sebastian Wey,

ABAP Objects DI-Container Unit-Test

Um den Aufwand für die Entwicklung von Unit-Tests überschaubar zu halten, werden im OO-Design Aspekte wie das Single-Responsibility-Prinzip oder Inversion-of-Control benötigt. Gerade für letzteres bietet sich ein Dependency-Injection-Container an, die in anderen Umgebungen wie Java oder C# selbstverständlich sind. In diesem Blog-Post möchte ich einen DI-Container für ABAP Objects vorstellen, der für viele einfache Szenarios eingesetzt werden kann.

Für Unit-Tests ist es wichtig, die Klassen zu designen, die möglichst unabhängig von anderen Klassen sind, damit diese isoliert getestet werden können. Der Einsatz von Interfaces ist schon ein erster richtiger Schritt zur Entkoppelung. Aber spätestens bei der Erzeugung eines Objektes der abhängigen Klasse ergibt sich eine Abhängigkeit, die in Unit-Tests zum Problem führt, dass die abhängige Klasse nicht durch einen Mock ausgetauscht werden kann. Eine Lösung dazu ist Dependency-Injection. Die zu testende Klasse darf ihre abhängigen Klassen nicht selbst erzeugen, sondern bekommt diese injiziert, z. B. per Parameterübergabe im Constructor.

Der in diesem Blog-Post vorgestellte DI-Container hat folgende Eigenschaften:

Der DI-Container hat zwei öffentliche Methoden:

Ein Objekt zu einem Interface? Nehmen wir an, wir haben ein Interface zum Lesen von Daten aus dem SAP Geschäftspartner. Dann wird für dieses Interface eine implementierende Klasse registriert, und wenn ein Objekt zu diesem Interface angefragt wird, wird ein Objekt der implementierenden Klasse erzeugt und zurückgegeben. Die konkrete Klasse kennt der Verwender des Interfaces nicht, und somit hat er keine Abhängigkeit zu dieser konkreten Klasse. Für Unit-Tests wird nun einfach ein Mock für dieses Interface registriert, und so können die Verwender des Interfaces getestet werden, ohne dass im SAP Geschäftspartner überhaupt Daten vorhanden sein müssen.

Richtig interessant wird es nun, wenn das ganze rekursiv durchgeführt wird. Wenn die implementierende Klasse erzeugt wird, wird geprüft, ob diese ebenfalls Abhängigkeiten hat. Dazu wird geprüft, ob die Klasse einen Constructor hat, und wenn ja, ob dieser Objektreferenzen als Parameter hat. In diesem Fall wird für die Objektreferenzen ebenfalls versucht, Objekte zu erzeugen. Die gleiche Prüfung wird für diese Objekte ebenfalls durchgeführt usw.

Sourcecode

Hier der vollständige Sourcecode der Klasse ZCL_DI_CONTAINER:

class ZCL_DI_CONTAINER definition
  public
  final
  create public .
public section.
*"* public components of class ZCL_DI_CONTAINER
*"* do not include other source files here!!!
  types T_SCOPE type CHAR1 .
  constants SCOPE_TRANSIENT type T_SCOPE value 'T'. "#EC NOTEXT
  constants SCOPE_SINGLETON type T_SCOPE value 'S'. "#EC NOTEXT
  methods BIND
    importing
      !SERVICE type CLIKE
      !TARGET type CLIKE optional
      !SCOPE type T_SCOPE default 'T' .
  methods GET
    importing
      !SERVICE type CLIKE
    returning
      value(RESULT) type ref to OBJECT .
protected section.
*"* protected components of class ZCL_DI_CONTAINER
*"* do not include other source files here!!!
private section.
*"* private components of class ZCL_DI_CONTAINER
*"* do not include other source files here!!!
  types:
    BEGIN OF t_binding,
      service       TYPE string,
      service_descr TYPE REF TO cl_abap_objectdescr,
      target_descr  TYPE REF TO cl_abap_classdescr,
      scope         TYPE t_scope,
      singleton     TYPE REF TO object,
    END OF t_binding .
  types:
    t_binding_tab TYPE HASHED TABLE OF t_binding WITH UNIQUE KEY service .
  data BINDINGS type T_BINDING_TAB .
  methods CREATE_BINDING
    importing
      !SERVICE_DESCR type ref to CL_ABAP_OBJECTDESCR
      !TARGET_DESCR type ref to CL_ABAP_CLASSDESCR optional
      !SCOPE type T_SCOPE default 'T'
    returning
      value(RESULT) type ref to T_BINDING .
  methods GET_CONSTRUCTOR_PARMBIND
    importing
      !TARGET_DESCR type ref to CL_ABAP_CLASSDESCR
    returning
      value(RESULT) type ref to ABAP_PARMBIND_TAB .
ENDCLASS.
CLASS ZCL_DI_CONTAINER IMPLEMENTATION.
* <SIGNATURE>---------------------------------------------------------------------------------------+
* | Instance Public Method ZCL_DI_CONTAINER->BIND
* +-------------------------------------------------------------------------------------------------+
* | [--->] SERVICE                        TYPE        CLIKE
* | [--->] TARGET                         TYPE        CLIKE (optional)
* | [--->] SCOPE                          TYPE        T_SCOPE (default ='T')
* +--------------------------------------------------------------------------------------</SIGNATURE>
METHOD bind.
  DATA:
    service_descr TYPE REF TO cl_abap_objectdescr,
    target_descr  TYPE REF TO cl_abap_classdescr.
  service_descr ?= cl_abap_objectdescr=>describe_by_name( service ).
  IF NOT target IS INITIAL.
    target_descr ?= cl_abap_classdescr=>describe_by_name( target ).
  ENDIF.
  create_binding(
    service_descr = service_descr
    target_descr = target_descr
    scope = scope ).
ENDMETHOD.
* <SIGNATURE>---------------------------------------------------------------------------------------+
* | Instance Private Method ZCL_DI_CONTAINER->CREATE_BINDING
* +-------------------------------------------------------------------------------------------------+
* | [--->] SERVICE_DESCR                  TYPE REF TO CL_ABAP_OBJECTDESCR
* | [--->] TARGET_DESCR                   TYPE REF TO CL_ABAP_CLASSDESCR (optional)
* | [--->] SCOPE                          TYPE        T_SCOPE (default ='T')
* | [<-()] RESULT                         TYPE REF TO T_BINDING
* +--------------------------------------------------------------------------------------</SIGNATURE>
METHOD create_binding.
  DATA binding TYPE t_binding.
  IF NOT target_descr IS INITIAL.
    binding-target_descr = target_descr.
  ELSE.
    binding-target_descr ?= service_descr.
  ENDIF.
  ASSERT binding-target_descr->is_instantiatable( ) EQ abap_true.
  binding-service = service_descr->absolute_name.
  binding-service_descr = service_descr.
  binding-scope = scope.
  INSERT binding INTO TABLE bindings REFERENCE INTO result.
ENDMETHOD.
* <SIGNATURE>---------------------------------------------------------------------------------------+
* | Instance Public Method ZCL_DI_CONTAINER->GET
* +-------------------------------------------------------------------------------------------------+
* | [--->] SERVICE                        TYPE        CLIKE
* | [<-()] RESULT                         TYPE REF TO OBJECT
* +--------------------------------------------------------------------------------------</SIGNATURE>
METHOD get.
  DATA:
    binding       TYPE REF TO t_binding,
    parmbindtab   TYPE REF TO abap_parmbind_tab,
    service_descr TYPE REF TO cl_abap_objectdescr.
  service_descr ?= cl_abap_objectdescr=>describe_by_name( service ).
  READ TABLE bindings REFERENCE INTO binding WITH TABLE KEY service = service_descr->absolute_name.
  IF sy-subrc NE 0.
    binding = create_binding( service_descr ).
  ENDIF.
  IF binding->scope EQ scope_singleton AND binding->singleton IS BOUND.
    result = binding->singleton.
    RETURN.
  ENDIF.
  parmbindtab = get_constructor_parmbind( binding->target_descr ).
  IF parmbindtab->* IS INITIAL.
    CREATE OBJECT result TYPE (binding->target_descr->absolute_name).
  ELSE.
    CREATE OBJECT result TYPE (binding->target_descr->absolute_name)
      PARAMETER-TABLE parmbindtab->*.
  ENDIF.
  IF binding->scope EQ scope_singleton AND NOT binding->singleton IS BOUND.
    binding->singleton = result.
  ENDIF.
ENDMETHOD.
* <SIGNATURE>---------------------------------------------------------------------------------------+
* | Instance Private Method ZCL_DI_CONTAINER->GET_CONSTRUCTOR_PARMBIND
* +-------------------------------------------------------------------------------------------------+
* | [--->] TARGET_DESCR                   TYPE REF TO CL_ABAP_CLASSDESCR
* | [<-()] RESULT                         TYPE REF TO ABAP_PARMBIND_TAB
* +--------------------------------------------------------------------------------------</SIGNATURE>
METHOD get_constructor_parmbind.
  DATA:
    constructor TYPE REF TO abap_methdescr,
    parmdescr   TYPE REF TO abap_parmdescr,
    refdescr    TYPE REF TO cl_abap_refdescr,
    dependency  TYPE REF TO cl_abap_objectdescr,
    parmbind    TYPE abap_parmbind.
  FIELD-SYMBOLS <fs> TYPE any.
  CREATE DATA result.
  READ TABLE target_descr->methods
      REFERENCE INTO constructor
      WITH KEY name = 'CONSTRUCTOR'.
  IF sy-subrc EQ 0.
    LOOP AT constructor->parameters REFERENCE INTO parmdescr.
      ASSERT parmdescr->type_kind EQ cl_abap_objectdescr=>typekind_oref.
      ASSERT parmdescr->parm_kind EQ cl_abap_objectdescr=>importing.
      refdescr ?= target_descr->get_method_parameter_type(
          p_method_name = constructor->name
          p_parameter_name = parmdescr->name ).
      CREATE DATA parmbind-value TYPE HANDLE refdescr.
      ASSIGN parmbind-value->* TO <fs>.
      dependency ?= refdescr->get_referenced_type( ).
      <fs> ?= get( dependency->absolute_name ).
      parmbind-name = parmdescr->name.
      parmbind-kind = cl_abap_objectdescr=>exporting.
      INSERT parmbind INTO TABLE result->*.
    ENDLOOP.
  ENDIF.
ENDMETHOD.
ENDCLASS.

Ganz wesentlich ist die Methode GET_CONSTRUCTOR_PARMBIND. Hier werden die Übergabeparameter für den Konstruktor ermittelt, und hier findet die Rekursion zur Auflösung dieser Parameter statt.

Beim Registrieren von Klassen kann der TARGET-Typ weggelassen werden. Dadurch können Klassen auf sich selbst registriert werden. Das kann dazu verwendet werden, um eine Klasse als Singleton zu registrieren. Dadurch muss dieses Entwurfsmuster nicht mehr in der Klasse selbst implementiert werden.

Verwendung

Die Registrierung von Klassen erfolgt üblicherweise beim Start eines Programms, also z. B. in den Ereignissen LOAD-OF-PROGRAM einer Funktionsgruppe oder INITIALIZATION eines Reports, oder aber in einer SETUP-Methode einer Unit-Test-Klasse.

Beispiel: Nehmen wir an, es gibt ein Interface ZIF_BP_SERVICE mit Servicemethoden zum Lesen von Geschäftspartnerdaten. Weiterhin gibt es dazu eine Klasse ZCL_BP_SERVICE, die dieses Interface implementiert. Zusätzlich gibt es das Interface ZIF_ACCOUNT und die Klasse ZCL_ACCOUNT. In unserem Programm registrieren wir diese so:

LOAD-OF-PROGRAM.
  DATA di_container TYPE REF TO zcl_di_container.
  CREATE OBJECT di_container.
  di_container->bind( service = 'ZIF_BP_SERVICE' target = 'ZCL_BP_SERVICE' ).
  di_container->bind( service = 'ZIF_ACCOUNT' target = 'ZCL_ACCOUNT' ).
<

Die Klasse ZCL_ACCOUNT benötigt den BP-Service und bekommt daher in ihrem Konstruktor einen entsprechenden Parameter:

CLASS zcl_account DEFINITION PUBLIC.
  PUBLIC SECTION.
    INTERFACES zif_account.
    METHODS constructor IMPORTING bp_service TYPE REF TO zif_bp_service.
* ...
ENDCLASS.

Ein Objekt der Klasse ZCL_ACCOUNT erhalten wir auf folgende Weise:

DATA account TYPE REF TO zif_account.
account ?= di_container->get( service = 'ZIF_ACCOUNT' ).

Zusammenfassung und Ausblick

Für viele einfache Szenarios wird der vorgestellte DI-Container sicherlich ausreichend sein. Es gibt jedoch auch Szenarios, in denen weitergehende Anforderungen bestehen. Wenn zu einem Interface mehrere Implementierungen vorhanden sind, und über Regeln oder Filterwerte die konkrete Implementierung ausgewählt werden soll, werden Multiple-Bindings benötigt. Bei komplexen Klassenhierarchien werden schnell die Grenzen der Constructor-Injection erreicht. Hier bietet sich dann die Setter-Injection an, bei der die Abhängigkeiten über spezielle Methoden in das Objekt injiziert werden.


Zurück zur Liste < Verwendungszweck-Parser mit regulären Ausdrücken FS-CD Business Domain Filter >