B站移动端低代码测试探索与实践
本期作者
沈柯
哔哩哔哩资深开发工程师
2021年加入B站,现为主站质量保障部社区/基础架构业务测试负责人,专注于客户端自动化、专项测试、持续集成等相关领域。
01 背景
移动互联网作为一个跨度十年有余的行业,业内的技术生态已经基本上定型。在经历过各大公司技术创新跟实践后,移动端的质量保障方案逐渐演变为几个大的方向,第一个方向是将测试能力右移,通过灰度、容灾、监控建立质量保障方案,如 Crash 告警、舆情监控、问题排查、业务全链路问题定位等,基于线上规模化效应,快速发现线上问题,再通过配置、热更新、动态化等技术手段修复线上问题。另外一个方向是将测试能力左移,建设一些列的线下保障手段,如测试流程规范化、UI 自动化、Monkey、性能专项测试工具链等,在版本发布前尽可能发现多的缺陷,通过组织用例评审、用例执行、专项测试等环节确保交付版本的功能正确性。还会有一些团队基于特定的需求做定向技术优化,如包大小管控、安装包重排、二进制压缩等。无论是技术的广度还是深度,业内的移动端技术体系已经比较成熟,但在代码测试这块一直缺少通用性的测试框架。
1.1 代码测试挑战
经历过多年的业务、技术迭代,行业内 TOP 的移动端应用代码量跟业务架构复杂度达到了新的高度,并且移动端应用属于重 UI 的产品,使得 UI 渲染代码跟业务逻辑代码高度耦合,测试同学版本迭代过程中会经常遇到代码可测性的难题,比如基础 SDK 、组件层面的代码改动,测试同学只能从黑盒功能层面进行验证,SDK 内部改动逻辑往往无法被充分测试。业内移动端白盒测试的技术分享也鲜有产出,导致移动端的白盒测试在大部分公司是空白。白盒测试作为软件工程中常用的技术手段,在移动端的研发体系内一直没有大范围的使用起来,主要原因在于高昂的执行成本以及技术门槛。在这个大背景下,移动端的白盒测试一直被视作正确但困难的事情,白盒测试跟研发流程结合起来困难重重。
1.2 代码测试技术方案
为了提升移动端的白盒测试能力,丰富测试保障的维度,需要设计一套技术方案来降低白盒测试的成本和门槛。通过对服务端代码测试保障方案的调研,可以尝试通过 AOP 方案解决移动端部分代码可测性问题,将预先编写的各类代码套件注入到被测试程序以增加额外的能力,实现专项测试的需求,如抓取函数内部运行时信息用于验证业务逻辑、注入性能检测代码监控关键链路耗时或者注入故障代码校验代码健壮性等,见下图:
本文旨在探索与介绍如何通过“流量录制”、“流量回放”代码套件降低白盒测试成本,持续提升移动端代码可测性。
02 技术目标
设计“流量录制”、“流量回放”代码套件,通过 AOP 能力将代码注入到被测试应用;基于注入的套件代码对函数状态进行拦截、修改、存储,结合各类断言能力持续管控业务代码逻辑正确性、可测性。
对比服务端,移动端应用并不存在“流量录制”或者“流量回放”的概念,只是为了方便相关同学快速理解方案的实现思路,才使用了“流量录制”、“流量回放”的术语,方案本质上是基于 AOP 方案获取代码运行时信息并通过序列化/反序列化技术手段将函数的执行过程进行录制/回放。
03 代码可测度量
为了能持续提升代码质量以及可测性,需要建立一套度量标准指导技术团队做代码优化。从定量角度分析,可将源码的实例构造难度、函数入参构造难度、函数可访问性、全局变量数量等细颗粒维度数据纳入计算模型,产出量化可测性指标。再结合业内移动端应用的常用代码架构做定性分析,对代码做一些分类,拆分类型可以参考一下架构图:
这三类的代码的特征如下表格所示:
基于以上分析,针对不同的代码,需要制定不同的序列化/回放策略,越贴近 UI 层的代码,函数内部信息越难抓取,可测性也越差。
04 AOP 方案
服务端的流量录制回放方案主要有两类,第一类是基于 HTTP 请求的录制跟回放,通过保存 HTTP 接口请求结果进行二次 diff 对比。第二类是代码级别的录制跟回放,比如阿里开源的 jvm-sandbox-repeater , 可以无侵入式录制 HTTP/Java/Dubbo入参/返回值录制能力,支持热插拔代码,对于录制结果可上报到服务端,进行监控、回归、问题排查等平台能力。对于移动端而言,第一类方案不具备可移植性,端上并不存在类似于 HTTP 接口的概念,核心功能无法抽象成一组对外暴露的接口,所以可以参考的方案只有第二类,即通过 AOP 技术拦截函数内部的执行信息进行逻辑校验。为了实现代码函数级别的信息截取,首先需要调研移动端的代码 AOP 方案,主要分为静态 AOP 跟动态 AOP 两种方式。
4.1 Android 字节码增强
Android 端 AOP 方案有很多方式,从注入的时机可分为源码阶段、class 阶段、dex 阶段、运行时阶段,前三者统一称为静态注入,运行时阶段称为动态注入。静态代码注入的常用方式包括,apk 反编译 + smali 注入,或者则可以通过修改打包脚本,在编译过程中注入字节码,静态注入的方案更加灵活,但是效率偏低,class 修改后需要重新打包。动态注入的前提是类还没有被加载,即无法将已经加载过的类进行修改后再加载,所以基本上可以放弃通过动态修改字节码的方案来实现 AOP;另外也可以通过动态代理、反射、xposed 等技术实现改变代码运行逻辑,但是每个方案的使用场景会有一定限制。
4.2 iOS Clang 编译插件
iOS 端 AOP 方案跟 Android 类似,可以在源码阶段、编译阶段、链接阶段、运行时阶段实现 AOP 功能。在源码阶段,可以通过编译框架的插件机制实现代码注入,比如编写 LLVM Pass 实现 IR 指令插桩,或者编写 Clang 插件遍历 AST(抽象语法树),在关键节点插入代码,最终通过 Xcode 编译生成可执行文件。相比于 Android,iOS 端的动态注入能力要丰富许多,可以通过 Method Swizzling、Message Forwarding、JSPatch、Frida 等方案动态 hook 拦截函数的输入、输出,不过大部分的动态注入方案主要是做逆向分析使用,若要实现工程化录制,有一定的改造成本。考虑到双端方案的一致性、可拓展性以及兼容性,对比动态注入方案,静态代码注入方案更加可控、灵活,所以最终采用静态代码注入实现 AOP 功能,代码插桩示意流程如下:
05 “流量”录制
代码流量录制核心在于把函数入参、返回值通过序列化方案保存下来。对比服务端,移动端序列化遇到的技术难点更多,首先是移动端属于重 UI 的程序,大部分业务逻辑跟 UI 布局代码错综复杂的穿插在一起,代码逻辑之间没有清晰的边界,无法有效抽离出可测的逻辑单元。第二个难点在于移动端的函数入参、返回值往往是一个复杂度很高的对象,除了保存常规的字符串、数值、布尔类型的参数,还需要处理一些大的复杂对象,如 Android 系统创建的 Context、Activity、View、Bundle, iOS 系统创建的 UIApplication、UIView、UISceneSession,或者其他业务自定义的复杂类,具体参考 Android 开源项目的示例代码:
序列化难点
对于这些复杂类的进行序列化会出现一系列不可预知的稳定性问题,比如对象的循环引用会导致序列化堆栈溢出。另外代码内部很多的逻辑是基于异步回调的方式进行编写,使得程序的状态难以捕捉。最后一个难点在于录制生成的数据内容需要可阅读、可理解、可编辑,若录制生成的数据格式可读性很差,会大大增加测试数据的维护成本,也不便于相关同学通过数据分析函数内部逻辑,所以若通过 Java 的 java.io.Serializable 或者 iOS 的 NSKeyedArchiver 实现“流量”的录制功能并不能完全满足测试需求。
“流量”录制问题的核心思路是将内存中对象转换为可存储的字符串,业内比较通用的解决方案就是通过 JSON 转换库将实例对象进行序列化/反序列化操作。Android 端常用的 JSON 序列化框架包括 fastjson、gson 等,iOS 端也可以通过类似的框架将对象转换成 JSON 字符串,通过这一步操作,可以将实例对象内的部分属性以 JSON 字符串形式进行传输、存储。对于复杂的系统对象,框架层面会按需将其进行缓存或者在执行过程中创建 Mock 对象,避免序列化产生过于复杂的结构化数据。
06 “流量”回放
“流量”回放的核心功能是通过解析“流量”录制保存的数据进行验证,验证方式主要有两类:第一类是通过代码回放框架进行验证,将录制的数据进行解析并还原代码运行场景,再结合函数的基线入参数据调用被测试函数,检查最新代码的函数返回值是否跟基线返回值保持一致;第二类是通过编写静态校验规则进行验证,通过预置规则校验函数执行的结果是否符合预期,两者的执行过程如下图所示:
实例信息丢失
代码运行回放的技术难点在于如何正确的实例化测试类,同时还要构建出正确的函数入参值。例如,构造函数的入参会包含复杂类,那么需要先进行第一层的参数值实例化,若当前层的参数值实例化又依赖其他复杂类,那么在解析过程中需要通过递归的方式创建第二层的对象。以此类推,若要保存满足此类场景的运行时数据,需要存储异常庞杂的 JSON 数据,但若不全量存储所有对象信息,则大概率创建出信息残缺的实例对象,导致函数内部逻辑执行失败。因此在生成流量协议过程中,既要避免存储无效数据,又要尽可能保全有用的信息,框架层面需要平衡好这两个问题,否则逻辑校验的置信度会大打折扣。
07 方案小结
为了实现移动端的“流量”录制/回放功能,需要攻克的技术难点比较多,不仅需要设计稳定、通用的 AOP 方案,同时需要考虑序列化/反序列化对象的信息保全问题,最后还需要实现基于配置动态创建对象的能力,每个环节在实现过程中都会遇到很多坑,后面章节会重点介绍一下在实现 AOP 方案过程中遇到的一些问题。
08 难点突破
8.1 代码插桩
Android 端可以通过通过 Gradle 打包插件 + ASM 框架实现字节码增强,整体的代码插桩插件开发、调试成本比较低,基本上可以在较短时间内完成开发工作,iOS 端的实现代码插桩的成本比 Android 高很多。Xcode 的编译环境非常封闭,IDE 内部并没有类似于 Gradle 打包插件的机制,开发者几乎无法实现编译期间静态代码插桩的能力,为了实现类似 Java 字节码增强的效果,调研了一下 Xcode 的编译流程。Xcode 编译过程依赖 LLVM 编译框架,框架采用了传统的 Three-Phase Compiler 设计,这种设计最大的优势就是解耦,让每部分专注于自己的功能。若所有的语言都使用了同一种中间指令表示,那么意味着可以使用相同的Optimizer,这就大大提高了代码重复利用率以及 Optimizer 优化。Three phase design 编译流程如下图所示:
Frontend 解析源代码,检查错误,并且把它翻译为特定语言语法抽象树
Optimizer 优化代码,比如去除无用的变量或者无用的计算,来提高代码运行效率
Backend 把优化后的代码转换为目标机器码(target instruction set),目标是生成充分可以利用目标机器体系结构的 native code
8.2 LLVM 编译流程
LLVM 项目包含了一系列的模块化,可复用的编译器和工具链,核心模块如下:
Clang/Gollvm/rustc 编译器(Frontend)
预处理阶段:处理宏,头文件导入等
编译阶段将:进行此法分析、语法分析、检测语法是否正确,最终将源代码编译成中间代码 LLVM InterMediate Representation(LLVM IR)
LLVM 核心优化器(Optimizer):负责进行 IR 指令优化,改善代码运行时间,例如消除冗余计算等
LLVM 静态编译器 (Backend):将 IR 指令映射到目标指令集,生成机器语言,针对机器硬件进行相关代码优化
LLVM 编译流程如下图所示:
基于 LLVM 编译流程跟插件化能力,可以在预处理阶段或者 IR 优化阶段插入测试代码:
IR 优化阶段:指令优化阶段,可以通过编写 LLVM Passes 插件实现代码插桩,Pass 是一种编译器开发的结构化技术,用于完成编译对象(如 IR )的转换、分析或优化等功能,具体详见:LLVM;从实际使用效果看,源码转成 IR 再插桩会丢失一部分源码信息,比如参数名、参数类型等(是否能获取有待进一步确认,不过在调研阶段暂时没发现可用 API),若插桩产物有稳定性问题,代码调试成本会比较高。
预处理阶段:预处理阶段可以操作的空间更多一些,基于 Clang 插件可以在源码指定位置插入代码,插入代码跟源码语法一致,可阅读性高,代码调试成本低,从代码维护成本考虑,推荐使用该方案。
8.3 Clang 插件开发
Clang 插件开发成本也比较高,在调试过程中会遇到一系列奇怪的问题,包括 AST 语法树信息丢失、枚举类型无法识别、依赖头文件补全等等,定位问题过程中相关资料也比较匮乏,只能参考官方文档进行调试。同时为了开发自定义插件,需要配置繁杂的 LLVM 开发环境,文档内用的是 llvm 13.x 版本的的代码,项目地址:https://github.com/llvm/llvm-project,按照项目文档配置开发环境,具体配置过程中不再赘述。在配置完成后,可以在项目 llvm/tools/clang/examples 目录内看到 Clang 插件案例代码,部分代码如下(代码已简化),这部分代码的作用是打印函数名称:
using namespace clang;
namespace {
class PrintFunctionsConsumer : public ASTConsumer {
bool HandleTopLevelDecl(DeclGroupRef DG) override {
for (DeclGroupRef::iterator i = DG.begin(), e = DG.end(); i != e; ++i) {
const Decl *D = *i;
if (const NamedDecl *ND = dyn_cast<NamedDecl>(D))
llvm::errs() << "top-level-decl: \"" << ND->getNameAsString() << "\"\n";
}
return true;
}
};
class PrintFunctionNamesAction : public PluginASTAction {
};
}
static FrontendPluginRegistry::Add<PrintFunctionNamesAction>
X("print-fns", "print function names");
插件开发完毕后,先将插件代码编译为 dylib 库,再通过命令行参数让 Clang 加载插件并进行代码预处理:
clang -Xclang -load -Xclang libBiliInstrument.dylib -Xclang -plugin -Xclang bili-instrument -c DemoClass.m
通过预处理阶段的节点回调功能,即可在 AST 语法树指定的节点统一插入代码,比如原始代码 Demo.m 内容如下:
#import <Foundation/Foundation.h>
typedef NSString* (^CompletionBlock)(NSString *);
@interface Demo : NSObject
- (void)performActionWithCompletion:(CompletionBlock)completionBlock;
@end
@implementation Demo
- (void)performActionWithCompletion:(CompletionBlock)completionBlock {
NSString* blockResult = completionBlock(@"bilibili");
NSLog(@"Action Performed, %@ \n", blockResult);
}
@end
int main() {
Demo *sampleClass = [[Demo alloc] init];
[sampleClass performActionWithCompletion:^(NSString *name) {
NSLog(@"Completion is called to intimate action is performed, %@", name);
return @"block result from main";
}];
return 0;
}
插桩后代码效果如下:
#import <Foundation/Foundation.h>
typedef NSString * (^CompletionBlock)(NSString *);
@interface Demo : NSObject
- (void)performActionWithCompletion:(CompletionBlock)completionBlock;
@end
@implementation Demo
- (void)performActionWithCompletion:(CompletionBlock)completionBlock {
MethodTraceBegin("simple.m", "performActionWithCompletion:", completionBlock);
NSString *blockResult = completionBlock(@"Neo");
NSLog(@"Action Performed, %@ \n", blockResult);
MethodTraceEnd("simple.m", "performActionWithCompletion:");
}
@end
int main() {
MethodTraceBegin("simple.m", "main");
Demo *sampleClass = [[Demo alloc] init];
[sampleClass performActionWithCompletion:^(NSString *name) {
MethodTraceBegin("simple.m", "main", name);
NSLog(@"Completion is called to intimate action is performed, %@", name);
int tempVar = @"block result from main";
MethodTraceEnd("simple.m", "main", tempVar);
return returnVar10086;
}];
int tempVar = 0;
MethodTraceEnd("simple.m", "main", tempVar);
return tempVar;
}
基于 Clang 插件提供的一系列 API,可以对源文件进行插桩,或者是基于源码对代码结构进行分析,实现诸如代码风格,有效API检查,无用代码筛查等功能。
09 工程实践
目前 Android(Java)、iOS(OC/C++) 双端 AOP 方案已基本开发完毕,后续会陆续支持 Kotlin、Swift 等语言。依托代码 AOP 能力,可以按需将各类测试代码套件注入到被测试应用,实现专项测试需求。目前平台一期投入解决移动端代码可测性问题,开发了第一版的流量录制回放代码套件,对实例对象、函数运行状态进行存储,结合代码回放、流量规则校验能力检查代码执行逻辑:
1. 流量数据编辑导入
2. 流量测试用例/规则管理
3. 用例执行历史
目前平台侧已完成流量录制回放基础能力,后续会逐步完善代码可测性量化模型、复杂类序列化、代码覆盖实时预览等功能,进一步丰富代码层面测试能力。
参考
[1] Software Testability Metrics and its Various Types:https://www.xenonstack.com/insights/software-testability-metrics
[2] Google Testability Explorer:https://code.google.com/archive/p/testability-explorer/
[3] Android项目架构设计深入浅出:https://developer.aliyun.com/article/850168?utm_content=g_1000317669
[4] The LLVM Compiler Infrastructure:https://llvm.org/
[5] Clang 15.0.0 documentation:https://clang.llvm.org/docs/ClangPlugins.html
[6] Clang Compiler User’s Manual:https://clang.llvm.org/docs/UsersManual.html
[7] The Java® Virtual Machine Specification https://docs.oracle.com/javase/specs/jvms/se9/html/index.html
[8] See No Eval: Runtime Dynamic Code Execution in Objective-C:https://blog.chichou.me/2021/01/16/see-no-eval-runtime-code-execution-objc/
[9] Using custom functions with NSExpression:https://funwithobjc.tumblr.com/post/2922267976/using-custom-functions-with-nsexpression
[10] frida, dynamic instrumentation toolkit:https://frida.re/docs/home/