A simple way to increase maintainability of ABAP unit tests – with BOPF example
I am a fan of unit testing. I have been using it in my code for the past 2 years even though it wasn’t asked for. I thought I was good at it until I attended the ABAP Unit Testing OpenSAP course and read the book xUnit Test Patterns. These two gave me very good perspectives and tools to up my unit testing game.
While the core lessons have been written about several times, I think the most important lessons are
- Unit tests must be easy to write, read and maintain.
- While the first test might take a while to write, the subsequent ones should take very minimal time.
- Most important of all. The unit test must be self contained. So setting up the data (for the dependent object calls), running the code under test and verifying should be in the same unit test.
The last one helped me make my unit tests more readable and maintainable.
In my recent project, we use BOPF. In this blog, I wish to illustrate how to organize the unit tests better with a case where we use BOPF. Its using the above 3 points.
For our case, let us consider the BOPF BO as follows:
Header -> Item. Item contains a field ‘Status’.
Status can be Not started, completed, error
Now for our example, let us say we have to provide a method that returns the number of items that are completed.
For those of you familiar with BOPF, to read data from items, we require the following:
- Get instance of service manager.
- Call retrieve_by_association on header passing header key to get the items.
So here we go. This is our class and method. I know that its a good practice to write the unit tests before. But bear with me for now.
CLASS zcl_unit_test_example_class DEFINITION PUBLIC FINAL CREATE PUBLIC . PUBLIC SECTION. METHODS get_completed_items_count IMPORTING !iv_key TYPE /bobf/conf_key RETURNING VALUE(rv_count) TYPE int4 . PROTECTED SECTION. PRIVATE SECTION. ENDCLASS. CLASS zcl_unit_test_example_class IMPLEMENTATION. METHOD get_completed_items_count. DATA: t_items TYPE bo_items. rv_count = 0. TEST-SEAM srv_mngr. DATA(o_srv_mngr) = /bobf/cl_tra_serv_mgr_factory=>get_service_manager( if_i_status_bo_c=>sc_bo_key ). END-TEST-SEAM. o_srv_mngr->retrieve_by_association( EXPORTING iv_node_key = if_i_status_bo_c=>sc_node-header it_key = VALUE #( ( key = iv_key ) ) iv_association = if_i_status_bo_c=>sc_association-header-items iv_fill_data = abap_true IMPORTING et_data = t_items ). rv_count = REDUCE int4( INIT result = 0 FOR wa IN t_items WHERE ( wa-status = 'Completed' ) ( result = result + 1 ) ). ENDMETHOD. ENDCLASS.
Now, I have wrapped the service manager instantiation into a seam. This works better for me than other methods of dependency injection because
- It doesn’t affect run time.
- No extra methods or factory is needed for unit testing.
- No need for the unit test class to be friends of any class.
In the unit test, I create a local mock BO class. Setting data for this shall be done in each unit test itself. Code for this is as follows.
CLASS lcl_bo_mock DEFINITION FINAL. PUBLIC SECTION. INTERFACES /bobf/if_tra_service_manager. CLASS-METHODS set_data IMPORTING is_header TYPE bo_header it_items TYPE bo_items. PRIVATE SECTION. CLASS-DATA: s_header TYPE bo_header, t_items TYPE bo_items. ENDCLASS. CLASS lcl_bo_mock IMPLEMENTATION. METHOD set_data. s_header = is_header. t_items = it_items. ENDMETHOD. METHOD /bobf/if_tra_service_manager~retrieve_by_association. IF iv_association = if_i_status_bo_c=>sc_association-header-items. et_data = VALUE bo_items( FOR wa IN t_items WHERE ( key = it_key[ 1 ]-key ) ( wa ) ). ENDIF. ENDMETHOD. ENDCLASS.
In this, I have the nodes as local variables. Based on the key, the corresponding items are fetched.
The set_data method can be used inside each unit test to set the data needed just for that unit test. Lets see how this works with 1 unit test method.
CLASS lcl_unit DEFINITION FOR TESTING FINAL DURATION SHORT RISK LEVEL HARMLESS. PUBLIC SECTION. METHODS setup. methods t_2_completed FOR TESTING. PRIVATE SECTION. DATA m_cut TYPE REF TO zcl_unit_test_example_class. ENDCLASS. CLASS lcl_unit IMPLEMENTATION. METHOD setup. TEST-INJECTION srv_mngr. o_srv_mngr = new lo_mock_bo( ). end-test-injection. CREATE OBJECT m_cut. ENDMETHOD. METHOD t_2_completed. " Check if 2 items in the list is completed lcl_bo_mock=>set_data( s_header = VALUE #( key = '1' ) t_items = VALUE #( ( key = '2' parent_key = '1' Status = 'Completed' ) ( key = '2' parent_key = '1' Status = 'Completed' ) ) ). data(result) = m_cut->get_completed_items_count( iv_key = '1' ). cl_abap_unit_assert=>assert_equals( act = result exp = 2 ). ENDMETHOD. ENDCLASS.
Now that is a lot of code to write for 1 unit test. But it is a lot of effort for just the first unit test. But the advantages are
- The data set up code, executing the code under test and result verification is in just 1 place inside the test method. Now I will know the input data and expected result in just 1 place. This improves organizing the unit test much better for me.
- From now on, I can simply copy the unit test method and make changes for further variations. That takes hardly a few seconds for each method.
Here I created 2 more methods quickly.
METHOD t_0_completed. " Check if 0 items in the list is completed lcl_bo_mock=>set_data( s_header = VALUE #( key = '1' ) t_items = VALUE #( ( key = '2' parent_key = '1' Status = 'Not started' ) ( key = '2' parent_key = '1' Status = 'Not started' ) ) ). data(result) = m_cut->get_completed_items_count( iv_key = '1' ). cl_abap_unit_assert=>assert_equals( act = result exp = 0 ). ENDMETHOD. METHOD t_1_completed. " Check if 1 items in the list is completed lcl_bo_mock=>set_data( s_header = VALUE #( key = '1' ) t_items = VALUE #( ( key = '2' parent_key = '1' Status = 'Not started' ) ( key = '2' parent_key = '1' Status = 'Completed' ) ) ). data(result) = m_cut->get_completed_items_count( iv_key = '1' ). cl_abap_unit_assert=>assert_equals( act = result exp = 1 ). ENDMETHOD.
This idea can be extended as follows:
- If the class has more than 1 method which need to be unit tested, move the place where service manager is initialized to a method and call this everywhere. So there will be one test seam and test injection only.
- If more methods such as retrieve or convert alternate key etc are required, enhance the mock BO class with those methods.
- The retrieve by association can be extended to support more nodes and input of more than 1 key.
- Maybe move the mock BO class to a global test class that can be used across all the classes that require unit testing of code that reads from this BO.
I know that there is a BOUnit test tool available. But this is how I like to do it. Thoughts? Comments?