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
1. Unit tests must be easy to write, read and maintain.
2. While the first test might take a while to write, the subsequent ones should take very minimal time.
3. 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:
1. Get instance of service manager.
2. 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
1. It doesn’t affect run time.
2. No extra methods or factory is needed for unit testing.
3. 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
1. 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.
2. 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:
1. 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.
2. If more methods such as retrieve or convert alternate key etc are required, enhance the mock BO class with those methods.
3. The retrieve by association can be extended to support more nodes and input of more than 1 key.
4. 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.
While the core lessons have been written about several times, I think the most important lessons are
1. Unit tests must be easy to write, read and maintain.
2. While the first test might take a while to write, the subsequent ones should take very minimal time.
3. 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.
1. Get instance of service manager.
2. 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
1. It doesn’t affect run time.
2. No extra methods or factory is needed for unit testing.
3. 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
1. 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.
2. 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:
1. 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.
2. If more methods such as retrieve or convert alternate key etc are required, enhance the mock BO class with those methods.
3. The retrieve by association can be extended to support more nodes and input of more than 1 key.
4. 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.
No comments:
Post a Comment