如果你在 ADT 里写过 ABAP 单元测试,大概率遇到过这种场面:被测类一行没改,测试却红了。我前几天就碰到一次,一个算订单金额的类,逻辑毫无变化,只是因为底层取数直接读了一张业务表,那张表里恰好多了一条脏数据,查询结果从一行变成两行,断言立刻挂掉。更烦人的是另一个调用外部价格服务的类,测试一跑就卡在那边超时。这种测试不是用来保障质量的,是用来折磨人的。要让 ABAP 单元测试稳如磐石,核心手段就是引入 Test Double,把数据库、函数、外部服务这些不可控的东西全部换成可控的替身。这篇文章就沿着接口、Function Module、数据库表、CDS View 四条主线,把我在 ADT 里做测试替身的完整方法、实际写法和踩坑记录整理出来。
1. 为什么 ABAP 单元测试总是"看天吃饭":依赖隔离是根因
1.1 一个典型的"测试自己就红了"的现场
很多 ABAP 开发者第一次写单元测试,写的是这种代码:被测类的方法里直接SELECT * FROM ztable,或者直接CALL FUNCTION 'Z_CRM_GET_PRICE',测试用例跑的时候连的是开发环境里的真实数据库、真实函数。这类测试能不能过,完全取决于底层数据长什么样。今天能过,明天业务顾问在后台维护了一条数据,测试就挂了;后天传输请求把一个 Function Module 的接口改了,又挂一批。
问题出在哪?并不是你的断言逻辑写错了,而是测试的执行结果被外部依赖污染了。数据库里的数据、远端系统的响应、其他程序留下的共享内存,这些都不归单元测试管。单元测试要验证的是"我这个方法,在给定输入下,是否产出符合预期的输出",而不是"当前环境能否凑巧返回我要的数据"。所以,第一步不是去学更多断言方法,而是搞清楚你的被测代码到底依赖了哪些外部边界。
1.2 "依赖"具体指什么:ABAP 里最常见的四类边界
在 ABAP 后端开发里,单元测试最常见的不可控依赖有这么几类:
- 数据库表与视图:最常见,任何
OPEN SQL都会碰到。真实表里有什么数据是不可预期的。 - Function Module:传统 ABAP 里大量逻辑通过 FM 暴露,调用外部系统、查主数据、做业务校验,全都有可能。
- 接口(Interface)调用:如果代码通过一个接口引用远端服务、HTTP 调用或 RFC 对象,测试时你并不想真的发起一次远程调用。
- CDS View:随着 CDS 建模普及,越来越多的读逻辑跑到 CDS 视图上,测试时同样需要隔离开。
这四类依赖有一个共同特征:它们的"真实实现"都连接着测试环境之外的东西。Test Double 要做的事情就是,把测试范围内的调用切到一套内存里的、行为可控的替身上去。替身不连数据库、不发远程请求、不依赖别人维护的数据,只返回你在这个用例里预先设定的内容。
1.3 给测试立个硬标准:不接外部资源也能跑
我把这个标准定为团队里写 ABAP 单元测试的底线:一段 ABAP 单元测试,必须在隔离环境里跑通,不依赖开发顾问手头的业务数据,不依赖目标系统在线,更不能被别的测试用例影响。如果一段测试没法做到这一点,那它就不应该被放进自动化回归里,因为它给出的红绿信号没有意义。
要做到这个标准,就需要给每一类外部依赖准备对应的替身。接口替身靠本地类实现,Function Module 靠 TEST-SEAM 做代码替换,表和 CDS View 靠 ABAP 官方提供的测试环境。下面一部分先讲清楚替身的分类学,否则很多人会混着用 Stub 和 Mock,最后写出来的测试既不像替身、也不像集成测试。
2. 测试替身的四类角色:先搞清楚你在造哪种替身再动手
2.1 Dummy、Fake、Stub、Mock 到底怎么分
Martin Fowler 对测试替身的分类到今天仍然适用,ABAP 项目里也完全可以对应上:
- Dummy:只是一个占位对象,传进去是为了满足参数列表,实际不会被调用。比如被测类的构造器必须要一个依赖对象,而这个用例里根本走不到它,就可以传一个空实现。
- Fake:一个简化但可用的实现,内部逻辑可能比真实实现简单得多,但功能路径是通的。比如把数据库访问换成内存里的一个内表,业务代码照常查询、照常聚合,只是数据源不一样。
- Stub:只负责返回固定结果,不关心后续逻辑。被测代码需要调用某个方法拿一个价格,你就让替身方法固定返回 100,其他什么都不做。
- Mock:不仅提供数据,还负责验证"某个方法确实被调用了、调用参数是什么、调了几次"。这是行为验证型替身。
很多人写 ABAP 测试时会混淆 Stub 和 Mock。实际上,绝大多数场景你用 Stub 就够了——被测类关心的是返回值,不是"谁调用了它"。只有当你想验证"这个流程必须把 A 结果传给 B 服务",或者"异常情况下某个清理方法必须被调用"时,Mock 才真正有用。
2.2 按依赖类型做选型:表选 Fake,接口选 Stub/Mock
放到 ABAP 具体场景里,我的选型表很固定:
| 依赖类型 | 推荐替身方式 | 替身分类 | 理由 |
|---|---|---|---|
| 接口 / 类引用 | 测试局部类实现同一接口,注入被测类 | Stub / Fake / Mock | 接口本身就是 ABAP 天然的多态点,替身最容易做 |
| Function Module | TEST-SEAM / TEST-INJECTION | Stub | FM 没有多态,只能从调用处下手做代码替换 |
| 数据库表 | CL_OSQL_TEST_ENVIRONMENT | Fake | 内存内表完全替代数据库表,行为最接近真实 |
| CDS View | CL_OSQL_TEST_ENVIRONMENT | Fake | 官方测试环境直接支持视图,成本最低 |
并不是说表不能用 Stub。你也可以把SELECT封装到类里,再用接口注入一个返回内表的替身。但在一个老旧的 ABAP 代码库里,到处去包一层成本太高,直接用CL_OSQL_TEST_ENVIRONMENT把表和视图的访问在底层切掉,能省非常多事。
2.3 两个容易走的弯路:过度替身与替身里塞逻辑
第一个弯路是给所有东西都做替身,连一个简单的字符串拼接工具类也要先定义接口再写替身。没必要。替身解决的是"外部不可控依赖",纯内部计算、纯字符串处理、只依赖入参的逻辑,直接测原方法就行。
第二个弯路更隐蔽:在替身里复刻了一套业务逻辑。比如你测"订单总额=单价×数量",然后在替身里也写了个"如果产品类型是 X,就给 9 折",测试断言里再写一遍同样的折扣。结果就是三层重复:生产代码、替身、断言。哪天业务改成 8 折,你只改生产代码,替身和断言没跟着变,测试照样过,但它过的那个"业务"已经不是真实业务了。替身要遵循一个原则:替身只负责提供可控的外部输入,不负责实现被测代码要验证的业务规则。
3. 接口依赖的注入式替身:构造器、setter、调用记录全流程
3.1 把"直接创建"改成"注入":替身的前提是依赖松耦合
接口替身能不能做,前提是被测类没有在自己的方法里直接NEW一个真实依赖对象。如果你写的是:
METHOD calc_total. DATA(lo_provider) = NEW zcl_price_provider_http( ). rv_total = lo_provider->get_unit_price( iv_product ) * iv_qty. ENDMETHOD.那这个类就和zcl_price_provider_http绑死了,测试里想换成假实现根本没地方下手。ABAP 虽然是老语言,但依赖注入这件事并不复杂,就是在构造时或 setter 方法里把依赖对象传进来:
CLASS zcl_order_price DEFINITION. PUBLIC SECTION. METHODS constructor IMPORTING io_provider TYPE REF TO zif_price_provider. METHODS calc_total IMPORTING iv_product TYPE string iv_qty TYPE i RETURNING VALUE(rv_total) TYPE i. PRIVATE SECTION. DATA mo_provider TYPE REF TO zif_price_provider. ENDCLASS.建议优先用构造器注入。它把依赖关系显式表达出来:这个类没有 provider 就干不了活。如果某个依赖是可选的,或者可能被业务动态替换,才考虑 setter 注入。
3.2 一个接口替身的完整可运行示例
我们先定义一个价格服务接口,生产实现走 HTTP,测试替身完全在本地:
INTERFACE zif_price_provider. METHODS get_unit_price IMPORTING iv_product TYPE string RETURNING VALUE(rv_price) TYPE i. ENDINTERFACE.被测类里只依赖这个接口,测试时可以注入一个替身。测试 include 里定义一个局部替身类:
CLASS lcl_stub_price_provider DEFINITION. PUBLIC SECTION. INTERFACES zif_price_provider. ENDCLASS. CLASS lcl_stub_price_provider IMPLEMENTATION. METHOD zif_price_provider~get_unit_price. rv_price = 100. ENDMETHOD. ENDCLASS.然后写测试类:
CLASS ltcl_order_price DEFINITION FOR TESTING. PRIVATE SECTION. METHODS calc_total FOR TESTING. ENDCLASS. CLASS ltcl_order_price IMPLEMENTATION. METHOD calc_total. DATA(lo_cut) = NEW zcl_order_price( NEW lcl_stub_price_provider( ) ). cl_abap_unit_assert=>assert_equals( exp = 100 act = lo_cut->calc_total( iv_product = 'P001' iv_qty = 1 ) ). ENDMETHOD. ENDCLASS.这个替身只干了一件事:不管传入什么产品,都返回固定单价 100。测试就能准确验证被测类calc_total的乘法逻辑是否成立。如果后续要测试"产品不存在时返回 0"或者"库存不足抛异常",只需要让替身按传入参数返回不同结果,被测类本身不用改。
3.3 调用记录模式:让替身变成有验证能力的 Mock
有时候光有返回值不够。比如被测逻辑是"先查主数据,再调价格服务,最后把两个结果合并后落库"。你要验证的是"价格服务真的被调用了两次,且第二次传入的参数是上一次的累计值"。这种情况下就需要一个带调用记录的 Mock。
ABAP 官方没有单独的 Mock 断言工具,最简单的方式是在替身里加一个计数器:
CLASS lcl_mock_price_provider DEFINITION. PUBLIC SECTION. INTERFACES zif_price_provider. DATA mv_call_count TYPE i. ENDCLASS. CLASS lcl_mock_price_provider IMPLEMENTATION. METHOD zif_price_provider~get_unit_price. mv_call_count = mv_call_count + 1. CASE iv_product. WHEN 'P001'. rv_price = 100. WHEN 'P002'. rv_price = 200. ENDCASE. ENDMETHOD. ENDCLASS.测试方法里先注入 mock,执行被测逻辑后断言调用次数和参数:
cl_abap_unit_assert=>assert_equals( exp = 2 act = lo_mock->mv_call_count ).更进一步,想断言"是否用 'P001' 调用过",可以在替身里存一个内表mt_invoked_products,每次调用追加一个产品编码,测试里用assert_contains检查。这套手写模式解决绝大多数 ABAP 里的 Mock 需求,完全不需要额外框架。
3.4 在 ADT 里运行和调试这类测试
在 ADT 中,右键被测类名,选择 New → ABAP Test Class,会自动生成一个测试 include 骨架。把上面的替身类写在测试类的DEFINITION之前,测试类标记FOR TESTING,然后保存激活。运行测试只需要在 Project Explorer 里选中测试类或测试类里的具体测试方法,右键 Run As → ABAP Unit Test。结果视图会列出绿勾和红叉,双击失败的测试方法可以直接跳到断言那一行。
我强烈建议在测试方法上不要只靠系统自动生成的FOR TESTING,而是给每个测试方法起一个能说明场景的名字。ADT 的测试结果列表会把方法名完整显示出来,命名越准确,日后排查越省时间。
4. Function Module 的替身方案:TEST-SEAM 与 FM Wrapper 怎么选
4.1 Function Module 为什么不能像接口一样直接替换
Function Module 和接口最大的区别是:FM 是按名称调用的全局函数,没有多态。你不能写一个"假的"Z_FM_GET_TAX_RATE在测试时替换掉原来的 FM。哪怕你用同样的名字重新 SE80 创建一个,系统里也不允许重复的函数名。所以想让 FM 调用变得可替换,只能从"调用处"下手。
ABAP 官方为这个场景留了一个专门语法:TEST-SEAM。简单说,就是在生产代码里给一段代码块起个名字,然后在单元测试代码里用同名的TEST-INJECTION把这段代码临时替换掉。测试运行时,被替换掉的那段真实调用不会执行,取而代之的是你在注入块里写的内容。
4.2 TEST-SEAM / TEST-INJECTION 改造详解
看一个例子。被测方法里原本直接调用一个税率 Function Module:
CLASS zcl_tax_calc DEFINITION. PUBLIC SECTION. METHODS get_tax_rate IMPORTING iv_country TYPE land1 RETURNING VALUE(rv_rate) TYPE decfloat. ENDCLASS. CLASS zcl_tax_calc IMPLEMENTATION. METHOD get_tax_rate. DATA(lv_rate) TYPE decfloat. TEST-SEAM read_tax_fm. CALL FUNCTION 'Z_FM_GET_TAX_RATE' EXPORTING iv_country = iv_country IMPORTING ev_rate = lv_rate. END-TEST-SEAM. rv_rate = lv_rate. ENDMETHOD. ENDCLASS.注意几个细节:TEST-SEAM块只包裹 FM 调用本身,后续对lv_rate的处理放在 seam 外;lv_rate声明在 seam 外,这样注入块里可以给这个变量赋值。
测试类里这样写:
CLASS ltcl_tax_calc DEFINITION FOR TESTING. PRIVATE SECTION. METHODS get_rate FOR TESTING. ENDCLASS. CLASS ltcl_tax_calc IMPLEMENTATION. METHOD get_rate. DATA(lo_cut) = NEW zcl_tax_calc( ). TEST-INJECTION read_tax_fm. lv_rate = 19. END-TEST-INJECTION. cl_abap_unit_assert=>assert_equals( exp = 19 act = lo_cut->get_tax_rate( 'DE' ) ). ENDMETHOD. ENDCLASS.测试执行时,get_tax_rate方法里的CALL FUNCTION被整体换成lv_rate = 19,FM 不会真的被调用。这种方式比包一层类更直白,而且不需要改方法签名,老代码也能快速加上替身。
关于 TEST-SEAM,有几条实操经验值得记住:
TEST-SEAM名字在同一个类里不能重复,命名时最好带上被替换对象的语义,比如read_tax_fm、fetch_customer。TEST-INJECTION块内可以访问被替换方法作用域内的局部变量,这正是它能给lv_rate赋值的原因。静态检查有时候会误报变量不可见,但只要测试方法里注入块的位置符合语法,运行通常没问题。- 别把大段业务逻辑塞进
TEST-SEAM。这段代码是给测试留的"活口",包裹范围越小越安全。理想状态是最多包一个 FM 调用或一个数据读取,不要让 seam 里出现复杂的 if/loop。
4.3 不想在生产代码加后门:FM Wrapper 方案
有的团队比较忌讳在生产代码里留TEST-SEAM,觉得这是"为了测试而开后门"。这时候可以用接口包一层,也就是 FM Wrapper 方案。
做法很直观:定义一个接口,接口方法封装 FM 调用;被测类只依赖接口;测试时注入接口的替身实现。
INTERFACE zif_tax_provider. METHODS get_rate IMPORTING iv_country TYPE land1 RETURNING VALUE(rv_rate) TYPE decfloat. ENDINTERFACE. CLASS zcl_tax_provider_fm DEFINITION. PUBLIC SECTION. INTERFACES zif_tax_provider. ENDCLASS. CLASS zcl_tax_provider_fm IMPLEMENTATION. METHOD zif_tax_provider~get_rate. CALL FUNCTION 'Z_FM_GET_TAX_RATE' EXPORTING iv_country = iv_country IMPORTING ev_rate = rv_rate. ENDMETHOD. ENDCLASS.被测类通过构造器接收zif_tax_provider,测试里注入一个本地假类。这个方案的好处是生产代码完全不留测试痕迹,依赖关系也更符合常规设计;坏处是每个 FM 都要在真实实现类里加一层转发,代码量会增加一点。
我的习惯是:如果是在已有老代码里快速补测试,用 TEST-SEAM;如果是一个新开发的模块、或者你本来就在做接口化改造,用 FM Wrapper。两种方案并不互斥,同一个类里完全可以混用。
5. 数据库表和 CDS View 的替身:CL_OSQL_TEST_ENVIRONMENT 实战
5.1 一个入口搞定表和视图:CL_OSQL_TEST_ENVIRONMENT 的基本用法
在 ABAP 里给数据库表和 CDS View 做替身,最省事的工具是系统类CL_OSQL_TEST_ENVIRONMENT。它会在单元测试会话里创建一个"内存版"的数据源,Open SQL 查这些表或视图时,读到的不是真实数据库,而是你通过insert方法塞进去的内存数据。
基本使用套路固定得很:
DATA(lo_env) = cl_osql_test_environment=>create( i_dependency_list = VALUE #( ( 'ZSALES_ORDER' ) ( 'ZI_SALES_ORDER' ) ) ). lo_env->clear_doubles( ). lo_env->insert( it_test_data ).i_dependency_list是需要替身的对象列表,可以是透明表名,也可以是 CDS View 名。clear_doubles清掉上一次用例留下的数据,保证每个测试方法从干净状态开始。insert把测试记录插进内存数据源。
5.2 数据库表替身示例:读取、汇总、断言一条龙
假设被测类有一个方法,根据客户编号汇总订单金额:
METHOD get_customer_total. SELECT SUM( amount ) FROM zsales_order WHERE customer = @iv_customer INTO @rv_total. ENDMETHOD.测试类里这样建环境和数据:
CLASS ltcl_sales_test DEFINITION FOR TESTING. PRIVATE SECTION. DATA mo_env TYPE REF TO if_osql_test_environment. METHODS setup. METHODS teardown. METHODS get_total FOR TESTING. ENDCLASS. CLASS ltcl_sales_test IMPLEMENTATION. METHOD setup. mo_env = cl_osql_test_environment=>create( i_dependency_list = VALUE #( ( 'ZSALES_ORDER' ) ) ). mo_env->clear_doubles( ). mo_env->insert( VALUE #( ( mandt = sy-mandt order_id = '0001' customer = 'C001' amount = 100 ) ( mandt = sy-mandt order_id = '0002' customer = 'C001' amount = 150 ) ( mandt = sy-mandt order_id = '0003' customer = 'C002' amount = 80 ) ) ). ENDMETHOD. METHOD teardown. mo_env->clear_doubles( ). ENDMETHOD. METHOD get_total. DATA(lo_cut) = NEW zcl_sales_order_report( ). cl_abap_unit_assert=>assert_equals( exp = 250 act = lo_cut->get_customer_total( 'C001' ) ). ENDMETHOD. ENDCLASS.这个测试完全不依赖开发系统里那张ZSALES_ORDER表里有什么脏数据。测试自己构造了两条 C001 的订单和一条 C002 的订单,汇总 C001 一定是 250,任何人跑都是这个结果。
有一点要提醒:insert的数据建议显式带上mandt字段。有些版本会自动处理客户端,但显式写sy-mandt能避免不少玄学问题。
5.3 CDS View 替身示例:让查询走内存数据
CDS View 的替身方式和数据库表几乎一样。假设有一个 CDS 视图ZI_SALES_ORDER,里面暴露了客户、订单号、金额等字段。被测代码直接对它做SELECT:
SELECT amount FROM zi_sales_order WHERE customer = @iv_customer INTO TABLE @DATA(lt_amount).测试环境里把这个 view 名称加进依赖列表,然后按视图字段插入数据。插入的时候需要按照 CDS 视图暴露的字段结构来构造内表,测试环境和真实 Open SQL 一样能直接读到。
METHOD setup. mo_env = cl_osql_test_environment=>create( i_dependency_list = VALUE #( ( 'ZI_SALES_ORDER' ) ) ). mo_env->clear_doubles( ). mo_env->insert( VALUE #( ( mandt = sy-mandt customer = 'C001' amount = 300 ) ) ). ENDMETHOD.这里面最常见的坑是:测试代码里SELECT用的是 CDS View,但i_dependency_list里只加了底层透明表,没加 view。这样查 CDS View 的语句仍然会打到真实数据,因为测试环境只替身了底层表。反过来,如果你只替身了 view,被测代码里个别用底层表的FOR ALL ENTRIES又绕过了 view,也会漏。所以依赖列表的完整程度,直接决定隔离效果。
复杂 CDS 建模(比如带聚合、union、关联)在替身环境下不一定完全支持。我的经验是:先按 view 名直接试,如果单元测试跑出来数据不对或报错,再退回"只替身底层表"的策略,让 CDS View 的语义基于这些替身表重新计算。多花一点时间验证,不要想当然。
5.4 替身环境的边界问题:外键、FOR ALL ENTRIES、复杂视图
用CL_OSQL_TEST_ENVIRONMENT时有几个边界一定要知道,否则排查问题时会很痛苦。
第一,替身环境不校验外键。你可以插入在真实数据库里违反外键约束的数据,因为内存数据源只是"看起来像表",并不会真的触发数据库约束。这反而是好事,测试时可以少造一堆关联主数据;但也要注意别因此营造出生产环境不存在的状态。
第二,FOR ALL ENTRIES需要依赖表在列表里,且驱动表的数据要预先插入。这里有个隐藏坑:如果驱动表的查询结果为空(比如传了一个空的内表),Open SQL 实际上是退化成不执行的。这种时序问题在替身里同样存在,遇到"代码逻辑没问题但结果为空"的测试,先检查是不是驱动内表为空。
第三,复杂 CDS View 受限于内存引擎。CDS 里如果用了数据库特定函数、union 复杂分支、或者依赖某些只在 HANA 上才有的语义,替身环境可能报"not supported"之类的错误。这时候没有捷径,只能在"替身底层表"和"把这个方法拆出来用接口替身"之间选一个。
6. 把这些做法沉淀成团队规范:测试可维护性经验
6.1 测试方法命名的信息量从哪来
写完一堆测试后你会发现,测试代码的维护成本大部分花在"看懂这个测试在测什么"上。我要求团队里的测试方法命名遵循一个固定句式:被测方法_场景_期望结果。
比如:
get_total_when_customer_exists_returns_250get_tax_rate_when_country_is_de_returns_19calc_total_when_qty_is_zero_returns_zero
ADT 的测试结果显示这些方法名时,任何人一眼就能定位问题。比test1、test2这种名字强太多。
6.2 替身数据的三个原则:最小、明确、可读
构造替身数据时,遵循三个原则能让测试稳定性和可读性同时提升:
第一,最小化。每个测试方法只插入当前用例需要的数据,不要图省事把"基础主数据"统统塞进 setup。数据越多,测试之间的隐性耦合越多,排查越难。
第二,明确化。测试数据要能直接反映场景语义。比如测试"空价格返回 0",替身里插入的价格字段最好就是一个醒目的 0,而不是一个从配置文件里读出来的值。让数据自己讲故事。
第三,可读化。插入内表时字段名写全,不要只写字面量不留字段标签。代码块里看( '0001' 'C001' 100 )和看( order_id = '0001' customer = 'C001' amount = 100 ),体验完全不同。维护成本高不高,往往在这些细节里。
6.3 把测试跑进 CI:质量门禁与反馈速度
如果你们团队还没有把 ABAP 单元测试接进构建流程,那前面做的所有替身工作都会慢慢衰减。人都有惰性,本地跑测试靠自觉,总有人跳过;CI 里跑测试靠机制,不绿就进不了下一环节。
在 ADT 里做单测只是第一步,建议把测试执行放到每晚的构建作业里,或者在传输请求 release 时挂一道测试检查。反馈速度也很重要,测试执行得越快,开发者越愿意在提交前跑一遍。这也是为什么要替身的一个现实理由:一个不碰数据库、不调外部 FM 的测试,几百个用例几秒钟就能跑完,而真连数据库的"伪单测"可能十分钟还在等锁释放。
把测试跑进 CI 之后,你会看到替身带来的稳定性优势被放大:测试结果不再抖动,回归成本直线下降,ABAP 单元测试才真正成为"能挡住问题"的质量门禁,而不是每次发版前还要人肉判断"这个红是不是环境问题"。
就个人体会来说,做 ABAP 单元测试最难的不是学各种替身语法,而是说服自己:每个外部依赖都值得被认真隔离。刚开始给老代码补替身时,我也总想偷懒,让测试直接读真表,结果三天两头被脏数据打脸。后来把接口、FM、表、CDS View 这四类方式固化成了模板,团队新人照着写,测试稳定率明显提升。如果你正在被不稳定的 ABAP 单元测试困扰,从那个最常被外部依赖拖累的类开始,先套一个替身跑通,再慢慢铺开。别急着把所有依赖一次替换干净,替身这东西,替一个,稳一个。