Why & when to use a Builder Class
A Test Method is divided in the 4 Parts: Setup, Exercise, Verify and Teardown (Meszaros).
Often you see that a huge and complicated part is the Setup, where you replace Dependencies with Test-Doubles to test a Class in Isolation. If you have complex Dependencies it is very easy to pollute your Test-Class with hundreds of lines of setup Code. If you want to test different scenarios, with different Input e.g. from Stubs, it get's even more worst.
One way to get around it would be the use of a mocking Framework that generates Code. I prefer self-written Stubs, Fakes or Mocks for different reasons.
To make Test-Classes shorter and more expressive I use often of Helper-Classes that use the "Builder Pattern". They can be a local Class if it is used for a single Test, or a global Class if it can be reused in several other Test's.
In the simplest case you just call the build Method, that creates a bound Instance to satisfy another Method to call - here with a Customer.
lcl_customer_builder=>new(
)->build( ).
In simple human readable Text you can add Method's that set data for the instance to build.
lcl_customer_builder=>new(
)->with_name(
'Max Mustermann'
)->with_customer_id(
'1234'
)->build( ).
Also it is very comfortable if Class to build has own Dependencies you have to satisfy. Inside the Builder you can simply set them to Default or Null Implementations.
Without the Builder Pattern the Code may have looked like this:
DATA: lo_customer TYPE REF TO lif_customer,
lo_fake_company TYPE REF TO lif_company.
lo_customer =
zcl_customer_factory=>get_instance()->get_customer( '1234' ).
lo_customer->set_name( 'Max Mustermann' ).
lo_customer->set_company(
zcl_company_factory=>get_company_null( )
).
An Example - Build a complex Fake Class
Test Class under Test and it's dependency
In our Example the Class under Test ZCL_EXAMPLE_CLASS_UNDER_TEST uses the Interface ZIF_DATE_OBJ_LOOKUP and it's Implementation ZCL_DB_DATE_OBJ_LOOKUP. For demonstration purposes it takes a Date and returns a generic Object Reference.
To test the Class ZCL_EXAMPLE_CLASS_UNDER_TEST in isolation we have to replace the use of ZCL_DB_DATE_OBJ_LOOKUP - that would access the Database.
Definition of ZIF_DATE_OBJ_LOOKUP
INTERFACE zif_date_obj_lookup
PUBLIC .
METHODS get_obj_for_date
IMPORTING
!i_date TYPE dats
RETURNING
value(r_obj) TYPE REF TO object .
ENDINTERFACE.
Our Class under Test ZCL_EXAMPLE_CLASS_UNDER_TEST
CLASS zcl_example_class_under_test DEFINITION
PUBLIC
FINAL
CREATE PUBLIC .
PUBLIC SECTION.
METHODS:constructor,
do_stuff
RETURNING value(r_value) TYPE i.
PRIVATE SECTION.
DATA mo_date_obj_lookup TYPE REF TO zif_date_obj_lookup .
ENDCLASS. "ZCL_EXAMPLE_CLASS_UNDER_TEST DEFINITIONCLASS zcl_example_class_under_test IMPLEMENTATION.
METHOD constructor.
CREATE OBJECT me->mo_date_obj_lookup
TYPE zcl_db_date_obj_lookup.
ENDMETHOD.METHOD do_stuff.
" Uses the dependency ZIF_DATE_OBJ_LOOKUP
me->mo_date_obj_lookup->get_obj_for_date( ... ).
ENDMETHOD.ENDCLASS.
The Fake Date Object Lookup Class
The Class lcl_fake_date_obj_lookup is the fake that will replace the Dependency for the class ZCL_DB_DATE_OBJ_LOOKUP. Instead the Original that use the Database, the Fake uses a Dictionary Class that returns an Object per Date.
CLASS lcl_fake_date_obj_lookup DEFINITION FINAL.
PUBLIC SECTION.
INTERFACES:
zif_date_obj_lookup.
METHODS:
constructor
IMPORTING
io_dictionary TYPE REF TO zif_dictionary.
PRIVATE SECTION.
DATA:
mo_fake_data_dictionary TYPE REF TO zif_dictionary.
ENDCLASS.
CLASS lcl_fake_date_obj_lookup IMPLEMENTATION.
METHOD constructor.
me->mo_fake_data_dictionary = io_dictionary.
ENDMETHOD.
METHOD zif_date_obj_lookup~get_obj_for_date.
r_obj =
me->mo_fake_data_dictionary->get( |{ i_date }| ).
ENDMETHOD.
ENDCLASS.
The Fake Date Object Lookup - Builder
The Fake is build with the helper Class lcl_fake_date_lookup_bldr. This class contains logic to simplify the creation of Fakes with different Scenarios.
CLASS lcl_fake_date_obj_lookup_bldr DEFINITION FINAL.
PUBLIC SECTION.
CLASS-METHODS:
new
RETURNING value(ro_self) TYPE REF TO lcl_fake_date_obj_lookup_bldr.
METHODS:
constructor,
start_at_date
IMPORTING
i_date TYPE dats
RETURNING value(ro_self) TYPE REF TO lcl_fake_date_obj_lookup_bldr,
ends_at_date
IMPORTING
i_date TYPE dats
RETURNING value(ro_self) TYPE REF TO lcl_fake_date_obj_lookup_bldr,
returns_in_default
IMPORTING
io_object TYPE REF TO object
RETURNING value(ro_self) TYPE REF TO lcl_fake_date_obj_lookup_bldr,
returns_at_date
IMPORTING
i_date TYPE dats
io_object TYPE REF TO object
RETURNING value(ro_self) TYPE REF TO lcl_fake_date_obj_lookup_bldr,
build
RETURNING value(ro_fake_date_obj_bldr) TYPE REF TO zif_date_obj_lookup.
PRIVATE SECTION.
DATA:
m_date_start TYPE dats,
m_date_end TYPE dats,
mo_defalt_obj TYPE REF TO object,
mo_dictionary_for_fake TYPE REF TO zif_dictionary.
METHODS:
get_value_for_date
IMPORTING
i_date TYPE dats
RETURNING value(ro_obj) TYPE REF TO object,
set_value_for_date
IMPORTING
i_date TYPE dats
io_object TYPE REF TO object
RETURNING value(ro_obj) TYPE REF TO object,
is_value_for_date_set
IMPORTING
i_date TYPE dats
RETURNING value(r_is_set) TYPE abap_bool,
get_dict_with_default_ret_obj
RETURNING value(ro_fake_dictionary) TYPE REF TO zif_dictionary.
ENDCLASS.
CLASS lcl_fake_date_obj_lookup_bldr IMPLEMENTATION.
METHOD new.
CREATE OBJECT ro_self.
ENDMETHOD.
METHOD constructor.
CREATE OBJECT me->mo_dictionary_for_fake
TYPE zcl_dictionary.
ENDMETHOD.
METHOD start_at_date.
me->m_date_start = i_date.
ro_self = me.
ENDMETHOD.
METHOD ends_at_date.
me->m_date_end = i_date.
ro_self = me.
ENDMETHOD.
METHOD returns_in_default.
me->mo_defalt_obj = io_object.
ro_self = me.
ENDMETHOD.
METHOD returns_at_date.
me->set_value_for_date(
i_date = i_date
io_object = io_object
).
ro_self = me.
ENDMETHOD.
METHOD build.
ASSERT me->mo_defalt_obj IS BOUND.
ASSERT me->m_date_end > me->m_date_start.
CREATE OBJECT ro_fake_date_obj_bldr
TYPE lcl_fake_date_obj_lookup
EXPORTING
io_dictionary = me->get_dict_with_default_ret_obj( ).
ENDMETHOD.
METHOD get_value_for_date.
ro_obj =
me->mo_dictionary_for_fake->get( |{ i_date }| ).
ENDMETHOD.
METHOD set_value_for_date.
me->mo_dictionary_for_fake->add(
i_key = |{ i_date }|
i_value = io_object
).
ENDMETHOD.
METHOD is_value_for_date_set.
r_is_set =
me->mo_dictionary_for_fake->exists( |{ i_date }| ).
ENDMETHOD.
METHOD get_dict_with_default_ret_obj.
DATA: lv_current_date TYPE dats.
lv_current_date = me->m_date_start.
WHILE lv_current_date <= me->m_date_end.
IF me->is_value_for_date_set( lv_current_date ) = abap_false.
me->set_value_for_date(
i_date = me->m_date_start
io_object = me->mo_defalt_obj
).
ENDIF.
lv_current_date = lv_current_date + 1.
ENDWHILE.
ro_fake_dictionary = me->mo_dictionary_for_fake.
ENDMETHOD.
ENDCLASS.
The Test Test-Class
CLASS ltcl_example_class_under_test DEFINITION
FOR TESTING
DURATION SHORT
RISK LEVEL HARMLESS
FINAL.
PRIVATE SECTION.
METHODS:
get_test_instance
IMPORTING
io_date_obj_lookup TYPE REF TO zif_date_obj_lookup
RETURNING value(ro_fcut) TYPE REF TO zcl_example_class_under_test,
test_3_days_with_one_problem FOR TESTING,
test_10_days_with_2_problems FOR TESTING."... more complex tests
ENDCLASS.
I decided to inject the Dependency by overwriting the Member Attribute after Construction. This way allows you to replace also local dependencies e.g. if you use a local Interface LIF_DB to extract SQL Commands to a local Class. And it is easy to create an Instance of the Class. To get access to the private Member Attribute I made the Test-Class to a Friend of the Class under Test.
But this has the Disadvantage that the Test knows internals of the Class Implementation and that the Constructor cannot contain Logic that uses the to replaced Dependency. A Clean way is to add the Dependency to the Constructor Parameters.
CLASS zcl_example_class_under_test DEFINITION LOCAL FRIENDS
ltcl_example_class_under_test.
The Test Implementation is reduced into the simple creation of the Instance - and the Assertions. Every Assert Statement contains the Builder Class - which tells the reader in simple readable Text the preconditions. I always try to reduce local Variables to a minimum to keep Methods short & clean.
CLASS ltcl_example_class_under_test IMPLEMENTATION.
METHOD get_test_instance.
CREATE OBJECT ro_fcut.
ro_fcut->mo_date_obj_lookup = io_date_obj_lookup.
ENDMETHOD.
METHOD test_3_days_with_one_problem.
cl_aunit_assert=>assert_equals(
exp= 3
act = me->get_test_instance(
lcl_fake_date_obj_lookup_bldr=>new(
)->start_at_date(
'20140101'
)->ends_at_date(
'20140103'
)->returns_in_default(
zcl_dummy2=>new( )
)->returns_at_date(
i_date = '20140102'
io_object = zcl_dummy1=>new( )
)->build( )
)->do_stuff( )
).
ENDMETHOD.
METHOD test_10_days_with_2_problems.
cl_aunit_assert=>assert_equals(
exp= 5
act = me->get_test_instance(
lcl_fake_date_obj_lookup_bldr=>new(
)->start_at_date(
'20140201'
)->ends_at_date(
'20140210'
)->returns_in_default(
zcl_dummy2=>new( )
)->returns_at_date(
i_date = '20140202'
io_object = zcl_dummy1=>new( )
)->returns_at_date(
i_date = '20140209'
io_object = zcl_dummy1=>new( )
)->build( )
)->do_stuff( )
).
ENDMETHOD.
ENDCLASS.
Conclusion
The Builder Pattern is another Tool in my Toolkit for Test-Driven Development. Complex Dependencies are often a signal that there's something wrong with your Class Design, but you often have to test Classes that must be tested with varying values - and a builder can help you with that.
The Builder Class in the Example above contains quite a bit of logic - and untested logic as dependency for your class under Test may result in brittle Test's. So you may have to test that logic as precondition before the actual Test's. If you use a complex global Builder Class, it would contain own Test's in it's local Test-Class Include.