
如果你在 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 ModuleTEST-SEAM / TEST-INJECTIONStubFM 没有多态只能从调用处下手做代码替换数据库表CL_OSQL_TEST_ENVIRONMENTFake内存内表完全替代数据库表行为最接近真实CDS ViewCL_OSQL_TEST_ENVIRONMENTFake官方测试环境直接支持视图成本最低并不是说表不能用 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_assertassert_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_assertassert_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 ModuleCLASS 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_assertassert_equals( exp 19 act lo_cut-get_tax_rate( DE ) ). ENDMETHOD. ENDCLASS.测试执行时get_tax_rate方法里的CALL FUNCTION被整体换成lv_rate 19FM 不会真的被调用。这种方式比包一层类更直白而且不需要改方法签名老代码也能快速加上替身。关于 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_environmentcreate( 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_environmentcreate( 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_assertassert_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里面暴露了客户、订单号、金额等字段。被测代码直接对它做SELECTSELECT 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_environmentcreate( 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_zeroADT 的测试结果显示这些方法名时任何人一眼就能定位问题。比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 单元测试困扰从那个最常被外部依赖拖累的类开始先套一个替身跑通再慢慢铺开。别急着把所有依赖一次替换干净替身这东西替一个稳一个。