Friday 31 May 2019

zmockup_loader and unit tests with interface stubbing

This article describes our experience of using mockup loader together with data accessor pattern approach.

Data accessor pattern overview


The code that needs to access DB inside is a bit complex to unit-test. One of the approaches to do this (if well designed from the beginning) is so called data accessor pattern.

SAP ABAP Study Materials, SAP ABAP Guides, SAP ABAP Learning, SAP ABAP Tutorials and Materials

The idea is to isolate selects from the processing code and abstract them behind a separate interface. The processing class does not read the database itself but rather calls to the data accessor instance that had been passed to it during instantiation. The implementation of the interface may differ depending on the environment (prod/test) or some other circumstances. For example (pseudo code):

interface zif_document.
  methods select_document importing i_docnr returning rt_doc_lines.
  methods update_document ...
endinterface.
...
class zcl_db_accessor.
  interfaces: zif_document.
  method zif_document~select_document.
    select ... from ... where document_id = i_docnr.
  endmethod.
endclass.
...
lo_data_accessor = new zcl_db_accessor( ).
lo_app = new zcl_my_program( if_data_accessor = lo_data_accessor ).
lo_app->run( ).

This design creates an opportunity to implement own test-related accessor class which will supply the test mocks instead of selects to DB. The app class will not be aware.

class zcl_mock_accessor.
  interfaces: zif_document.
  method zif_document~select_document.
    return #( document_id = 1, amount = 100, date = 2018-09-01, ... ).
  endmethod.
endclass.
...
* unit test
lo_app = new zcl_my_program( if_data_accessor = new zcl_mock_accessor( ) ).
lo_app->method_under_test( ... ).

Stubbing interfaces with mockup loader


Mockup loader has 2 instruments to unit-test `db-related` code. However, it requires writing some test-related code within the “production” code which is not ideal (although a chipper way if the program was not designed properly from the beginning). The second approach is interface stubbing which has been recently introduced to the mockup loader and described below.

Obviously, each unit-test can implement the mentioned interface and replace the select with zmockup_loader calls. I found that this code is quite similar and repeating in most cases. So why not to make it more convenient and automatic. Here is the resulting solution:

*** test class setup (or class-setup)
data lo_factory type ref to zcl_mockup_loader_stub_factory.
data lo_ml      type ref to zcl_mockup_loader.

lo_ml = zcl_mockup_loader=>create(
  i_type = 'MIME'
  i_path = 'ZMOCKUP_LOADER_EXAMPLE' ). " <YOUR MOCKUP>

create object lo_factory
  exporting
    io_ml_instance   = lo_ml
    i_interface_name = 'ZIF_MY_DATA_ACCESSOR'. " <YOUR INTERFACE TO STUB>

" Connect one or MANY methods to respective mockups 
lo_factory->connect_method(
  i_method          = 'SELECT_XXX'           " <METHOD TO STUB>
  i_mock_name       = 'EXAMPLE/documents' ). " <MOCK PATH>

data mi_da_stub type ref to ZIF_MY_DATA_ACCESSOR. 
mi_da_stub ?= lo_factory->generate_stub( ). 

*** Pass the stub to code-under-test in the test method
create object lo_my_app
  exporting
    if_data_accessor = mi_da_stub.
lo_my_app->method_under_test( ... ).

So what is happening here:

1. Mockup loader instance is created and bind to the source of mockups

2. zcl_mockup_loader_stub_factory is created linking the instance of mockup loader and the target data accessor interface (this must be a global interface)

3. connect_method links a particular interface method to a particular mock table. This can be repeated for multiple methods if needed

4. generate_stub generates the instance of dynamically formed class that implements the data accessor interface and read the data from mockup loader. mi_ifstub should probably be an attribute, maybe even class attribute if the test data is unified enough (for the efficiency considerations)

5. finally, the interface is passed to the code-under-test, which is even not aware that the data is mocked.

Filtering features


If you have multiple data-sets for the same code-under-test you might need to create multiple stubs … or you can use filtering solution that was implemented in the mockup loader. Suppose you have table mock which has records like:

DOCNR LINENR AMOUNT ...
100   1      1000
100   2      2000
200   1      5000

… where the DOCNR represents a test case. So you might want to build a loop over the test cases calling the same code and checking the result. The test cases can also be a separate mock table by the way. Here is how to implement this task within one stub.

lo_factory->connect_method(
  i_method_name     = 'SELECT_XXX'         " <METHOD TO STUB>
  i_sift_param      = 'I_DOCNR'            " <FILTERING PARAM>
  i_mock_tab_key    = 'DOCNR'              " <MOCK HEADER FIELD>
  i_mock_name       = 'EXAMPLE/documents' ). " <MOCK PATH>

Note two optional parameters of the connect_method: i_sift_param and i_mock_tab_key. The first refers to the name of a real ‘importing’ parameter in the interface method and the second the key field in the mock table. So in the above example the mockup loader will take the actual value passed with the I_DOCNR parameter and filter only those table lines of the test data where DOCNR = I_DOCNR. One stub – multiple tests.

N.B. only one filtering parameter is supported – which is a simplification in some way – but for the unit tests it should be enough (at least in our experience).

Exporting, Changing, Returning


I didn’t mention yet how the data is returned from interface methods. Indeed, it can be returning or changing or exporting parameter. By default the stubbing code assumes the data is ‘returning’. This can be overridden like this:

lo_factory->connect_method(
  i_method_name     = 'SELECT_XXX'         " <METHOD TO STUB>
  i_output_param    = 'ET_DATA'            " <PARAMETER FOR THE DATA>
  i_mock_name       = 'EXAMPLE/documents' ). " <MOCK PATH>

Consider the i_output_param parameter. It refers to the name of the parameter to put the mock data into. This can be exporting, changing or returning parameter. By default (if empty or not given) the stubber looks for returning parameter and use it.

lo_factory->connect_method(
  i_method_name     = 'SELECT_XXX'         " <METHOD TO STUB>
  i_output_param    = 'ET_DATA'            " <PARAMETER FOR THE DATA>
  i_mock_name       = 'EXAMPLE/documents' ). " <MOCK PATH>

Final words


I hope you’ll find the tool useful for your unit-tests. It is not always the case that you need a lot of testing data to prepare and check. But when it is so mockup loader proved very useful, at least for our team. Preparation of the test case data in Excel is a charm compared to hard-coding it (ecatt seems to be less convenient in our opinion).

The code is open sourced and can be found in this github repository.

The best way to install the tool would be abapGit  – this is an amazing developer tool, if you haven’t tried it yet, you really should.

In the articles yet to come: convenient conversion of batch of excels into a mockup slug in the system and in-sap mockup editing. Stay tuned. 

Technical notes on the implementation for geeks


The initial version of the stubber used test double framework (TDF below) which seems to be popular these days (though I find very un-intuitive that it requires to call the method after configuring it … imho). However, the TDF is not supported under 7.4 and some of our clients are still there so as the result I created a ‘native’ stubber. It uses ‘generate subroutine pool’ to create the class that implements the given interface dynamically. To minimize dynamic coding some common code was implemented in zcl_mockup_loader_stub_base class which is then inherited in the dynamic one. All of interface methods are implemented – those which were not ‘connected’ via connect_method would just have empty implementations. And here is one interesting fact …

Originally, I wanted to define the dynamic implementation as the class ‘for testing’. Indeed, this is test-only code and would also give a possibility to use ‘partially implemented’ keyword for the interface. But it didn’t worked out. The dynamic code would be successfully generated but the attempt to create the class would fail with “class \PROG=%…\CLASS=LCL_STUB does not exist”. After several hours or unsuccessful trials I finally realized that the “for testing” class cannot be referred dynamically. I’m mentioning it here so that it could be useful for someone. I didn’t find any topics in the internet (though didn’t search really hard). Intuitively it can be explained so that the test code is kind of “meta” code to the program which maybe stored somehow in different way. But if whoever can leave the proper clarification in comments – that would be nice.

But wait – what has happened to the TDF implementation? It also exists but I separated it into another repository (to avoid compilation issues under 7.4). Can be used if you prefer TDF. It is a lightweight addon and just redefines a couple of methods. You just need to use another factory class – zcl_mockup_loader_dbl_factory – everything else remains the same as in the above examples.

One interesting technical aspect is how to trigger the method automatically after if was configured with TDF. In order to do this I have to call a method with dynamically obtained parameters. Fortunately there is the ‘parameter-table’ keyword:

call method r_stub->(lv_invoke_name)
  parameter-table lt_dummy_params.

Here is how I create the parameter table:
  • describe the interface by cl_abap_objectdescr=>describe_by_name
  • take the needed method description and find all non-optional importing and changing params
  • for each: call cl_abap_objectdescr->get_method_parameter_type to get type descriptor object and then instantiate the parameter data ref with ‘create data … type handle’
  • one trick here is to care about abstract data types like ‘any’, ‘clike’ and etc. For those cases specifically I explicitly create ‘char1’ data ref.
  • parameter table is ready

No comments:

Post a Comment