Testable ABAP Architecture: Build Code You Can Test
ABAP

Testable ABAP Architecture: Build Code You Can Test

Here's a hard truth most ABAP developers discover too late: you can't retrofit testability. If you design your classes around direct database calls, hard-coded dependencies, and static method chains, no amount of clever test tooling will save you. Testable ABAP architecture isn't something you bolt on at the end — it's a series of upfront decisions that either open the door to unit testing or slam it shut.

In this post, I'll walk you through the specific architectural choices that make ABAP code testable from day one. We'll cover dependency injection patterns, interface-driven design, the seam principle, and how to structure your classes so ABAP Unit can actually reach the logic you care about.

Why Testable ABAP Architecture Starts With Dependencies

The single biggest barrier to unit testing in ABAP is uncontrolled dependencies. I'm talking about SELECT statements living inside business logic classes, calls to BAPI function modules buried three methods deep, or DATE system variables read directly in the middle of a calculation. Each one of those is a hidden coupling that makes isolated testing nearly impossible.

The fix isn't complicated, but it requires discipline. You need to identify every external dependency your class touches and make those dependencies injectable — meaning they come in from the outside rather than being created or accessed internally.

Think of it this way: if your class creates its own database reader with CREATE OBJECT inside the constructor, your test has no way to intercept that. But if the database reader is passed in as a constructor parameter, your test can hand in a test double instead. That's the entire game.

Interface-First Design: The Foundation of Testability

Before you write a single class, ask yourself: what are the seams in this design where I might want to swap in a fake implementation during testing? Those seams should become interfaces.

Here's a concrete example. Say you're building a pricing calculator that needs to read condition records from the database:


INTERFACE zif_condition_reader.
  METHODS:
    get_conditions
      IMPORTING
        iv_material TYPE matnr
        iv_customer TYPE kunnr
      RETURNING
        VALUE(rt_conditions) TYPE ztt_conditions.
ENDINTERFACE.

Now your production implementation does the real SELECT:


CLASS zcl_condition_reader_db DEFINITION
  PUBLIC FINAL
  CREATE PUBLIC.

  PUBLIC SECTION.
    INTERFACES zif_condition_reader.
ENDCLASS.

CLASS zcl_condition_reader_db IMPLEMENTATION.
  METHOD zif_condition_reader~get_conditions.
    SELECT *
      FROM konp
      INTO TABLE @rt_conditions
      WHERE matnr = @iv_material.
  ENDMETHOD.
ENDCLASS.

And your pricing calculator only ever knows about the interface:


CLASS zcl_pricing_calculator DEFINITION
  PUBLIC
  CREATE PUBLIC.

  PUBLIC SECTION.
    METHODS:
      constructor
        IMPORTING
          io_condition_reader TYPE REF TO zif_condition_reader,
      calculate_price
        IMPORTING
          iv_material TYPE matnr
          iv_customer TYPE kunnr
        RETURNING
          VALUE(rv_price) TYPE p DECIMALS 2.

  PRIVATE SECTION.
    DATA: mo_condition_reader TYPE REF TO zif_condition_reader.
ENDCLASS.

CLASS zcl_pricing_calculator IMPLEMENTATION.
  METHOD constructor.
    mo_condition_reader = io_condition_reader.
  ENDMETHOD.

  METHOD calculate_price.
    DATA(lt_conditions) = mo_condition_reader->get_conditions(
      iv_material = iv_material
      iv_customer = iv_customer
    ).
    " ... pricing logic here
  ENDMETHOD.
ENDCLASS.

Notice what just happened. The pricing logic is now completely decoupled from the database. Your test can provide a hand-crafted test double that returns exactly the conditions you specify, making your test deterministic, fast, and database-independent.

This is the core pattern described in detail in our post on ABAP dependency injection without a framework — worth reading alongside this one.

Constructor Injection vs. Setter Injection

There are two practical ways to inject dependencies in ABAP: through the constructor or through setter methods. Both work, but they have different trade-offs.

Constructor injection (as shown above) is my strong preference. It makes dependencies explicit and visible, ensures the object is always in a valid state after instantiation, and signals to callers exactly what the class needs to function.

Setter injection is sometimes necessary when the dependency isn't known at construction time, or when you're working with a framework that constructs objects for you. The downside is that the object can exist in a partially initialized state, which is a source of subtle bugs.


" Setter injection — use when constructor injection isn't possible
CLASS zcl_pricing_calculator DEFINITION.
  PUBLIC SECTION.
    METHODS:
      set_condition_reader
        IMPORTING
          io_reader TYPE REF TO zif_condition_reader.
ENDCLASS.

One pattern I use when constructor injection would create too many parameters: group related dependencies into a configuration object or a factory. This keeps the constructor signature clean without hiding what the class actually needs.

Isolating System Dependencies With Wrappers

Database access gets all the attention, but there are other system dependencies that will ambush your tests if you're not careful:

  • System date/time: Direct calls to SY-DATUM or GET TIME STAMP make time-sensitive logic untestable
  • User context: Reading SY-UNAME directly couples your logic to the current user session
  • Configuration reads: Calling CALL FUNCTION 'SUSR_USER_AUTH_FOR_OBJ_GET' or reading Customizing tables inline creates database dependencies
  • Message output: Writing to application logs or calling MESSAGE statements inline prevents output verification

The fix for all of these is the same: wrap them behind an interface.


INTERFACE zif_system_clock.
  METHODS:
    get_current_date
      RETURNING VALUE(rv_date) TYPE datum.
ENDINTERFACE.

CLASS zcl_system_clock DEFINITION PUBLIC FINAL CREATE PUBLIC.
  PUBLIC SECTION.
    INTERFACES zif_system_clock.
ENDCLASS.

CLASS zcl_system_clock IMPLEMENTATION.
  METHOD zif_system_clock~get_current_date.
    rv_date = sy-datum.
  ENDMETHOD.
ENDCLASS.

Now in your test, you create a zcl_system_clock_mock that returns whatever date your test scenario requires. Suddenly you can test "what happens on the last day of the fiscal year" without changing the system clock.

The LOCAL FRIENDS Pattern for Testing Private Logic

Sometimes you have private methods with complex logic that genuinely deserve their own tests. You don't want to make them public just for testing — that would pollute your API. ABAP has a clean solution: the FOR TESTING addition combined with FRIENDS.


CLASS zcl_pricing_calculator DEFINITION
  PUBLIC
  CREATE PUBLIC.

  PUBLIC SECTION.
    " ... public interface

  PRIVATE SECTION.
    METHODS apply_discount
      IMPORTING iv_base_price TYPE p DECIMALS 2
      RETURNING VALUE(rv_discounted) TYPE p DECIMALS 2.

ENDCLASS.

" In the test class definition:
CLASS zcl_pricing_calculator DEFINITION LOCAL FRIENDS ltc_pricing_tests.

CLASS ltc_pricing_tests DEFINITION FOR TESTING
  DURATION SHORT
  RISK LEVEL HARMLESS.

  PRIVATE SECTION.
    METHODS test_discount_logic FOR TESTING.
ENDCLASS.

This lets your test class access private members without exposing them to the outside world. Use it sparingly — if you find yourself reaching for private internals constantly, that's usually a signal the private logic should be extracted into its own class with a proper interface.

Structuring Your Classes for Single Responsibility

One architectural decision that pays enormous dividends for testability is strict adherence to the Single Responsibility Principle. A class that does too many things is hard to test because you can never isolate the behavior you care about from the behavior you don't.

The classic offender in ABAP is the "god class" that reads data, applies business logic, formats output, and sends a message — all in one. When you try to test the business logic, you're forced to deal with database access and output formatting at the same time.

Break those responsibilities apart:

  • Data access classes: Thin wrappers around SELECT statements, nothing else
  • Domain logic classes: Pure calculation and rule enforcement, no I/O
  • Orchestration classes: Wire the others together, contain minimal logic themselves
  • Output classes: Format and present results

Your domain logic classes become trivially easy to test because they take data in and return data out, with no side effects. This aligns directly with what we covered in SOLID principles in ABAP — particularly the Single Responsibility Principle in practice.

Avoiding Static Method Traps

Static methods — called via class_name=>method_name — are a testability dead end. You cannot inject a test double in place of a static call. If your business logic class calls zcl_tax_calculator=>compute_vat( ) directly, your test has no way to intercept that call.

Static methods are fine for pure utility functions (string manipulation, mathematical helpers) that have no dependencies and no side effects. But the moment a static method touches a database, reads configuration, or produces a side effect, it becomes a testing liability.

The migration path: convert the static class to an instance class, extract the interface, and inject it. Yes, this means more boilerplate. The payoff is code you can actually verify in isolation.

Writing Tests That Document Intent

Once your architecture is testable, your tests should do more than just verify correctness — they should document what your code is supposed to do. Good test method names read like specifications:


CLASS ltc_pricing_tests IMPLEMENTATION.

  METHOD price_includes_vat_for_eu_customers.
    " Arrange
    DATA(lo_mock_reader) = NEW zcl_condition_reader_mock( ).
    lo_mock_reader->set_conditions( lt_eu_conditions ).

    DATA(lo_calculator) = NEW zcl_pricing_calculator(
      io_condition_reader = lo_mock_reader
    ).

    " Act
    DATA(lv_price) = lo_calculator->calculate_price(
      iv_material = '100-100'
      iv_customer = 'EU_CUST_001'
    ).

    " Assert
    cl_abap_unit_assert=>assert_equals(
      act = lv_price
      exp = '119.00'
      msg = 'EU customers should receive VAT-inclusive pricing'
    ).
  ENDMETHOD.

ENDCLASS.

Arrange-Act-Assert is the pattern. Each test sets up its scenario, executes exactly one thing, and verifies exactly one outcome. When a test fails six months from now, the structure tells the next developer exactly what broke and why it matters.

For deeper coverage of test doubles specifically, our article on ABAP unit testing with test doubles and mocking frameworks goes further into the mechanics of building mocks and fakes in ABAP.

Legacy Code: Creating Seams Where None Exist

Of course, not everything is greenfield. If you're working with existing code that wasn't built for testability, you need to create seams — places where you can insert test-friendly behavior without rewriting everything.

The technique Michael Feathers calls "extract and override" works well in ABAP. Take a direct dependency buried in a method, extract it into a protected method, then subclass the production class in your test and override that method to return controlled data.


" Original untestable method:
METHOD calculate_price.
  SELECT SINGLE netpr FROM konp
    INTO @DATA(lv_price)
    WHERE matnr = @iv_material.
  rv_price = lv_price * '1.19'.
ENDMETHOD.

" Refactored with an extracted seam:
METHOD calculate_price.
  DATA(lv_price) = get_base_price( iv_material ).
  rv_price = lv_price * '1.19'.
ENDMETHOD.

METHOD get_base_price.  " PROTECTED — overridable
  SELECT SINGLE netpr FROM konp
    INTO @rv_price
    WHERE matnr = @iv_material.
ENDMETHOD.

Now your test subclass overrides get_base_price to return a fixed value, and you can test the VAT logic without touching the database. It's not the cleanest architecture, but it's a practical stepping stone when refactoring legacy code incrementally.

This approach pairs naturally with the strategies in our post on refactoring legacy ABAP reports to clean OOP.

The Payoff: What Testable Architecture Actually Gives You

Investing in testable ABAP architecture delivers returns that compound over time:

  • Faster debugging: When a unit test fails, you know exactly which class and which scenario broke
  • Confident refactoring: Your test suite tells you immediately if a change broke something
  • Better design pressure: The discipline of writing testable code pushes you toward cleaner separation of concerns
  • Onboarding documentation: Tests show new team members what the code is supposed to do, not just what it does
  • Reduced transport risk: Catch regressions in development, not in production

The architecture decisions themselves — interfaces, dependency injection, single responsibility, static method avoidance — make your code better even before you write a single test. Testability is really just a proxy for good design.

Start with one class. Pick the one with the most business logic, extract an interface for its biggest dependency, inject it through the constructor, and write three tests. You'll see immediately what was hidden from you before. Then keep going.

For the broader design principles that support this approach, ABAP dependency injection without a framework gives you the full pattern catalog without requiring any additional tooling.