Open SQL Test Doubles in ABAP
The moment a SELECT runs inside an ABAP Unit test, the test borrows whatever rows happen to live on the current system. Different client, different test result. Someone deletes a customizing entry, the test goes red. The data generator hasn't run on a fresh box, the test goes red. Nothing about the production code changed.
ABAP developers tend to live with this. The usual responses are bad in different ways: trust the system data, build a test fixture and clean it up by hand, or wrap every SELECT in a tiny data access object (DAO) so the unit test can mock the wrapper. SAP added CL_OSQL_TEST_ENVIRONMENT to NetWeaver 7.52 as a fourth option, and it has aged well. The production code keeps its real Open SQL. The framework swaps the underlying table for a double during the test.
What the framework actually does
The framework is exposed through a single interface, IF_OSQL_TEST_ENVIRONMENT, and a single static factory:
DATA(env) = cl_osql_test_environment=>create(
i_dependency_list = VALUE #( ( 'SCARR' ) ) ).
i_dependency_list is a list of database artifacts you want under control: transparent tables, classic views, CDS views, CDS view entities, CDS table entities, CDS projection views, CDS table functions, and external views. From the moment create( ) returns, every Open SQL statement the test executes against SCARR reads from a managed double instead of the real table. The double starts empty. You fill it with insert_test_data( ), empty it again with clear_doubles( ), and tear the whole thing down with destroy( ).
That last sentence is the entire mental model. Everything else is plumbing.
A working example
Here is the production class. It does plain Open SQL against SCARR and SFLIGHT. There is no interface, no seam, no test mode. Nothing about it would suggest the class is built for testability.
CLASS zcl_carrier_reader DEFINITION
PUBLIC FINAL CREATE PUBLIC.
PUBLIC SECTION.
TYPES:
BEGIN OF ts_carrier,
carrid TYPE scarr-carrid,
carrname TYPE scarr-carrname,
END OF ts_carrier,
tt_carriers TYPE STANDARD TABLE OF ts_carrier WITH EMPTY KEY.
METHODS list_carriers
RETURNING VALUE(rt_result) TYPE tt_carriers.
METHODS find_by_id
IMPORTING iv_carrid TYPE scarr-carrid
RETURNING VALUE(rv_name) TYPE scarr-carrname.
METHODS count_flights
RETURNING VALUE(rv_count) TYPE i.
ENDCLASS.
CLASS zcl_carrier_reader IMPLEMENTATION.
METHOD list_carriers.
SELECT carrid, carrname FROM scarr
ORDER BY carrid
INTO TABLE @rt_result.
ENDMETHOD.
METHOD find_by_id.
SELECT SINGLE carrname FROM scarr
WHERE carrid = @iv_carrid
INTO @rv_name.
ENDMETHOD.
METHOD count_flights.
SELECT COUNT(*) FROM sflight
INTO @rv_count.
ENDMETHOD.
ENDCLASS.
The local test class registers SCARR as a dependency and seeds two rows in setup. SFLIGHT is deliberately left out of the dependency list to show what happens to tables the framework does not know about.
CLASS ltcl_carriers DEFINITION
FOR TESTING DURATION SHORT RISK LEVEL HARMLESS.
PRIVATE SECTION.
CLASS-DATA test_environment TYPE REF TO if_osql_test_environment.
DATA carrier_reader TYPE REF TO zcl_carrier_reader.
CLASS-METHODS class_setup.
CLASS-METHODS class_teardown.
METHODS setup.
METHODS:
list_returns_doubles FOR TESTING,
find_returns_match FOR TESTING,
empty_after_clear FOR TESTING,
unmocked_table_passes FOR TESTING,
insert_unregistered_raises FOR TESTING,
writes_go_to_double FOR TESTING.
ENDCLASS.
CLASS ltcl_carriers IMPLEMENTATION.
METHOD class_setup.
test_environment = cl_osql_test_environment=>create(
i_dependency_list = VALUE #( ( 'SCARR' ) ) ).
ENDMETHOD.
METHOD class_teardown.
test_environment->destroy( ).
ENDMETHOD.
METHOD setup.
DATA seed TYPE STANDARD TABLE OF scarr.
carrier_reader = NEW #( ).
test_environment->clear_doubles( ).
seed = VALUE #(
( mandt = sy-mandt carrid = 'AA' carrname = 'American Airlines' currcode = 'USD' url = '' )
( mandt = sy-mandt carrid = 'LH' carrname = 'Lufthansa' currcode = 'EUR' url = '' )
).
test_environment->insert_test_data( seed ).
ENDMETHOD.
METHOD list_returns_doubles.
DATA(actual) = carrier_reader->list_carriers( ).
cl_abap_unit_assert=>assert_equals( exp = 2 act = lines( actual ) ).
cl_abap_unit_assert=>assert_equals( exp = 'AA' act = actual[ 1 ]-carrid ).
cl_abap_unit_assert=>assert_equals( exp = 'LH' act = actual[ 2 ]-carrid ).
ENDMETHOD.
METHOD find_returns_match.
cl_abap_unit_assert=>assert_equals(
exp = 'Lufthansa'
act = carrier_reader->find_by_id( 'LH' ) ).
ENDMETHOD.
METHOD empty_after_clear.
test_environment->clear_doubles( ).
cl_abap_unit_assert=>assert_initial( carrier_reader->list_carriers( ) ).
ENDMETHOD.
METHOD unmocked_table_passes.
DATA(count) = carrier_reader->count_flights( ).
cl_abap_unit_assert=>assert_true( xsdbool( count >= 0 ) ).
ENDMETHOD.
METHOD insert_unregistered_raises.
DATA flights TYPE STANDARD TABLE OF sflight.
DATA raised TYPE abap_bool.
flights = VALUE #(
( mandt = sy-mandt carrid = 'AA' connid = '0017' fldate = '20260101' ) ).
TRY.
test_environment->insert_test_data( flights ).
CATCH cx_root.
raised = abap_true.
ENDTRY.
cl_abap_unit_assert=>assert_equals( exp = abap_true act = raised ).
ENDMETHOD.
METHOD writes_go_to_double.
DATA carrier TYPE scarr.
DATA actual_name TYPE scarr-carrname.
DATA updated_name TYPE scarr-carrname VALUE 'Updated Name'.
test_environment->clear_doubles( ).
carrier = VALUE #(
mandt = sy-mandt carrid = 'ZZ' carrname = 'Inserted Name' currcode = 'USD' url = '' ).
INSERT scarr FROM @carrier.
UPDATE scarr SET carrname = @updated_name WHERE carrid = @carrier-carrid.
SELECT SINGLE carrname FROM scarr WHERE carrid = @carrier-carrid INTO @actual_name.
cl_abap_unit_assert=>assert_equals( exp = updated_name act = actual_name ).
carrier-carrname = 'Modified Name'.
MODIFY scarr FROM @carrier.
SELECT SINGLE carrname FROM scarr WHERE carrid = @carrier-carrid INTO @actual_name.
cl_abap_unit_assert=>assert_equals( exp = carrier-carrname act = actual_name ).
DELETE FROM scarr WHERE carrid = @carrier-carrid.
cl_abap_unit_assert=>assert_initial( carrier_reader->list_carriers( ) ).
ENDMETHOD.
ENDCLASS.
The SELECT statements inside the production class are unchanged from how they would look in shipped code. Nothing in ZCL_CARRIER_READER knows about the double; the redirection is configured entirely by the test fixture.
The lifecycle in one paragraph
create( ) lives in class_setup, because building the doubles is expensive and the dependency list rarely changes between test methods. clear_doubles( ) lives in setup, so each test method starts from a known empty state. insert_test_data( ) lives wherever the test's "given" section makes sense, usually in setup for shared rows and inside the test method for scenario-specific rows. destroy( ) lives in class_teardown and removes the doubles. Skip destroy( ) and the doubles linger in the database until garbage collection cleans them up. The framework provides cl_osql_test_environment=>garbage_collection( ) for that, but the cleaner habit is to call destroy( ) and not depend on cleanup.
A subtle point worth knowing: clear_doubles( ) empties the double, it does not switch back to the real table. The test method empty_after_clear above proves this. After clear_doubles( ) and no insert_test_data( ), list_carriers( ) returns zero rows even though the live SCARR table has plenty. The framework remains in the path. To temporarily route a SELECT to the real database, IF_OSQL_TEST_ENVIRONMENT exposes disable_double_redirection( ) and enable_double_redirection( ). Use sparingly. A test that toggles redirection mid-flight is usually trying to be two tests at once.
Writes are redirected too
The redirection is not read-only. If the registered dependency is a database table, SAP documents both ABAP SQL reads and writes as redirected to the double table.1 In practical terms, INSERT, UPDATE, MODIFY, and DELETE against SCARR affect the test double while redirection is enabled. The writes_go_to_double test above clears the double, inserts ZZ, updates it, modifies it, deletes it again, and then reads through the production class to verify the double is empty.
That does not make unit tests a good place for broad persistence workflows. It just means database-writing production code can still be tested without touching the real table, as long as every written table is in i_dependency_list.
Tables you forgot to register
The unmocked_table_passes method is the interesting one. count_flights( ) reads SFLIGHT. The test class registered only SCARR. The SELECT COUNT(*) runs against the real SFLIGHT and returns whatever number the system happens to hold. The framework does not warn, does not fail, does not log. The query is simply not redirected.
This is the failure mode worth internalizing. A test that uses CL_OSQL_TEST_ENVIRONMENT looks isolated even when it is not. If the production code reads five tables and the dependency list mentions four, one statement still touches the live database, and the test can flip green or red based on data the test author never controlled. The framework does exactly what you ask of it. Anything you forget to ask for stays real.
The corollary: insert_test_data( ) only accepts data for tables that were passed to create( ). Trying to seed SFLIGHT while SFLIGHT is not in the dependency list raises an exception. The framework has nowhere to put the rows.
Why this beats wrapping every SELECT
The architectural reflex when faced with untestable database code is to extract a repository or DAO: ZCL_CARRIER_REPOSITORY with read_by_id( ) and read_all( ), an interface to inject a fake, and a tidy unit test that asserts on the fake's stub data. That is the right answer when persistence is a real boundary in the domain. It is the wrong answer when the only motivation is testability.
A repository class with one method per SELECT adds indirection without adding isolation. The fake's stubbed return value never executes the original Open SQL. A wrong join, a mistyped WHERE clause, a missing client handling, a typed-field mismatch: none of it is exercised by the test. The fake just returns whatever the test author already believed.
CL_OSQL_TEST_ENVIRONMENT keeps the actual SELECT in the path. The query is parsed, joins are executed, the WHERE clause filters, and the result is mapped back into the receiving variable. The only thing you replaced is the row source. A broken query still fails the test.
That is a stronger guarantee than a mocked DAO can give, with less code in the production class.
Compared to TEST-SEAM
TEST-SEAM and TEST-INJECTION solve a different problem. They cut a hole in the production code that a test can fill with a different statement block.
TEST-SEAM read_carrier.
SELECT SINGLE carrname FROM scarr
INTO @rv_name
WHERE carrid = @iv_carrid.
END-TEST-SEAM.
TEST-INJECTION read_carrier.
rv_name = 'Lufthansa'.
END-TEST-INJECTION.
The seam version is useful for legacy code that cannot be refactored safely, for authority checks, for spots where Open SQL is not the right abstraction. It is also blunter than what the OSQL framework offers: the injected block replaces the production block entirely, so the original SELECT is no longer compiled into the executed path. The test can pass while the real SELECT is broken in ways nobody notices until production.
The Clean ABAP guide treats test seams as a temporary workaround, not a default design technique.2 SAP's keyword documentation frames them as a way to replace specific statements that block unit tests, including persistent reads and writes.3 The OSQL framework is the better tool whenever the dependency is ABAP SQL itself.
What the framework does not solve
Open SQL test doubles are not a license to skip integration tests.
The framework controls one layer: the data sources used by ABAP SQL inside ABAP Unit. It does not validate that real customizing exists, that a database-specific execution plan is acceptable, that locks behave correctly under load, that update task behavior matches expectations, or that a CDS view with a non-trivial join chain returns what you think it does end-to-end. A doubled CDS view returns whatever you seeded into the double, regardless of how the underlying entities would actually behave together.
CDS access control is a separate caveat. SAP's documentation says DCL access-control logic is disabled for dependent CDS objects during these tests.1 If DCL behavior matters, test it explicitly with the framework's access-control double or with a separate integration test that runs against the real authorization setup.
Test data design also stays a real problem. A doubled table is empty, and large VALUE #( ... ) blocks turn ugly fast. Build helper methods named for the scenario (given_two_active_carriers, given_a_carrier_with_no_flights) and keep each test's seed minimal. If the setup is bigger than the behavior under test, the test design is wrong, not the framework.
Some practical limits worth knowing:
- The framework arrived in NetWeaver 7.52. Older systems do not have it.
- Database views and database functions are read-only, but the framework still allows seeding their doubles. This works for the test, but it does not mirror how the real artifact would compute its result.
- For wide tables or deep CDS hierarchies, fixture data becomes the bottleneck before the framework does. Test Data Containers (
insert_from_tdc( )) exist for this and trade inline visibility for less noise.4
Unit, integration, and accidental integration
Two categories of tests are worth keeping on purpose:
- A unit test that controls its dependencies and asserts behavior in a small scope.
- An integration test that intentionally uses real dependencies to verify wiring, customizing, authorizations, or end-to-end behavior.
The third category is the accidental one: a test that uses real dependencies because nobody isolated them, but still runs in the unit test bucket. It is neither a useful unit test nor an honest integration test. It has the cost of both and the guarantees of neither.
Most of the pain attributed to "ABAP Unit is unreliable" comes from that accidental category. CL_OSQL_TEST_ENVIRONMENT exists to move database-heavy tests out of it. Real integration tests still need to exist, named and bucketed accordingly, run on schedules where flakiness is tolerable. The goal is not to pretend integration risk disappeared. The goal is to stop smuggling integration risk into unit tests by accident.
A short checklist
When writing a database-isolated ABAP Unit test:
- List every Open SQL data source the production code reaches, and put each one in
i_dependency_list. Missing entries silently pass through to the real database. - Create the environment in
class_setup, destroy it inclass_teardown. - Call
clear_doubles( )at the start ofsetupso each test starts clean. - Insert only the rows the current test needs. Use named helper methods for fixtures shared across tests.
- Let production Open SQL run unchanged. The point of the framework is that the
SELECTis real. - Assert the production code's observable behavior, not the framework's internals.
- Keep an integration test bucket for the things
CL_OSQL_TEST_ENVIRONMENTcannot prove: real customizing, real authorizations, real database execution, real RAP behavior.
The framework is small and well-scoped. The hardest part is remembering which tables the code actually touches, which is the same skill that makes any test of database-heavy code work in the first place.
Further reading
- SAP Help: ABAP SQL Test Double Framework
- SAP samples: ABAP Test Isolation Examples
- SAP samples: ABAP Cheat Sheets — ABAP Unit Tests
- SAP Help: TEST-SEAM and TEST-INJECTION
- SAP Clean ABAP Style Guide
- Software-Heroes: ABAP Unit — TDF SQL Double
- Tricktresor: oSQL Test Environment
Sources
Footnotes
-
SAP Help, ABAP SQL Test Double Framework ↩ ↩2
-
SAP Clean ABAP Style Guide, Use Test Seams as Temporary Workaround ↩
-
SAP Help, Test Seams ↩
-
abapedia, IF_OSQL_TEST_ENVIRONMENT ↩