DEV Community

Cover image for ABAP Unit Testing in SAP S/4HANA — Part 2: Test Doubles, Dependency Injection, and Mocking Strategies for Real-World Systems
Oktay Ates
Oktay Ates

Posted on • Originally published at aixsap.com

ABAP Unit Testing in SAP S/4HANA — Part 2: Test Doubles, Dependency Injection, and Mocking Strategies for Real-World Systems

If you’ve already read Part 1 of our ABAP Unit Testing series, you know the foundation: why tests matter, how to structure them, and what makes a test valuable rather than ceremonial. Now it’s time to get into the harder stuff—the part that most ABAP developers avoid because it feels complicated. I’m talking about test doubles, dependency injection, and mocking in ABAP.

Here’s the honest truth: writing a unit test that calls real database tables, triggers actual BAPIs, or depends on system configuration is not a unit test. It’s an integration test wearing a costume. And when it breaks at 2 AM in production, you won’t know if your logic is wrong or the test environment is misconfigured. This article will show you how to fix that—with real, working ABAP code and patterns I’ve used on actual S/4HANA projects.

Why Dependency Injection Changes Everything in ABAP Testing

The core problem with legacy ABAP code and testability is tight coupling. Your method directly calls SELECT from a database table, calls a function module, or accesses a Singleton class. There’s no seam—no place to inject a replacement during testing.

Dependency Injection (DI) is the architectural answer. Instead of your class creating or directly calling its dependencies, those dependencies are provided from outside. This means during tests, you can swap in a controlled substitute—a test double—that behaves exactly as you need.

In ABAP, DI is typically implemented through:

  • Constructor injection: Passing the dependency when instantiating the class

  • Setter injection: Providing the dependency via a setter method after instantiation

  • Parameter injection: Passing the dependency directly to the method that needs it

Constructor injection is the cleanest approach in most cases and the one I recommend as a default.

Understanding the Test Double Taxonomy

Before writing code, let’s get the terminology right. These terms are often used interchangeably—incorrectly. As a senior architect, precision matters.

Dummy

An object passed around but never actually used. It satisfies a parameter requirement but plays no role in the test logic.

Stub

Provides pre-configured return values. You tell it what to return for a given input. No behavior verification—just controlled outputs.

Spy

Like a stub, but it also records calls made to it. After the test, you can verify what was called, how many times, and with what parameters.

Mock

Pre-programmed with expectations. It verifies that specific interactions occurred. If the expected call doesn’t happen, the mock fails the test.

Fake

A working implementation that takes shortcuts suitable for testing—like an in-memory database instead of a real one.

In ABAP unit testing practice, you’ll most frequently work with stubs and fakes. Let’s build them.

Setting Up: A Real-World Example Scenario

Let’s work with a realistic scenario: a class that calculates customer credit limits based on their order history. It depends on a data access object (DAO) that queries the database and a pricing service.

Step 1: Define the Interface

Always program against interfaces, not implementations. This is what makes DI and test doubles possible in ABAP.


" Interface: ZIF_CUSTOMER_DAO
INTERFACE zif_customer_dao
  PUBLIC.

  TYPES:
    BEGIN OF ty_customer_data,
      customer_id   TYPE kunnr,
      total_orders  TYPE i,
      total_revenue TYPE p LENGTH 13 DECIMALS 2,
      payment_score TYPE i,
    END OF ty_customer_data.

  METHODS:
    get_customer_data
      IMPORTING
        iv_customer_id TYPE kunnr
      RETURNING
        VALUE(rs_data) TYPE ty_customer_data
      RAISING
        zcx_customer_not_found.

ENDINTERFACE.

Enter fullscreen mode Exit fullscreen mode

" Interface: ZIF_CREDIT_POLICY
INTERFACE zif_credit_policy
  PUBLIC.

  METHODS:
    calculate_credit_limit
      IMPORTING
        iv_revenue      TYPE p
        iv_payment_score TYPE i
      RETURNING
        VALUE(rv_limit) TYPE p LENGTH 13 DECIMALS 2.

ENDINTERFACE.

Enter fullscreen mode Exit fullscreen mode

Step 2: Implement the Production Class with Constructor Injection


" Class: ZCL_CREDIT_LIMIT_CALCULATOR
CLASS zcl_credit_limit_calculator DEFINITION
  PUBLIC FINAL CREATE PUBLIC.

  PUBLIC SECTION.
    METHODS:
      constructor
        IMPORTING
          io_customer_dao  TYPE REF TO zif_customer_dao
          io_credit_policy TYPE REF TO zif_credit_policy,

      get_credit_limit
        IMPORTING
          iv_customer_id   TYPE kunnr
        RETURNING
          VALUE(rv_limit)  TYPE p LENGTH 13 DECIMALS 2
        RAISING
          zcx_customer_not_found.

  PRIVATE SECTION.
    DATA:
      mo_customer_dao  TYPE REF TO zif_customer_dao,
      mo_credit_policy TYPE REF TO zif_credit_policy.

ENDCLASS.

CLASS zcl_credit_limit_calculator IMPLEMENTATION.

  METHOD constructor.
    mo_customer_dao  = io_customer_dao.
    mo_credit_policy = io_credit_policy.
  ENDMETHOD.

  METHOD get_credit_limit.
    " Retrieve customer data via injected DAO
    DATA(ls_customer) = mo_customer_dao->get_customer_data(
      iv_customer_id = iv_customer_id
    ).

    " Apply policy via injected policy service
    rv_limit = mo_credit_policy->calculate_credit_limit(
      iv_revenue       = ls_customer-total_revenue
      iv_payment_score = ls_customer-payment_score
    ).
  ENDMETHOD.

ENDCLASS.

Enter fullscreen mode Exit fullscreen mode

Notice what’s happening here: the production class has no idea whether it’s talking to a real database or a test double. That’s exactly what we want.

Building Test Doubles in ABAP Unit Tests

Now let’s write the test class. In ABAP, test doubles are typically implemented as FOR TESTING local classes within the test class file. They implement the same interface as the production dependency but return controlled values.

Building a Stub for the Customer DAO


CLASS ltc_customer_dao_stub DEFINITION FOR TESTING.
  PUBLIC SECTION.
    INTERFACES zif_customer_dao.

    " Control what the stub returns
    DATA:
      ms_return_data   TYPE zif_customer_dao=>ty_customer_data,
      mv_raise_exception TYPE abap_bool.

ENDCLASS.

CLASS ltc_customer_dao_stub IMPLEMENTATION.

  METHOD zif_customer_dao~get_customer_data.
    IF mv_raise_exception = abap_true.
      RAISE EXCEPTION TYPE zcx_customer_not_found
        EXPORTING
          textid = zcx_customer_not_found=>customer_not_found.
    ENDIF.
    rs_data = ms_return_data.
  ENDMETHOD.

ENDCLASS.

Enter fullscreen mode Exit fullscreen mode

Building a Stub for the Credit Policy


CLASS ltc_credit_policy_stub DEFINITION FOR TESTING.
  PUBLIC SECTION.
    INTERFACES zif_credit_policy.

    DATA mv_return_limit TYPE p LENGTH 13 DECIMALS 2.

ENDCLASS.

CLASS ltc_credit_policy_stub IMPLEMENTATION.

  METHOD zif_credit_policy~calculate_credit_limit.
    rv_limit = mv_return_limit.
  ENDMETHOD.

ENDCLASS.

Enter fullscreen mode Exit fullscreen mode

Writing the Actual Test Class


CLASS ltc_credit_limit_calculator DEFINITION FOR TESTING
  RISK LEVEL HARMLESS
  DURATION SHORT.

  PRIVATE SECTION.
    DATA:
      mo_cut           TYPE REF TO zcl_credit_limit_calculator,  " Class Under Test
      mo_dao_stub      TYPE REF TO ltc_customer_dao_stub,
      mo_policy_stub   TYPE REF TO ltc_credit_policy_stub.

    METHODS:
      setup,
      test_standard_customer_gets_correct_limit,
      test_high_value_customer_gets_higher_limit,
      test_unknown_customer_raises_exception.

ENDCLASS.

CLASS ltc_credit_limit_calculator IMPLEMENTATION.

  METHOD setup.
    " Instantiate stubs
    CREATE OBJECT mo_dao_stub.
    CREATE OBJECT mo_policy_stub.

    " Inject stubs into the class under test
    mo_cut = NEW zcl_credit_limit_calculator(
      io_customer_dao  = mo_dao_stub
      io_credit_policy = mo_policy_stub
    ).
  ENDMETHOD.

  METHOD test_standard_customer_gets_correct_limit.
    " Arrange: configure stub behavior
    mo_dao_stub->ms_return_data = VALUE #(
      customer_id    = '1000000001'
      total_revenue  = '50000.00'
      payment_score  = 75
    ).
    mo_policy_stub->mv_return_limit = '25000.00'.

    " Act
    DATA(lv_result) = mo_cut->get_credit_limit(
      iv_customer_id = '1000000001'
    ).

    " Assert
    cl_abap_unit_assert=>assert_equals(
      exp = '25000.00'
      act = lv_result
      msg = 'Standard customer credit limit should be 25000'
    ).
  ENDMETHOD.

  METHOD test_high_value_customer_gets_higher_limit.
    " Arrange
    mo_dao_stub->ms_return_data = VALUE #(
      customer_id    = '1000000002'
      total_revenue  = '500000.00'
      payment_score  = 95
    ).
    mo_policy_stub->mv_return_limit = '200000.00'.

    " Act
    DATA(lv_result) = mo_cut->get_credit_limit(
      iv_customer_id = '1000000002'
    ).

    " Assert
    cl_abap_unit_assert=>assert_equals(
      exp = '200000.00'
      act = lv_result
      msg = 'High-value customer should receive 200000 credit limit'
    ).
  ENDMETHOD.

  METHOD test_unknown_customer_raises_exception.
    " Arrange: tell the stub to raise an exception
    mo_dao_stub->mv_raise_exception = abap_true.

    " Act & Assert: expect the exception to propagate
    TRY.
      mo_cut->get_credit_limit( iv_customer_id = '9999999999' ).
      cl_abap_unit_assert=>fail(
        msg = 'Exception zcx_customer_not_found was expected but not raised'
      ).
    CATCH zcx_customer_not_found.
      " Expectedtest passes
    ENDTRY.
  ENDMETHOD.

ENDCLASS.

Enter fullscreen mode Exit fullscreen mode

This is the Arrange-Act-Assert (AAA) pattern in clean ABAP. Each test method does exactly one thing, has no hidden dependencies, and runs in milliseconds with no database involvement whatsoever.

Using ABAP Test Seams for Legacy Code

Not every codebase is greenfield. You’ll often need to write tests for legacy ABAP that wasn’t designed with testability in mind. For those situations, ABAP provides Test Seams—a mechanism that lets you inject alternative code specifically during test execution.


" In production code (legacy method)
METHOD get_open_orders.
  TEST-SEAM get_orders_from_db.
    SELECT vbeln, erdat, netwr
      FROM vbak
      INTO TABLE @DATA(lt_orders)
      WHERE kunnr = @iv_customer_id
        AND gbstk NE 'C'.
  END-TEST-SEAM.

  rv_count = lines( lt_orders ).
ENDMETHOD.

Enter fullscreen mode Exit fullscreen mode

" In test class: inject alternative implementation
METHOD test_open_order_count.
  REPLACE TEST-SEAM get_orders_from_db WITH.
    lt_orders = VALUE #(
      ( vbeln = '0000012345' erdat = '20240101' netwr = '1000.00' )
      ( vbeln = '0000012346' erdat = '20240115' netwr = '2500.00' )
    ).
  END-REPLACE.

  DATA(lv_count) = mo_cut->get_open_orders( iv_customer_id = '1000000001' ).

  cl_abap_unit_assert=>assert_equals(
    exp = 2
    act = lv_count
    msg = 'Should return 2 open orders'
  ).
ENDMETHOD.

Enter fullscreen mode Exit fullscreen mode

Test Seams are a pragmatic bridge. Use them on legacy code while you refactor toward proper interface-based DI. Don’t make them a permanent strategy—they couple your production code to test infrastructure in a way that becomes messy at scale.

Architect’s note: I’ve refactored dozens of legacy ABAP programs. The typical progression is: Test Seams first to get coverage, then extract interfaces, then introduce DI, then retire the seams. Don’t try to do it all at once—that’s how refactoring projects stall.

Verifying Interactions with Spy-Style Test Doubles

Sometimes knowing what was returned isn’t enough—you need to verify what was called. This is where spy-style doubles come in. Let’s extend the DAO stub to also act as a spy:


CLASS ltc_customer_dao_spy DEFINITION FOR TESTING.
  PUBLIC SECTION.
    INTERFACES zif_customer_dao.

    DATA:
      ms_return_data       TYPE zif_customer_dao=>ty_customer_data,
      mv_get_data_called   TYPE abap_bool,
      mv_last_customer_id  TYPE kunnr,
      mv_call_count        TYPE i.

ENDCLASS.

CLASS ltc_customer_dao_spy IMPLEMENTATION.

  METHOD zif_customer_dao~get_customer_data.
    " Record the call
    mv_get_data_called  = abap_true.
    mv_last_customer_id = iv_customer_id.
    mv_call_count       = mv_call_count + 1.

    rs_data = ms_return_data.
  ENDMETHOD.

ENDCLASS.

Enter fullscreen mode Exit fullscreen mode

METHOD test_dao_is_called_exactly_once.
  " Arrange
  DATA(lo_dao_spy) = NEW ltc_customer_dao_spy( ).
  lo_dao_spy->ms_return_data = VALUE #( customer_id = '1000000001' payment_score = 80 ).
  mo_policy_stub->mv_return_limit = '10000.00'.

  DATA(lo_cut) = NEW zcl_credit_limit_calculator(
    io_customer_dao  = lo_dao_spy
    io_credit_policy = mo_policy_stub
  ).

  " Act
  lo_cut->get_credit_limit( iv_customer_id = '1000000001' ).

  " Assert: verify interaction
  cl_abap_unit_assert=>assert_true(
    act = lo_dao_spy->mv_get_data_called
    msg = 'DAO get_customer_data should have been called'
  ).
  cl_abap_unit_assert=>assert_equals(
    exp = 1
    act = lo_dao_spy->mv_call_count
    msg = 'DAO should be called exactly once'
  ).
  cl_abap_unit_assert=>assert_equals(
    exp = '1000000001'
    act = lo_dao_spy->mv_last_customer_id
    msg = 'DAO should be called with the correct customer ID'
  ).
ENDMETHOD.

Enter fullscreen mode Exit fullscreen mode

Common Pitfalls and How to Avoid Them

Pitfall 1: Testing Through Too Many Layers

If your test setup requires six objects and three stubs just to test one method, your class is doing too much. This is a test smell pointing to a Single Responsibility violation. Refactor the class, not the test.

Pitfall 2: Over-Mocking

When every method call is mocked and verified, you end up with tests that are brittle—they break on every refactoring even when behavior is correct. Focus mocks on behavior verification, not interaction verification. Use stubs for everything else.

Pitfall 3: Shared State Between Tests

In ABAP, the SETUP method runs before each test. Use it religiously. Never rely on instance variables modified in one test affecting another—that’s a recipe for flaky, order-dependent tests.

Pitfall 4: Ignoring Exception Paths

Most developers test the happy path and call it done. Your exception handling is often the most critical code in production. Always write explicit tests for error scenarios as demonstrated in the examples above. Our ABAP Exception Handling series covers building robust error architectures that are worth testing thoroughly.

Connecting Tests to Your CI/CD Pipeline

Tests that only run when a developer remembers to run them aren’t worth much. In modern S/4HANA development—especially when using ABAP RESTful Application Programming Model (RAP)—you should integrate ABAP Unit Tests into your transport management and CI/CD process.

Key integration points:

  • Use ATC (ABAP Test Cockpit) checks with unit test coverage thresholds before transport release

  • Configure gCTS (Git-enabled Change and Transport System) hooks to run unit tests on every push

  • Set minimum coverage targets per package—I typically recommend 70% as a floor, 85%+ as a target for business-critical logic

  • Use abapGit alongside your pipeline to version-control test classes along with production code

What Good Test Coverage Actually Looks Like

Coverage percentages are a proxy metric, not a goal. I’ve seen 90% coverage on code that tested nothing meaningful and 60% coverage that gave complete confidence in the business logic. Coverage tells you what was executed—not what was verified.

A healthier set of coverage questions to ask:

  • Are all business rules covered by at least one test?

  • Are all exception paths tested?

  • Do the tests run in under 30 seconds total?

  • Can a new developer understand the business rules just by reading the tests?

That last point is powerful. Well-written tests are the best documentation your system will ever have—and unlike comments, they can’t lie.

Key Takeaways

  • Design for testability from day one: Program against interfaces, inject dependencies via constructor, and avoid static calls in business logic

  • Know your test double types: Use stubs for controlled returns, spies for interaction verification, and fakes for complex in-memory substitutes

  • Use Test Seams tactically: They’re a bridge for legacy code, not a destination

  • Follow AAA: Arrange, Act, Assert—every test, every time

  • Test exceptions explicitly: Don’t skip the error paths; they matter most in production

  • Automate execution: Tests not in a pipeline are suggestions, not guarantees

If you want to go deeper on clean ABAP architecture principles that make all of this easier, check out our guide on refactoring legacy SAP code to modern standards. The patterns are complementary—clean code and testable code are almost always the same code.

What’s Next?

In Part 3 of this series, we’ll tackle integration testing in ABAP—specifically, how to test RAP business objects end-to-end, how to use EML (Entity Manipulation Language) in tests, and when integration tests are worth the added complexity. Subscribe or bookmark the site to catch it when it drops.

Have a specific mocking or testing scenario you’re struggling with in your S/4HANA project? Drop it in the comments below. I read every one and often turn them into future articles. If this guide helped you, share it with your team—testing culture starts with one person deciding to care about it.

Top comments (0)