Contents
- Why Aren't Tests Written in ABAP?
- Your First Test: Anatomy of a Test Class
- Choosing the Right Assertion
- The Real Problem Is Design, Not Testing
- Pulling Dependencies Out: Interfaces and Injection
- The ABAP Test Double Framework
- Testing Code That Touches the Database
- Existing Legacy Code: TEST-SEAM and Its Limits
- Running Tests, Coverage and ATC
- Rules That Hold Up in Real Projects
- Conclusion
ABAP Unit has been part of SAP systems for many years. Even so, plenty of enterprise ABAP repositories do not contain a single test class. The reason is usually not reluctance: because the existing code was never written to be testable, the developer who sits down to write the first test hits a wall and gives up.
In this article I first cover the mechanics of ABAP Unit, then the part that actually matters — the design decisions that make code testable — with real code examples. If you have read the Clean ABAP style guide, treat this as its practical sequel.
Why Aren't Tests Written in ABAP?
The objections I hear in the field almost always fall into the same four categories:
- "Our code depends on the database, I can't test it." Correct diagnosis, wrong conclusion. The problem is not that testing is impossible, but that the business logic is buried in the same method as the
SELECT. - "I don't have time to write tests." The time is already being spent — running the program manually from SE38 and eyeballing the result. The difference is that this check cannot be reused.
- "It gets tested after transport anyway." Manual user testing does not catch regressions. Six months later, when you touch the same method, nobody remembers the old scenario.
- "Testing all the legacy code is impossible." True, and nobody is asking for that. The goal is to test the part you are touching.
The real return on testing is not finding bugs; it is being able to change code without fear. For teams doing custom code remediation during an S/4HANA migration, that is exactly the most expensive problem: modules they end up rewriting from scratch because they are afraid to touch them.
Your First Test: Anatomy of a Test Class
ABAP Unit tests live as local classes in the Test Classes include of the global class they test (in Eclipse ADT, open the class and switch to the Test Classes tab). That way they travel in the same transport as the production code without polluting the production package.
Let's start with a simple class to test:
CLASS zcl_discount_calculator DEFINITION
PUBLIC FINAL
CREATE PUBLIC.
PUBLIC SECTION.
METHODS calculate
IMPORTING order_value TYPE netwr_ap
customer_group TYPE kdgrp
RETURNING VALUE(discount) TYPE netwr_ap.
ENDCLASS.The test class looks like this:
CLASS ltcl_discount_calculator DEFINITION FINAL FOR TESTING
DURATION SHORT
RISK LEVEL HARMLESS.
PRIVATE SECTION.
" cut = code under test
DATA cut TYPE REF TO zcl_discount_calculator.
METHODS setup.
METHODS no_discount_below_threshold FOR TESTING.
METHODS ten_percent_for_key_account FOR TESTING.
ENDCLASS.
CLASS ltcl_discount_calculator IMPLEMENTATION.
METHOD setup.
cut = NEW zcl_discount_calculator( ).
ENDMETHOD.
METHOD no_discount_below_threshold.
cl_abap_unit_assert=>assert_equals(
act = cut->calculate( order_value = '999' customer_group = '01' )
exp = 0
msg = 'Orders below the threshold must get no discount' ).
ENDMETHOD.
METHOD ten_percent_for_key_account.
cl_abap_unit_assert=>assert_equals(
act = cut->calculate( order_value = '1000' customer_group = 'KA' )
exp = '100'
msg = 'Key accounts are expected to get a 10% discount' ).
ENDMETHOD.
ENDCLASS.There are three building blocks:
1. The two mandatory declarations in the class header
RISK LEVEL — HARMLESS, DANGEROUS or CRITICAL. HARMLESS means the test changes neither the database nor system settings. Your unit tests should always be at this level.
DURATION — SHORT, MEDIUM or LONG. It declares the expected runtime; exceeding it raises a warning. A genuine unit test is SHORT (well under a second).
2. Fixture methods
setup runs before every test method and teardown after every test. class_setup and class_teardown run once per class — put expensive setup there, such as a database double.
Because each test method gets a clean start from setup, tests do not depend on each other. That is the only real guarantee that your tests are independent of execution order.
3. FOR TESTING methods
Every private method marked FOR TESTING is a test. The name should describe the behaviour being verified. A name like test_01 tells you nothing when the test turns red; no_discount_below_threshold gives you the diagnosis straight from the failure report.
If you call code that can raise a checked exception, declare the method as FOR TESTING RAISING cx_static_check; an unexpected exception then fails the test automatically.
Choosing the Right Assertion
All verification goes through the cl_abap_unit_assert class. The ones I use most:
" Value comparison — the most used by far
cl_abap_unit_assert=>assert_equals( act = ... exp = ... msg = '...' ).
cl_abap_unit_assert=>assert_differs( act = ... exp = ... msg = '...' ).
" Empty / non-empty
cl_abap_unit_assert=>assert_initial( act = ... msg = '...' ).
cl_abap_unit_assert=>assert_not_initial( act = ... msg = '...' ).
" Reference check
cl_abap_unit_assert=>assert_bound( act = lo_instance msg = '...' ).
" Boolean
cl_abap_unit_assert=>assert_true( act = ... msg = '...' ).
cl_abap_unit_assert=>assert_false( act = ... msg = '...' ).
" Unconditional failure — when an expected exception did not arrive
cl_abap_unit_assert=>fail( 'Expected exception was not raised' ).assert_equals also works on internal tables and structures, so you do not need a loop to compare row by row. You can pass two internal tables directly.
Here is the pattern for expecting an exception — it is critical that the test also fails when the exception does not arrive:
METHOD missing_order_raises_exception.
TRY.
cut->read( '9999999999' ).
cl_abap_unit_assert=>fail( 'Expected an exception for a missing order' ).
CATCH zcx_order_not_found.
" expected behaviour — test passes
ENDTRY.
ENDMETHOD.Do not skip the msg parameter. When your test breaks in a scheduled ATC run, that sentence is the only information you have.
The Real Problem Is Design, Not Testing
The example above was easy because calculate is a pure computation. Real-world ABAP usually looks more like this:
METHOD process_overdue_order.
SELECT SINGLE * FROM vbak
INTO @DATA(order)
WHERE vbeln = @order_id.
IF sy-datum > order-erdat + 30.
CALL FUNCTION 'Z_SEND_REMINDER_MAIL'
EXPORTING iv_vbeln = order-vbeln.
UPDATE vbak SET zzstatus = 'X' WHERE vbeln = @order_id.
COMMIT WORK.
ENDIF.
ENDMETHOD.This method cannot be tested. Not because ABAP Unit falls short, but because the method does four separate jobs at once:
- Reading data (
SELECT) — requires that order to exist in the test environment. - System time (
sy-datum) — the result depends on the day it runs. A test that passes today breaks in six months. - Side effect (sending mail) — a real mail goes out every time the test runs.
- Writing data (
UPDATE+COMMIT) — makes the testDANGEROUSand unrunnable in ordinary clients.
The fix is not to force a test onto this; it is to separate the decision from the side effects. There is a business rule in here: "an order is overdue if more than 30 days have passed since its creation date." That rule can be written as a pure function and tested on its own. Reading, writing and mailing remain a thin shell around it.
Pulling Dependencies Out: Interfaces and Injection
The classic approach, which works perfectly well in ABAP: put the dependency behind an interface and hand it to the object from outside (constructor injection).
First an interface for the read responsibility:
INTERFACE zif_order_reader PUBLIC.
TYPES: BEGIN OF ty_order,
order_id TYPE vbeln_va,
created TYPE erdat,
net_value TYPE netwr_ap,
END OF ty_order.
METHODS read
IMPORTING order_id TYPE vbeln_va
RETURNING VALUE(order) TYPE ty_order
RAISING zcx_order_not_found.
ENDINTERFACE.System time is a dependency too, so give it an interface as well. This small move is what makes date-dependent tests still produce the same result years later:
INTERFACE zif_clock PUBLIC.
METHODS today RETURNING VALUE(result) TYPE d.
ENDINTERFACE.The decision class now receives both dependencies from outside and writes nothing:
CLASS zcl_overdue_policy DEFINITION
PUBLIC FINAL
CREATE PUBLIC.
PUBLIC SECTION.
METHODS constructor
IMPORTING order_reader TYPE REF TO zif_order_reader
clock TYPE REF TO zif_clock.
METHODS is_overdue
IMPORTING order_id TYPE vbeln_va
RETURNING VALUE(result) TYPE abap_bool
RAISING zcx_order_not_found.
PRIVATE SECTION.
CONSTANTS c_grace_days TYPE i VALUE 30.
DATA order_reader TYPE REF TO zif_order_reader.
DATA clock TYPE REF TO zif_clock.
ENDCLASS.
CLASS zcl_overdue_policy IMPLEMENTATION.
METHOD constructor.
me->order_reader = order_reader.
me->clock = clock.
ENDMETHOD.
METHOD is_overdue.
DATA(order) = order_reader->read( order_id ).
result = xsdbool( clock->today( ) > order-created + c_grace_days ).
ENDMETHOD.
ENDCLASS.In production a factory wires up the real implementations; in a test you supply the fakes yourself. If constructs such as xsdbool and NEW are unfamiliar, have a look at the modern ABAP syntax article.
The ABAP Test Double Framework
For simple cases a hand-written local double is more readable than the framework. Local test double classes conventionally take the ltd_ prefix:
CLASS ltd_fixed_clock DEFINITION FOR TESTING.
PUBLIC SECTION.
INTERFACES zif_clock.
METHODS constructor IMPORTING fixed_date TYPE d.
PRIVATE SECTION.
DATA fixed_date TYPE d.
ENDCLASS.
CLASS ltd_fixed_clock IMPLEMENTATION.
METHOD constructor.
me->fixed_date = fixed_date.
ENDMETHOD.
METHOD zif_clock~today.
result = fixed_date.
ENDMETHOD.
ENDCLASS.For interfaces with many methods, writing each one by hand gets tedious. SAP's ABAP Test Double Framework generates the double for you:
METHOD overdue_order_is_flagged.
" 1) Generate a double from the interface
DATA(order_reader) = CAST zif_order_reader(
cl_abap_testdouble=>create( 'ZIF_ORDER_READER' ) ).
" 2) Configure what the next call should return
cl_abap_testdouble=>configure_call( order_reader
)->returning( VALUE zif_order_reader=>ty_order(
order_id = '0000004711'
created = '20260801'
net_value = '2500' ) ).
" 3) The call that records the configuration (not a verification yet)
order_reader->read( '0000004711' ).
" 4) Build the object under test with the doubles
DATA(cut) = NEW zcl_overdue_policy(
order_reader = order_reader
clock = NEW ltd_fixed_clock( '20260919' ) ).
" 5) Verify
cl_abap_unit_assert=>assert_true(
act = cut->is_overdue( '0000004711' )
msg = 'An order past 30 days must count as overdue' ).
ENDMETHOD.Step three is confusing at first glance: the framework learns which call gets which answer by having you make that call once. That line is a recording step, not a verification.
Two useful extras:
- Simulating exceptions:
configure_call( double )->raise_exception( NEW zcx_order_not_found( ) )lets you test the failure path. Failure paths are often more critical than the happy path and the hardest to trigger in a real system. - Call expectations:
->and_expect( )->is_called_times( 1 )asserts how often the dependency is called, verified at the end of the test withcl_abap_testdouble=>verify_expectations( double ). Use this sparingly: tests that verify internal structure instead of behaviour obstruct refactoring rather than enabling it.
Testing Code That Touches the Database
Once the dependencies are separated, what remains is the thin layer that really does go to the database — the actual implementation of zif_order_reader in the example above. You can test that too; SAP provides the Open SQL Test Double Framework for it. The framework intercepts SELECTs against the tables you list and returns the test data you supply. The real table is never touched, so the test stays HARMLESS:
CLASS ltcl_order_reader DEFINITION FINAL FOR TESTING
DURATION SHORT
RISK LEVEL HARMLESS.
PRIVATE SECTION.
CLASS-DATA sql_environment TYPE REF TO if_osql_test_environment.
CLASS-METHODS class_setup.
CLASS-METHODS class_teardown.
METHODS setup.
METHODS reads_existing_order FOR TESTING RAISING cx_static_check.
ENDCLASS.
CLASS ltcl_order_reader IMPLEMENTATION.
METHOD class_setup.
" Expensive setup: once per class
sql_environment = cl_osql_test_environment=>create(
i_dependency_list = VALUE #( ( 'VBAK' ) ) ).
ENDMETHOD.
METHOD class_teardown.
sql_environment->destroy( ).
ENDMETHOD.
METHOD setup.
" Clear the doubles before each test so tests don't pollute each other
sql_environment->clear_doubles( ).
ENDMETHOD.
METHOD reads_existing_order.
DATA orders TYPE STANDARD TABLE OF vbak.
orders = VALUE #( ( vbeln = '0000004711'
kunnr = '0000001000'
erdat = '20260801'
netwr = '2500'
waerk = 'EUR' ) ).
sql_environment->insert_test_data( orders ).
DATA(order) = NEW zcl_order_reader( )->read( '0000004711' ).
cl_abap_unit_assert=>assert_equals(
act = order-net_value
exp = '2500'
msg = 'The net value read from the test data must match' ).
ENDMETHOD.
ENDCLASS.For code that reads CDS Views, the sibling framework is cl_cds_test_environment. You pass the target entity via i_for_entity and the framework doubles the underlying tables that view is built on:
cds_environment = cl_cds_test_environment=>create(
i_for_entity = 'ZI_SALESORDER' ).This is genuinely valuable when developing CDS-based OData V4 services: you can verify the filter and calculation logic of a consumption view without ever connecting to production data.
Existing Legacy Code: TEST-SEAM and Its Limits
If what you have is a three-thousand-line report with neither interfaces nor classes, none of the above applies immediately. ABAP offers TEST-SEAM for exactly this situation: you open a seam in the production code whose contents a test can replace.
" Production code
METHOD read_config.
TEST-SEAM config_read.
SELECT SINGLE value FROM ztconfig
INTO @result
WHERE key = @key.
END-TEST-SEAM.
ENDMETHOD." Test code
METHOD returns_injected_config.
TEST-INJECTION config_read.
result = 'X'.
END-TEST-INJECTION.
cl_abap_unit_assert=>assert_equals(
act = cut->read_config( 'MAIL_ACTIVE' )
exp = 'X'
msg = 'The injected configuration value must be returned' ).
ENDMETHOD.Know this before reaching for TEST-SEAM
It leaves test traces in production code. The TEST-SEAM block is part of the production source; it does not fix the design flaw, it papers over it.
It is not available in ABAP Cloud. TEST-SEAM is not permitted in the ABAP for Cloud Development language version. Code you test with a seam today will have to be revisited when it moves to the clean core.
Its correct use is temporary. It is reasonable for getting a first safety net around legacy code without breaking it. Once the net is in place, move the dependency behind an interface and remove the seam.
Running Tests, Coverage and ATC
Day to day in Eclipse ADT, two shortcuts will cover you:
- Ctrl + Shift + F10 — runs the tests of the open object. Use the same shortcut on a package and every test in that package runs; it is the fastest way to validate a whole module.
- Ctrl + Shift + F11 — runs the tests with coverage measurement. Executed lines are marked green in the editor, never-entered lines red.
To be honest about the coverage percentage: it is an indicator, not a target. It is entirely possible to write a high-coverage test suite that verifies nothing. The useful way to read it is this — if a critical business rule sits among the red lines in the coverage report, write a test there; do not spend effort turning getters and setters green.
For tests to actually protect you, they have to run automatically. Two practical routes:
- ATC integration: add the unit test check to your ABAP Test Cockpit check variant and bind it to transport release. A transport with a failing test cannot be released. This is the only mechanism that keeps the discipline from depending on people remembering.
- Scheduled runs: set up a scheduled Code Inspector run for critical packages and route the result to the team. Seeing a red line in the morning is cheaper than seeing an error in production.
One warning: tests at DANGEROUS or CRITICAL risk level will not run unless the client setting explicitly allows it. That is deliberate protection; instead of opening up a client to run a test, make the test HARMLESS.
Rules That Hold Up in Real Projects
For new code
Write the test first, or at least alongside. By the time you sit down to write tests after the code is finished, it is too late to discover that the code is untestable.
Verify one behaviour per test. When a test with five assertions breaks, you have to open the debugger to find out which rule was violated.
Make the test name a sentence. The name of a failing test should tell whoever reads the report exactly what broke.
For existing code
The boy scout rule: do not try to test the whole repository. When you touch a method to fix a bug, first write a red test that demonstrates the bug, then fix it. When the test turns green you have both solved the bug and left a permanent regression shield behind.
Prioritise classes dense in business rules. Pricing, discounts, tax, approval flows — places where logic concentrates and changes often — give the highest return. Screen flow and ALV configuration give the lowest.
For the test code itself
Test code is production code. Clean ABAP rules apply inside the test class too; test classes that grow by copy-paste eventually get deleted because nobody maintains them.
Do not disable a failing test. A commented-out test is worse than no test: it creates a sense of assurance that does not exist. Either fix it or delete it.
A test that touches the database, RFC or mail is not a unit test. Those scenarios are integration tests; move them to a separate class, give them DURATION MEDIUM and run them in scheduled runs rather than on every change.
Conclusion
ABAP Unit is a tool you can learn in half a day: one local class, two declarations, a handful of assertion methods. The hard part is not the tool but the design that makes code testable — separating the business rule from data access, system time and side effects.
The good news is that when you do that separation, tests are not all you gain. Single-responsibility classes with their dependencies behind interfaces are already more readable, easier to change and better prepared for the move to ABAP Cloud. The effort of writing tests is really the bill for fixing the design — and that bill gets paid either way: now with tests, or later in production without them.
Start with the next method you touch. One test is categorically different from zero tests.
Get support on test strategy and refactoring for your existing ABAP codebase.
Request an Initial Consultation