CDS Test Doubles in ABAP
A CDS view entity that projects a join, computes a CASE expression, and groups by a key has logic in it. That logic deserves the same kind of test coverage as ABAP code. The complication is that the logic is expressed against real database tables, and the moment you test it the way most ABAP code is tested, you are reading whatever happens to live in SCARR and SFLIGHT on the current system. Add an authorization role, a customizing tweak, or a fresh data generator run, and the test result moves with it.
SAP ships CL_CDS_TEST_ENVIRONMENT for this. It is the sibling of CL_OSQL_TEST_ENVIRONMENT, but pointed at CDS entities instead of ABAP SQL. It builds doubles for the CDS entity's dependencies, accepts test data for those doubles, and then lets you SELECT FROM the entity itself and assert on what comes back. The logic inside the view runs for real. Only the data sources change.
If you have not seen the OSQL framework yet, the Open SQL Test Doubles post covers the lifecycle, the dependency-list mental model, and the failure mode where forgotten tables silently fall through to the real database. Most of that applies here. This post focuses on what is different when the artifact under test is a CDS entity.
What you actually test
Two distinct testing scenarios are easy to confuse.
The first is testing ABAP code that consumes a CDS entity. The production code does SELECT FROM z_carrier_region. You want the SELECT to return controlled rows so the surrounding logic can be exercised. For that, the OSQL framework is the right tool. Register the CDS entity in i_dependency_list and seed the entity's double with the rows the production code should see.
The second is testing the CDS entity itself. The view does a join, a CASE, an aggregation, an association resolution. You want to verify that the view computes what its model says it computes, given controlled inputs in the underlying tables. This is what CL_CDS_TEST_ENVIRONMENT is for. The framework doubles the entity's dependencies, lets the actual CDS engine evaluate the entity, and you assert on the result.
The rest of this post is about the second case.
A small CDS view entity
A view that maps a carrier's currency to a region using a CASE expression:
@AccessControl.authorizationCheck: #NOT_REQUIRED
@EndUserText.label: 'Carrier with region from currency'
@Metadata.ignorePropagatedAnnotations: true
define view entity z_carrier_region
as select from scarr as c
{
key c.carrid,
c.carrname,
c.currcode,
case c.currcode
when 'EUR' then 'EU'
when 'GBP' then 'EU'
when 'USD' then 'AMERICAS'
when 'CAD' then 'AMERICAS'
when 'JPY' then 'ASIA'
else 'OTHER'
end as region
}
There is exactly one piece of logic worth testing inside the view itself: the CASE covers every branch defined in the model, including the ELSE. Projection and ordering are decided by the consumer, so they belong to the test of whatever code reads from the view.
A trivial production class that reads from the entity:
CLASS zcl_carrier_region_reader DEFINITION
PUBLIC FINAL CREATE PUBLIC.
PUBLIC SECTION.
TYPES:
BEGIN OF ts_carrier,
carrid TYPE scarr-carrid,
carrname TYPE scarr-carrname,
currcode TYPE scarr-currcode,
region TYPE c LENGTH 8,
END OF ts_carrier,
tt_carriers TYPE STANDARD TABLE OF ts_carrier WITH EMPTY KEY.
METHODS list_regions
RETURNING VALUE(rt_result) TYPE tt_carriers.
ENDCLASS.
CLASS zcl_carrier_region_reader IMPLEMENTATION.
METHOD list_regions.
SELECT carrid, carrname, currcode, region
FROM z_carrier_region
ORDER BY carrid
INTO TABLE @rt_result.
ENDMETHOD.
ENDCLASS.
The class adds nothing but a SELECT and an ORDER BY. The point of the tests below is the view, not the class.
The test class
The "! @testing annotation links the local test class to the CDS entity. ADT uses it to offer the test class from the CDS object's context, and the relation also helps when running related tests for a CDS change.
"! @testing z_carrier_region
CLASS ltcl_region DEFINITION
FOR TESTING DURATION SHORT RISK LEVEL HARMLESS.
PRIVATE SECTION.
CLASS-DATA test_environment TYPE REF TO if_cds_test_environment.
DATA region_reader TYPE REF TO zcl_carrier_region_reader.
CLASS-METHODS class_setup.
CLASS-METHODS class_teardown.
METHODS setup.
METHODS:
empty_when_seed_empty FOR TESTING,
eur_maps_to_eu FOR TESTING,
gbp_maps_to_eu FOR TESTING,
usd_maps_to_americas FOR TESTING,
cad_maps_to_americas FOR TESTING,
jpy_maps_to_asia FOR TESTING,
unknown_maps_to_other FOR TESTING,
ordering_is_by_carrid FOR TESTING,
direct_select_hits_real_db FOR TESTING,
direct_select_hits_double FOR TESTING.
ENDCLASS.
CLASS ltcl_region IMPLEMENTATION.
METHOD class_setup.
test_environment = cl_cds_test_environment=>create(
i_for_entity = 'Z_CARRIER_REGION' ).
ENDMETHOD.
METHOD class_teardown.
test_environment->destroy( ).
ENDMETHOD.
METHOD setup.
region_reader = NEW #( ).
test_environment->clear_doubles( ).
test_environment->disable_double_redirection( ).
ENDMETHOD.
METHOD eur_maps_to_eu.
DATA seed TYPE STANDARD TABLE OF scarr.
seed = VALUE #(
( mandt = sy-mandt carrid = 'LH' carrname = 'Lufthansa' currcode = 'EUR' url = '' ) ).
test_environment->insert_test_data( seed ).
DATA(rows) = region_reader->list_regions( ).
cl_abap_unit_assert=>assert_equals( exp = 1 act = lines( rows ) ).
cl_abap_unit_assert=>assert_equals( exp = 'EU' act = rows[ 1 ]-region ).
ENDMETHOD.
METHOD gbp_maps_to_eu.
DATA seed TYPE STANDARD TABLE OF scarr.
seed = VALUE #(
( mandt = sy-mandt carrid = 'BA' carrname = 'British Airways' currcode = 'GBP' url = '' ) ).
test_environment->insert_test_data( seed ).
DATA(rows) = region_reader->list_regions( ).
cl_abap_unit_assert=>assert_equals( exp = 'EU' act = rows[ 1 ]-region ).
ENDMETHOD.
METHOD usd_maps_to_americas.
DATA seed TYPE STANDARD TABLE OF scarr.
seed = VALUE #(
( mandt = sy-mandt carrid = 'AA' carrname = 'American Airlines' currcode = 'USD' url = '' ) ).
test_environment->insert_test_data( seed ).
DATA(rows) = region_reader->list_regions( ).
cl_abap_unit_assert=>assert_equals( exp = 'AMERICAS' act = rows[ 1 ]-region ).
ENDMETHOD.
METHOD cad_maps_to_americas.
DATA seed TYPE STANDARD TABLE OF scarr.
seed = VALUE #(
( mandt = sy-mandt carrid = 'AC' carrname = 'Air Canada' currcode = 'CAD' url = '' ) ).
test_environment->insert_test_data( seed ).
DATA(rows) = region_reader->list_regions( ).
cl_abap_unit_assert=>assert_equals( exp = 'AMERICAS' act = rows[ 1 ]-region ).
ENDMETHOD.
METHOD jpy_maps_to_asia.
DATA seed TYPE STANDARD TABLE OF scarr.
seed = VALUE #(
( mandt = sy-mandt carrid = 'JL' carrname = 'Japan Airlines' currcode = 'JPY' url = '' ) ).
test_environment->insert_test_data( seed ).
DATA(rows) = region_reader->list_regions( ).
cl_abap_unit_assert=>assert_equals( exp = 'ASIA' act = rows[ 1 ]-region ).
ENDMETHOD.
METHOD unknown_maps_to_other.
DATA seed TYPE STANDARD TABLE OF scarr.
seed = VALUE #(
( mandt = sy-mandt carrid = 'XX' carrname = 'Mystery Air' currcode = 'CHF' url = '' ) ).
test_environment->insert_test_data( seed ).
DATA(rows) = region_reader->list_regions( ).
cl_abap_unit_assert=>assert_equals( exp = 'OTHER' act = rows[ 1 ]-region ).
ENDMETHOD.
METHOD empty_when_seed_empty.
cl_abap_unit_assert=>assert_initial( region_reader->list_regions( ) ).
ENDMETHOD.
METHOD ordering_is_by_carrid.
DATA seed TYPE STANDARD TABLE OF scarr.
seed = VALUE #(
( mandt = sy-mandt carrid = 'LH' carrname = 'Lufthansa' currcode = 'EUR' url = '' )
( mandt = sy-mandt carrid = 'AA' carrname = 'American Airlines' currcode = 'USD' url = '' )
( mandt = sy-mandt carrid = 'BA' carrname = 'British Airways' currcode = 'GBP' url = '' ) ).
test_environment->insert_test_data( seed ).
DATA(rows) = region_reader->list_regions( ).
cl_abap_unit_assert=>assert_equals( exp = 3 act = lines( rows ) ).
cl_abap_unit_assert=>assert_equals( exp = 'AA' act = rows[ 1 ]-carrid ).
cl_abap_unit_assert=>assert_equals( exp = 'BA' act = rows[ 2 ]-carrid ).
cl_abap_unit_assert=>assert_equals( exp = 'LH' act = rows[ 3 ]-carrid ).
ENDMETHOD.
METHOD direct_select_hits_real_db.
DATA seed TYPE STANDARD TABLE OF scarr.
DATA actual_name TYPE scarr-carrname.
seed = VALUE #(
( mandt = sy-mandt carrid = 'ZZ' carrname = 'Doubled Z' currcode = 'EUR' url = '' ) ).
test_environment->insert_test_data( seed ).
SELECT SINGLE carrname FROM scarr
WHERE carrid = 'ZZ'
INTO @actual_name.
cl_abap_unit_assert=>assert_differs( exp = 'Doubled Z' act = actual_name ).
ENDMETHOD.
METHOD direct_select_hits_double.
DATA seed TYPE STANDARD TABLE OF scarr.
DATA actual_name TYPE scarr-carrname.
seed = VALUE #(
( mandt = sy-mandt carrid = 'ZZ' carrname = 'Doubled Z' currcode = 'EUR' url = '' ) ).
test_environment->insert_test_data( seed ).
test_environment->enable_double_redirection( ).
SELECT SINGLE carrname FROM scarr
WHERE carrid = 'ZZ'
INTO @actual_name.
cl_abap_unit_assert=>assert_equals( exp = 'Doubled Z' act = actual_name ).
ENDMETHOD.
ENDCLASS.
The production class is unchanged from how it would look in shipped code. Nothing in ZCL_CARRIER_REGION_READER knows about the double, and the CDS view itself does not need a test annotation either. The redirection is configured entirely by the test fixture.
The seeded SCARR rows are fed into the framework's double, the CDS engine evaluates the CASE against those rows, and the production SELECT reads the result.
What create( ) does for you
The OSQL framework requires every dependency to be listed by hand. CL_CDS_TEST_ENVIRONMENT does not. Calling
cl_cds_test_environment=>create( i_for_entity = 'Z_CARRIER_REGION' )
is enough for this view. The framework parses the CDS hierarchy under the entity, finds its first-level data sources, and creates doubles for them. In the example above, the only data source is SCARR, so a SCARR double appears automatically, and insert_test_data( ) accepts rows of type scarr without any further configuration.
The class documentation is explicit about this: i_dependency_list is for hierarchy testing, not for unit tests. For a CDS unit test, omit the parameter and let the framework derive the leaves.
That changes the failure mode compared to OSQL. With OSQL, a forgotten dependency silently passes through to the real database. In a CDS unit test, the framework derives the first-level data sources of the entity under test, so the forgetful-developer mode is harder to trigger for those. Two boundaries still belong to the test author:
- Modeled associations are not part of the runtime data sources. To include them, set
test_associations = 'X'oncreate( ). Otherwise the doubles only cover what the entity reads from at runtime. - When you opt into hierarchy testing with
i_select_base_dependenciesor supply an expliciti_dependency_list, you take responsibility for naming every leaf the entity needs. The framework will throw an exception if the supplied list does not cover one node in every dependency path.
Lifecycle
The pattern is the same as for OSQL: create( ) in class_setup, clear_doubles( ) in setup, insert_test_data( ) either in setup for shared rows or in the test method for scenario-specific rows, and destroy( ) in class_teardown. The IF_CDS_TEST_ENVIRONMENT interface exposes those four methods as the workhorse, plus get_double( ), get_access_control_double( ), enable_double_redirection( ) and disable_double_redirection( ), insert_from_tdc( ), and set_session_variables( ).
set_session_variables( ) feeds CDS session variables (USER, CLIENT, LANGUAGE, DATE, USER_DATE, USER_TIMEZONE) into the test scope. If your CDS view branches on $session.user or filters by $session.system_date, you control those values from the test instead of inheriting them from whoever happens to run it.
Redirection works the other way around
There is one behavioral difference from OSQL that is worth understanding before it bites.
In the OSQL framework, registering a dependency redirects every SELECT against that dependency to the double. By default. If you want the real table back, you call disable_double_redirection( ).
In the CDS framework, the entity under test always reads from the doubles. That is the whole point. But if your test code does an extra SELECT against one of the dependent tables directly, that select hits the real database, not the double. The framework's interface documentation states this plainly: "By default this is disabled in CDS TDF, which means select on any test doubles created as part of CDS tests will return the actual data, not the test data that is inserted."1 Call enable_double_redirection( ) to flip it.
The two final test methods above show this concretely. direct_select_hits_real_db seeds a row with carrid = 'ZZ' into the double and then issues a direct SELECT FROM scarr WHERE carrid = 'ZZ'. The seeded 'Doubled Z' value does not come back, because direct redirection is off by default and the doubled row never reaches the table. direct_select_hits_double does the same thing with enable_double_redirection( ) first, and the seeded value does come back.
setup calls disable_double_redirection( ) after clear_doubles( ) so that each test starts in the framework's default state. Without that, a method that flips redirection on can leak the new state into whatever test runs next.
In practice this means: when testing a CDS view, write the assertion against the view, not against the underlying tables. The view sees the doubles. A direct SELECT FROM scarr from the same test method does not.
What the example covers
The reason this approach is more useful than mocking the view's result is that the CDS engine still does the work. Six tests focus on the view's logic and exercise every branch of the CASE:
currcode = 'EUR'andcurrcode = 'GBP'both produceregion = 'EU'. A typo in the secondWHENwould surface here, not by reading the source.currcode = 'USD'andcurrcode = 'CAD'both produce'AMERICAS'.currcode = 'JPY'produces'ASIA'.currcode = 'CHF'(not in anyWHENbranch) falls to theELSEand produces'OTHER'.
Two more tests cover lifecycle and consumer behavior rather than the view itself. empty_when_seed_empty confirms clear_doubles( ) empties the double between methods; ordering_is_by_carrid confirms the ORDER BY in the reader returns rows in the expected order. The ORDER BY is in ZCL_CARRIER_REGION_READER, not in the CDS view, so this test is about the consumer, not about the entity.
A repository class returning a hand-rolled tt_carriers for the same scenarios would cover none of these. The fake would just return the values the test author already typed. The CDS test environment runs the real view against controlled inputs, so a broken WHEN branch fails the test.
This example only uses a projection plus a CASE. Joins, aggregations, calculated fields with arithmetic, and modeled associations work the same way: the framework doubles whatever the entity reads from, the engine evaluates the model, and the result reflects the model's logic on the seeded data. For modeled associations specifically, cl_cds_test_environment=>create( ... test_associations = 'X' ) is needed so that doubles are built for entities reachable only through associations, not just through runtime data sources.
DCL is off by default
CDS access control (DCL) is disabled by default for the entity under test. The class documentation calls the obsolete disable_dcl parameter "ignored" because the framework now does it unconditionally:2 tests run without authorization filtering so the data shape itself can be verified.
If access control is the thing under test, do not skip it. get_access_control_double( ) returns an IF_CDS_ACCESS_CONTROL_DOUBLE you can configure with test roles. For dependent entities reached through hierarchy testing, DCL stays off so the assertions are not muddied by access checks at every level.
This is the correct default for unit testing. It is also a fact worth remembering before claiming a test "covers the security model." It does not, unless you wired up access control explicitly.
Limits
CDS test doubles are not a full integration test, in the same way that OSQL doubles are not. The test verifies that the CDS logic, given inputs you control, produces outputs you assert on. It does not verify that the production database has the customizing the view depends on at runtime. It does not verify that a long association chain returns the same rows under real authorization. It does not verify that a database-specific execution plan stays acceptable under load.
A few practical limits on top of that:
i_dependency_listis for hierarchy testing. The framework throws an exception if you seti_select_base_dependencies = abap_falseand the supplied list does not cover one node in every dependency path. For unit tests, omit the parameter.create_for_multiple_cds( )exists for testing several entities in one fixture. It is useful when entities share dependencies that would otherwise be doubled twice. It is also where dependency conflicts surface as runtime exceptions, which is the framework saying you asked for a graph it cannot reconcile.- The supported types for explicit dependencies are
TABLE,VIEW,CDS_VIEW,CDS VIEW ENTITY,CDS PROJECTION VIEW,CDS_TABLE_FUNCTION,EXTERNAL_VIEW. Anything else has to be tested through a different layer. - Special functions
CURRENCY_CONVERSIONandUNIT_CONVERSIONare not supported bycreate_for_multiple_cds( ). The single-entitycreate( )lets you opt out viadisable_function_double = abap_truefor unit testing. - Test data design is the bottleneck before the framework is. A view that joins six tables needs seed rows in six tables for each scenario. Helper methods named for the scenario keep the tests readable.
- Release availability:
CL_CDS_TEST_ENVIRONMENTshipped with SAP NetWeaver 7.51, one release before the OSQL counterpart in 7.52.3 Older systems do not have it.
OSQL doubles or CDS doubles
The choice is mostly automatic, once you know what you are testing.
- The unit under test is an ABAP method that does
SELECT FROM <some_cds>. UseCL_OSQL_TEST_ENVIRONMENTand register the CDS entity. The view's logic is treated as a black box; you control its result by seeding its double. - The unit under test is the CDS entity itself. Use
CL_CDS_TEST_ENVIRONMENT. The framework doubles the entity's dependencies, the view's logic runs, and you assert on the actual output.
Mixing the two in one test class is rarely useful and usually a sign that the test is trying to cover two units at once.
A short checklist
For a CDS unit test:
- Pick the CDS entity that is the unit under test. Pass it to
create( i_for_entity = '<NAME>' ). Do not passi_dependency_listfor unit testing. - Use
class_setupforcreate( ),setupforclear_doubles( ),class_teardownfordestroy( ). Same shape as OSQL. - Seed the underlying tables of the entity with the smallest data set that proves the scenario.
- Assert on the result of selecting from the CDS entity, not on the seeded tables.
- For modeled associations, set
test_associations = 'X'oncreate( ). - For session-variable-driven logic, call
set_session_variables( )from the test. - For DCL coverage, configure
get_access_control_double( )explicitly. The default is "no access control."
Further reading
- Open SQL Test Doubles in ABAP, the companion post on
CL_OSQL_TEST_ENVIRONMENT - SAP Help: Creating a Test Class for CDS
- SAP samples: ABAP Test Isolation Examples
- SAP samples: ABAP Cheat Sheets — ABAP Unit Tests
- Software-Heroes: ABAP Unit — TDF CDS Double
- SAPCodes: CDS View Unit Test with Test Doubles
Sources
Footnotes
-
ABAP source of
IF_CDS_TEST_ENVIRONMENT~enable_double_redirection, ABAP doc comment quoted in SAP Help: Creating a Test Class for CDS ↩ -
ABAP source of
CL_CDS_TEST_ENVIRONMENT~create, parameterdisable_dcl, marked as "Obsolete and ignored" ↩ -
SAP samples, ABAP Test Isolation Examples, table of test isolation tools and first release ↩