在当今的互联网时代,“小步快跑,快速迭代”几乎成为每个互联网产品的研发策略。如何保证产品质量的情况下,同时又能够支持产品的快速上线,软件测试人员目前面临了更大的挑战。传统的黑盒测试存在测试效率低、发现问题能力有限等局限性,而白盒测试存在人力投入成本大、耗时较长等问题,行业更多的采用了折中的灰盒测试方案,但灰盒测试同样也面临着一定的挑战。本节将讲述一种基于AOP技术注入测试代码到被测对象中的技术方案,通过采集程序的异常行为、构造各类异常等手段,提升灰盒测试的能力,发现更多潜在的产品缺陷。
在软件测试领域,从是否感知软件的内部工作结构(源码)的角度,可以大致分为黑盒测试、白盒测试以及灰盒测试。
黑盒测试也称为功能测试,测试人员不需要了解源码,主要从用户交互界面进行测试。黑盒测试实施难度低,也比较贴近用户。虽然黑盒测试在发现软件的潜在问题方面能力有限,但在项目时间紧张或是软件不具备实施白盒测试的条件下,仍然是很多项目的首选方案。
白盒测试是基于代码的测试,包含代码评审、单元测试、代码静态扫描、代码覆盖率分析等手段。白盒测试能够有效发现软件的潜在问题,但耗时比较长,在落地时也面临一定的挑战,例如:软件架构设计上是否支持可测性等问题,会对单元测试的实施带来影响,所以在实际项目中更多的是采用了折中方案:灰盒测试。
灰盒测试则是介于黑盒测试和白盒测试之间的一种测试方法,通过了解一定的软件的内部工作结构来指导测试场景的设计,比如业内最常见的代码覆盖率分析:采集和分析测试过程中的未覆盖的代码,通过增加测试用例来提升代码覆盖率,在一定程度上提升了测试的覆盖度。
但灰盒测试仍然面临一定的挑战:
(1)如何采集和监控程序更多的异常行为?典型的异常行为有:异常处理是否合理、线程间的关系是否合理、消息间的时序是什么样的等。
(2)如何快捷的构造程序的异常行为?比如:如何抛出代码里特定的异常对象?
针对上面的挑战,一种常见的思路是在业务代码里增加一定的测试代码。但这里又引入了新的问题:
(1)注入的测试代码如何管理和维护?
(2)如何保证测试代码不污染产品代码?如何避免发布时夹带测试代码的风险?
如果我们能够将测试代码和开发代码做到完全分离,编译出不同的版本(有测试代码的插桩版和无测试代码的非插桩版),是不是就可以解决这个问题了?我们先把视线暂时从测试领域转移到开发领域,看看是否有相似的问题和解决方案。
大家熟知的OOP面向对象编程(Object-Oriented Programming),做到了组件的可重用性和模块化等特性,降低了软件的复杂度和维护成本,但对于某类需求,OOP却无法很好的解决。
我们来看一个例子,图1中org.apache.tomcat的源码的模块分布情况,红色柱状图是XML parsing模块的实现和调用情况,它被很好的封装在一个模块里,和其他的柱状模块基本没有交互,管理和维护成本比较低。
图1:tomcat源码中XML parsing模块的分布情况
图2中显示的是logging功能在org.apache.tomcat各个模块的分布情况,logging模块本身可以做到很好的封装,但调用它的地方却分散到各个模块中,logging代码和非logging模块代码纠缠在一起。那这有什么问题呢?设想一下改动logging代码这个需求,比如:logging模块的接口发生变更,要求将接口的入参由char*,变为string类型,或是需要由2个参数增加为3个,那么所有调用logging的地方都要发生变更。如果要梳理出所有模块对logging使用情况,也就需要把所有的调用者相关代码梳理一遍。
这种纠缠代码(tangled code)注定带来了复杂性和维护成本:
(1) 冗余代码:多处同样的代码块。
(2) 难于理解:代码散落到各处,没有一个集中的地方。
(3) 难于变更:需要找到所有的相关代码,变更一处时要考虑到是否会影响其他的地方。
图2:tomcat源码中logging模块的分布情况
像logging这类需求,称作“横切需求(crosscutting concern)”,也就是实现时横跨了多个模块的需求。AOP(Aspect Oriented Programming),即“面向切面编程”,很好的解决了这类问题。简单说,AOP将需求分为两类:主需求(core concern)和横切需求,AOP提出将横切需求与主需求在代码层面上分离,横切需求的代码单独维护,避免出现代码交织现象。AOP是如何做到的呢?
我们还是以日志功能为例进行讲解。图3是一个简单的交易系统示例,有四个模块:账户模块、转账模块、数据库模块和日志模块。其中账户模块、转账模块和数据库模块需要调用日志模块的功能进行日志记录。传统的方式是将这些日志模块的API调用嵌入到各个调用方的模块中,从而存在代码纠缠问题。
AOP的实现方式如图4所示,它新增了一个Logging Aspect(方面)模块,Aspect类似于OOP的类。各个主需求模块不再直接调用日志模块的API,而是将对日志的调用统一放到了Logging Aspect这个模块中进行实现,然后在编译或是运行时将Logging Aspect的实现自动织入到各个主需求模块中,从而解决了代码纠缠问题。
图3 传统方式实现日志功能,存在代码纠缠问题
图4 AOP方式实现日志功能,代码分离
我们再以AspectJ(AOP在JAVA上的一种实现)为例,看一看AOP是如何做到自动织入的。图5是AspectJ的一种编译时织入方式(compile-time weaving),我们的主需求使用JAVA语言实现,使用javac或是AspectJ的编译器ajc来编译;aspect的编写则使用AspectJ语言实现或是使用JAVA的annotation方式实现,然后使用ajc进行编译; 最后一步使用ajc将上面两步各自生成的class文件织入(weaving)到一起,生成最终的业务对象。
图5 AspectJ编译织入过程
AspectJ支持三种织入方式:
(1)编译时织入(compile-time weaving):AspectJ同时编译业务源码和Aspect源码,编译过程中完成织入,也就是上面提到的织入方式。
(2)编译后织入(post-compile weaving):也叫二进制织入(binary-weaving),它通常用于对已经编译好的java class文件或jar包进行织入。
(3)加载时织入(load-time weaving):这也是一种二进制织入方式,和编译后织入的不同在于,它是在类被JVM加载时完成织入。
具体选择哪种织入方式,可以根据实际项目的需要来决定,比如没有业务源码的情况下,可以选择编译后织入或加载时织入。
那么AOP是如何完成这些横切需求的呢?下面我们了解下AOP的一些基本概念,以及它带给我们的一些启示。
AOP有四个核心的概念,分别如下:
(1)连接点(join point):简单的来说join point就是程序执行流程中的一个个可识别的执行点,比如,对象的创建、函数的调用、if/else/while等等。它是一个抽象的概念,在实现AOP时,并不需要去定义一个join point。
(2)切入点(point cut):是一组一个或多个连接点,可以在其中执行通知(advice)。
(3)通知(advice):是point cut的执行代码,是执行“方面”(aspect)的具体逻辑。
(4)方面(aspect):point cut和advice结合起来就是aspect,它类似于OOP中定义的一个类。
我们再用一个DB的类比来更好的理解下AOP的这几个概念。
(1)连接点 vs DB行数据:每个连接点类似于DB的每行数据。
(2)切入点 vs SQL语句:切入点类似于DB的SQL语言,通过编写SQL语言过滤出来我们感兴趣的数据。
(3)通知 vs Trigger:通知则类似于DB的trigger,当有满足条件的DB数据被修改时,会触发预先存储到DB里的SQL脚本代码。
下面我们结合着AspectJ来具体看下AOP的这些概念。
AspectJ是AOP在JAVA上的一种实现,支持丰富的切入点注入,易学易用,其官网地址是:https://www.eclipse.org/aspectj/。
在JAVA领域,除了AspectJ外,还有一些其他的AOP实现工具,比如:和Spring框架集成的 Spring AOP,来自阿里巴巴开源的JVM Sandbox等,读者可以查询它们的相关资料,选择适合自己项目的一款实现工具。
另外,在C/C++领域,典型的AOP实现工具是:AspectC++。读者可以参考https://en.wikipedia.org/wiki/Aspect-oriented_programming了解更多其他语言的AOP实现的相关信息。
总的来说,目前JAVA领域的AOP实现工具是最成熟的,在工程上可以比较好的落地,这也是本节选择AspectJ讲解的一个原因。
上文提到Join point是程序的一个个的执行点,对于AspectJ来说,它取了join point的一个子集,而不是全部的join point,只有这些暴露的join point才是插桩点,在AspectJ中用pointcut来定义这些join point.
AspectJ主要支持下面几类join point:
- 对象方法和构造函数的调用(call)
- 对象方法和构造函数的本身的执行(exectution)
- 对象属性的访问操作(get & set)
- 异常handler的执行(handler)
- 类的静态方法的初始化(initialization)
AspectJ提供了很灵活的pointcut语法,既支持精准匹配,如某个package的某个函数,又支持通匹配符,如../*/+等,如Activity+表示Activity类及其子类,用来过滤出感兴趣的join points插桩点,这里不详细介绍,仅举一些例子作为说明。
表1 Pointcut签名举例
Pointcut签名 | 说明 |
---|---|
public int android.net.NetworkInfo.getType() | 精确匹配该getType方法 |
* Activity+.onCreate(..) | 匹配Activity类及其子类的名称为onCreate的所有方法,入参及返回值可以为任意类型,访问类型可以是public, private, protected类型。 |
* *.*(..) 或* *(..) | 匹配所有的函数调用 |
!get(* *.*) && !set(* *.*) | 所有非对象属性的读写操作 |
* *(..) throws IOException | 匹配所有抛出IOException的函数 |
表1是Pointcut的一些签名举例,签名主要是用来定义我们感兴趣的join points。
pointcut的定义格式是:pointcut类型(pointcut签名)。
常见的pointcut类型有:
(1)execution(pointcut签名):由pointcut签名指定的函数自身在执行。
(2)call(pointcut签名):由pointcut签名指定的函数被调用。
(3)handler(异常类型):某个异常类型的exception handler被执行。
(4)this(SomeType):当前执行的对象(即this指针)是SomeType类型的。
(5)target(SomeType):触发的目标对象是SomeType类型的,如通过call(pointcut签名)触发的目标对象。
(6)within(SomeClass):当前执行的代码属于SomeClass。
(7)args(某个变量对象):用于将传入到joinpoint的入参变量对象保存下来,传递给advice。
更多的详细介绍可参考官方文档:https://www.eclipse.org/aspectj/doc/next/progguide/starting-aspectj.html。
Advice就是在我们选择出来的pointcut点上要执行的代码块,advice分为三类before/after/around,分别用于控制advice在pointcut的周围何时执行。
顾名思义,before在pointcut插桩点执行前先执行,比如调用某个函数,在该函数执行前获取下当前系统时间。
after在pointcut插桩点执行后执行,比如此时再获取下当前系统时间,这样和before做下对比下,就能算出该函数的执行时间了。after可以细分为两类:after returning和after throwing,前者是指函数正常返回,后者是指函数抛出异常返回;after则是两者的并集。
around是最灵活的一种,可以用自己的代码替代原pointcut插桩点的执行代码,可以决定是否要原pointcut代码继续执行,如果需要继续执行则调用proceed函数即可。
Advice的执行上下文和插桩对象是在同一个进程空间的,确切的说,advice代码实际上在编译阶段,是直接插入到插桩点。那么advice代码中理所当然的应该能够像被插桩的joinpoint一样访问资源,比如类内部的方法、属性等,这些是通过thisJoinPoint 对象来获取。通过thisJoinPoint可以获取当前joinpoint的基本信息,如代码行号、joinpoint的名称信息,如函数名称等,同时thisJoinPoint的getThis()是最强大的函数,它返回当前advice所在对象的this指针,有了this指针,自然可以调用this对象的方法/属性等信息了。
下面我们举一个基于AspectJ的简单的例子来介绍下AOP。关于开发环境搭建,AspectJ在多个IDE(如Eclipse/Netbeans/IntelliJ IDEA等)上都有插件,也支持命令行/maven/ant等编译方式,读者可以查阅相关指引自行搭建。
package helloworld;
public class Hello {
public void sayHello(String name)
{
System.out.println("hello, " +name);
}
public static void main(String[] args) {
Hello h = new Hello();
h.sayHello("tom");
}
}
代码段1 : 业务代码Hello示例
package helloworld;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.reflect.CodeSignature;
public aspect Tracing {
pointcut tracedCalls():call(* Hello.*(..))
&& !within(Tracing) && !within(Around);
before():tracedCalls(){
System.out.println("[Aspect Tracing][before] Entering: "+thisJoinPoint);
printParameters(thisJoinPoint);
}
after():tracedCalls(){
System.out.println("[Aspect Tracing][after] Leaving: "+thisJoinPoint);
}
//打印函数的基本入参信息
static private void printParameters(JoinPoint jp) {
System.out.println("Arguments: " );
Object[] args = jp.getArgs();
String[] names = ((CodeSignature)jp.getSignature()).getParameterNames();
Class[] types = ((CodeSignature)jp.getSignature()).getParameterTypes();
for (int i = 0; i < args.length; i++) {
System.out.println(" " + i + ". " + names[i] +
" : " + types[i].getName() +
" = " + args[i]);
}
}
}
代码段2 : tracing aspect代码示例
package helloworld;
public aspect Around {
pointcut hello(String name):execution(* Hello.sayHello(..)) && args(name);
void around(String name):hello(name){
String new_name = "jerry";
System.out.println("[Aspect around]:" +
"before executing sayHello, change name from "
+ name + " to " + new_name);//篡改入参
proceed(new_name); //使用新的入参继续执行原函数
return;
}
}
代码段3 : around aspect代码示例
代码段1是一个简单的hello程序,包含了一个函数sayHello(),sayHello()有一个string入参。
代码段2是完成Tracing的功能的aspectj代码,在函数被调用前和调用后输出日志,同时打印入参信息。
代码段3是完成篡改sayhello()入参的aspectj代码,它是通过aspectj的around机制实现的。
插桩前后的hello程序的输出,如代码段4所示。
插桩前的hello程序输出:
hello, tom
插桩后的hello程序输出:
[Aspect Tracing][before] Entering: call(void helloworld.Hello.sayHello(String))
Arguments:
0. name : java.lang.String = tom
[Aspect around]: before executing sayHello, change name from tom to jerry
hello, jerry
[Aspect Tracing][after] Leaving: call(void helloworld.Hello.sayHello(String))
代码段4 : 插桩前后的hello程序的输出
AOP作为一种编程范式,将开发代码和测试代码完全隔离,完美的解决了横切需求带来的代码纠缠困扰。同时,在编译时,通过编译脚本控制是否进行测试代码的注入,可以同时生成两个版本,一个是无测试代码注入的发布版本,一个是有测试代码的插桩版本,这样就保证了测试代码不污染产品代码,也避免发布时夹带测试代码的风险。
另外,AspectJ的join point语法可以支持灵活的插桩点,然后执行任意的advice代码。这些能力将有效的帮助我们采集程序异常行为以及构造程序的异常行为。我们将在本节的的第4小节结合实战案例进行讲解。
以下案例基于AspectJ的实现方案来进行讲解。
常规的黑盒或是灰盒测试中,虽然通过前端UI能够感知一定的程序功能,但对于一些程序的异常行为却感知较少,特别是一些异常可能被程序捕获,并通过降级服务来补偿,但这类异常可能是非预期的异常,从而被忽略。下面将讲解如何通过AOP来捕获一些常见的可能会被忽略的程序异常行为,进而发现更多潜在的程序bug。
通过AOP可以捕获到代码中被try…catch代码catch住的异常:这类异常比较隐蔽,因为被catch住了,所以一般不会导致程序崩溃,如果没有被应用表现出来,就很容易被忽略。当然,有些被捕获的异常可能是符合预期的,所以需要做进一步的分析判断。
如何捕获这类异常呢?代码段5是AspectJ的实现代码,pointcut中的handler(*)将会在所有的catch处注入我们的advice代码,从而我们可以获取异常对象的一些运行时的信息,并记录下来。
//捕获所有被catch住的异常
pointcut exceptionHandlerPointcut(Throwable ex, Object exHandlerObject):
handler(*) && args(ex)
&& this(exHandlerObject);
before(Throwable ex, Object exHandlerObject):
exceptionHandlerPointcut(ex, exHandlerObject)
{
String str = "Exception caught by :" + exHandlerObject + "\n";
str += "Signature: " + thisJoinPoint.getStaticPart().getSignature() + "\n";
str += "Source Line: " + thisJoinPoint.getStaticPart().getSourceLocation()
+ "\n";
str += StackTraceUtil.getStackTrace(ex);//获取异常对象的堆栈信息
logger.info(str); //输出异常对象信息到日志中
}
代码段5:Exception catcher: 发现那些消失的异常
这里举一个具体非预期的异常实例。在某个App执行过程中,通过上面的Exception Catcher捕获到了下面的一个异常:android.database.sqlite.SQLiteException: no such column: Ol_7132 (code 1): , while compiling: UPDATE onlinetable SET time=?,xmlContent=?,key=? WHERE key=Ol_7132
但从App前端交互及功能上没有发现任何问题,这个异常是怎么发生的,又是如何被处理的呢?通过代码分析,这个bug对应的是App的一个性能优化的辅助功能,App会缓存一些网络页面到数据库中。如果有缓存,则拉取本地缓存数据,否则从服务器重新拉取数据。这个SQLiteException导致数据库缓存操作时失败,故主程序会从服务器重新拉取数据,主流程仍然能够跑通,但该缓存功能彻底失效。最后发现这个异常的根本原因是查询SQL书写格式有问题导致的。
通过这个案例,我们看到AOP技术可以非常方便的帮助我们采集到业务代码里的一些看不到的程序行为,有了这些行为日志后,我们接着做人工分析,进而将一些从前端交互上无法发现的异常挖掘出来。
如果我们想知道某个功能背后有哪些函数被执行过,最直接的方式就是在代码里对每个函数的入口处打印一行日志,代码量较少的情况下,手动做这些事情还可以接受,但随着代码量的增加,手工维护这类日志代码就是个很大的负担了。这个需求很明显是属于我们前面提到的横切需求,下面让我们看下AOP的代码是如何解决这个问题的。
//不需要记录aspectJ插桩代码里的函数执行
pointcut excludedAJ():!within (com.scream.aop..*) && !cflow(adviceexecution());
//定义要监控的函数执行,排除一些java基类Object的访问函数以及对类的成员属性的访问函数
pointcut funcExecutionPointcut():execution(* *.*(..))
&& !execution(* *.access$*(..));
before():funcExecutionPointcut() && excludedAJ()
{
Signature sig = thisJoinPoint.getStaticPart().getSignature();
SourceLocation sl = thisJoinPoint.getStaticPart().getSourceLocation();
int line = sl.getLine();
String file = sl.getFileName();
String className = "";
if (thisJoinPoint.getThis() != null)
className = thisJoinPoint.getThis().getClass().getName();
mylogger.log(Level.INFO,"Entering [" + className + "." + sig.getName()
+ "] @" + line + "@" +file);
NDC.push(prefix);//NDC对象来自于log4j,用于控制log行的缩进
}
after():funcExecutionPointcut() && excludedAJ()
{
NDC.pop();
}
代码段6:Function Tracing: 记录函数执行顺序
代码段6实现了对被测对象的所有函数入口执行时进行记录的功能,其中pointcut excludedAJ()是用于将部分package和自身advice的执行排除在外,对它们的调用不需要记录。NDC对象来自于log4j,用于控制log行的缩进。
我们看看一个具体的日志输出实例:
图6 函数调用层次输出示例
从图6,我们可以看到函数调用层次关系清晰明了。我们该如何利用这些信息呢?
(1)精准测试
前端进行UI操作时,将函数调用关系记录下来,这样可以将前端操作用例和代码对应起来,建立起二者的正向映射关系。当代码有变更的时候,我们就可以反向推测出哪些相关的用例需要执行,达到精准测试的目的。关于精准测试,读者可以参考16.2精准测试章节。
(2)发现潜在的性能问题
举一个实际的案例,在某次功能测试时(启动app,然后按home键,将程序切换到后台执行),但短短几分钟内,Function Tracing:一直往sd卡写log数据, 1M,2M,3M,… 10M…30M… 同时看到Eclispe的logcat窗口里满屏的log输出,是不是业务代码有问题?我们可以写个函数执行次数的统计脚本,统计每个函数的执行次数、都被谁调用过。
图7 函数调用次数统计示例
图7统计数据显示getFirstVisiblePosition()函数会在短短的几分钟内执行了2004次,结合着函数调用树往上找,我们找到了问题的根源。这个函数是被ImageListManager的mLoadThread线程调用,在应用app不可见的时候,仍然一直被调用执行。这个线程没有做任何的启停控制,启动后就一直以200ms的频率sleep-waitup的循环方式执行,没有任何机制来暂停这个线程。至此我们找到了优化点,当app切换到后台后,我们会暂停这个线程的执行。
当然这些bug的发现也依赖于用例的设计,我们可以事先分析下可能会有问题的场景,然后去验证自己的设想。比如:在没有任何UI操作的时候,是否有看不见的线程/函数在空跑?在缓存完成后,缓存线程是否自动结束?当打开歌词activity后关闭该activity时,歌词线程是否会结束?等等。
通过统计函数的调用次数,重点分析top的函数调用,我们在实际的项目中发现很多这类函数空转的问题,函数空转带来的影响是应用性能问题,对于手机app来说会有手机电量的损耗,而这个问题也是手机app需要特别关注的。
针对网络异常包的监控,传统的方式通常是:PC端可以通过工具比如Fiddler或Wireshark等抓包工具进行网络抓包;手机App则可以通过手机设置代理,接入PC热点的方式进行抓包;然后再对这些采集到的网络请求包进行过滤分析,从中找到一些可疑的数据。这种方式主要的缺点是当采集的网络请求数据量比较大时,容易出现分析遗漏,同时在抓包环境设置上也比较繁琐。
以HTTP网络消息为例,我们看看AOP是如何监控异常的HTTP请求和响应数据的。
//要监控的HTTP函数
pointcut callConnect(java.net.HttpURLConnection callerObj):
call(* java.net.URLConnection.connect()) && target(callerObj);
before(java.net.HttpURLConnection callerObj):callConnect(callerObj)
{
TLog.i(TAG,"======HTTP Request headers==========");
dumpHttReqHeaders(callerObj);
}
after(java.net.HttpURLConnection callerObj) returning : callConnect(callerObj)
{
TLog.i(TAG,"======HTTP Response headers==========");
dumpHttpResHeaders(callerObj);
}
//打印HTTP请求体的基本信息
private static void dumpHttReqHeaders(HttpURLConnection httpCon)
{
String output = "";
output += "requestUrl =" + httpCon.getURL().toString() + "\n";
for (String header : httpCon.getRequestProperties().keySet()) {
if (header != null) {
for (String value : httpCon.getRequestProperties().get(header)) {
output += header + ":" + value + "\n";
}
}
}
TLog.i(TAG,output);
}
//打印HTTP响应体的基本信息
private static void dumpHttpResHeaders(HttpURLConnection httpCon)
{
String output = "";
output += "requestUrl =" + httpCon.getURL().toString() + "\n";
Map> hdrs = httpCon.getHeaderFields();
if (hdrs == null)
return;
SethdrKeys = hdrs.keySet();
for (String k : hdrKeys)
output += k + ":" + hdrs.get(k) + "\n";
try {
if (httpCon.getResponseCode() >= 300) { //只记录返回码>=300的可疑响应
TLog.e(TAG,output);
}
else {
TLog.i(TAG,output);
}
} catch (IOException e) {
TLog.e(TAG, e);
}
}
代码段7:Dump HTTP Headers : 记录HTTP的请求和响应的异常数据
代码段7展示了如何编写AOP的pointcut规则来过滤出HTTP相关的Reqest和Reponse请求,同时对Reponse Code >=300的可疑响应会记录在日志文件里,作为后续的重点排查对象。
下面看一个我们的实际案例,看看这个监控能力是如何帮助我们发现产品bug的。
比如,某音乐App有个功能叫CDN竞速,在播放在线歌曲时,先连接几个CDN节点竞速,选择较快的一个CDN节点。但因为代码问题,导致竞速请求失败返回404错误,App的兜底策略最终走了默认节点,但前端功能正常,如果不通过网络包分析,这类问题是很难发现的。但通过AOP记录的异常数据包,我们快速发现并准确定位到问题,原来是HTTP header中的某个Cookie字段设置有误导致的。
对于多线程程序,我们可以通过AOP来采集线程的生命期信息,包括线程的父子关系,线程创建和销毁的时间点等基本信息,从中发现一些可能的信息,比如:线程的创建是否合理?线程间的父子关系是否合理?那么如何采集线程的生命期信息呢?
//线程自身执行的入口函数
pointcut threadRun():execution(public void java.lang.Thread+.run())
|| execution(public void java.lang.Runnable+.run());
//线程被启动时的函数
pointcut threadStart(Thread startedThread):
call(public void java.lang.Thread+.start())
&& target(startedThread);
//记录线程何时被创建的,以及线程间的父子关系
before(Thread startedThread) : threadStart(startedThread)
{
String parentThreadName = Thread.currentThread().getName();//获取父线程名称
long parentThreadId = Thread.currentThread().getId();//获取父线程Id
String targetThreadName = startedThread.getName();//获取子线程名称
long targetThreadId = startedThread.getId();//获取子线程Id
//获取joinpoint的基本信息
Signature sig = thisJoinPoint.getStaticPart().getSignature();
SourceLocation sl = thisJoinPoint.getStaticPart().getSourceLocation();
int line = sl.getLine();
String file = sl.getFileName();
String className = "";
if (thisJoinPoint.getThis() != null)
className = thisJoinPoint.getThis().getClass().getName();
TLog.i(TAG, "Thread ["+ parentThreadName + "(" + parentThreadId
+ ")] has started a new Thread [" + targetThreadName
+ "(" + targetThreadId +")]. [(" + className + ")"
+ sig.toShortString() + "] @" + line + "@" +file);
}
void around():threadRun() //线程执行前随机sleep xx 秒
{
Random random = new Random();
int randTime = 0;
int randConfig = readSleepRandomFromConfig(); //读取配置文件中的随机sleep时间
if (randConfig > 0 )
randTime = random.nextInt(randConfig);
String threadName = Thread.currentThread().getName();
long threadId = Thread.currentThread().getId();
Signature sig = thisJoinPoint.getStaticPart().getSignature();
SourceLocation sl = thisJoinPoint.getStaticPart().getSourceLocation();
int line = sl.getLine();
String file = sl.getFileName();
String className = "";
if (thisJoinPoint.getThis() != null)
className = thisJoinPoint.getThis().getClass().getName();
TLog.i(TAG, "Thread ["+ threadName +"(" + threadId + ")] is running. [("
+ className + ")" + sig.toShortString() + "] @" + line + "@" +file);
try {
if (randTime > 0)//随机sleep xx 秒
{
TLog.i(TAG, "Trying to put thread[" + threadName +
"(" + threadId + ")] sleep " + randTime + " sec");
Thread.sleep(randTime*1000);
}
} catch (InterruptedException e) {
TLog.e(TAG, "Failed to put thread[" + threadName +"("
+ threadId + ")] sleep " + randTime + " sec");
TLog.e(TAG, e);
}
proceed(); //随机sleep后,让线程继续执行
//线程结束执行时,记录下该事件.
TLog.i(TAG, "Thread ["+ threadName +"(" + threadId + ")] is terminated. [("
+ className + ")" + sig.toShortString() + "] @" + line + "@" +file);
}
代码段8:ThreadMonkeyRunner : 记录线程的生命期信息及构建线程随机sleep异常
代码段8展示了通过threadRun()和threadStart()这两个pointcut可以完成对线程的生命期信息的收集以及如何构建线程随机sleep异常。关于线程随机sleep异常,我们将在4.2.3章节讲解。
有了线程的生命期信息,我们可以基于此做人工分析,发现一些潜在的bug。比如:关闭某音乐App的连接智能音箱功能,重启App后,置于后台一段时间,然后观察一下线程执行情况,通过日志发现该功能的连接音箱线程仍被启动了,但实际上是不需要启动的。
我们还可以基于日志,绘制出线程间的父子关系,线程的创建和消亡时间,来帮助我们更好的理解业务的代码逻辑,据此来指导我们构造更多线程异常。这部分我们将放在4.2节进行讲解。
为什么要做自动记录UI操作流呢?因为在日常测试中经常遇到一些非预期的bug,但又记不清了之前做了哪些UI操作。如果能够记录下用户的各个操作流,那么就会更方便定位问题。如果对Android的控件开发比较熟悉的话,控件是基于事件响应的,即实现各种onXXX函数。比如:onClick(View),onKeyDown(int keyCode, KeyEvent event), onItemClick等等。利用AOP可以过滤出这些pointcuts,然后插入相应的log记录代码即可,而不需要在App的各个UI界面编写每个控件的操作日志,从而极大的简化了代码。
我们看下AspectJ的代码实现例子,因篇幅有限,代码段9中我们仅举几个控件操作的例子。
//click事件
pointcut onClick(View v): execution(public void onClick(View)) && args(v);
//keyDown事件
pointcut onKeyDown(int keyCode, KeyEvent event):
execution(public boolean onKeyDown(int, KeyEve)
&& args(keyCode, event);
//菜单项点击事件
pointcut onMenuItemClick(MenuItem menuItem):
execution(public void onMenuItemClick(MenuItem)) && args(menuItem);
before(View view):onClick(view)
{
TLog.i(TAG, "ClassName = " + thisJoinPoint.getThis().getClass().getName());
UIUtil.getTextFromUIElement(TAG,view);//输出点击view的文本信息
}
before(int keyCode, KeyEvent event):onKeyDown(keyCode, event)
{
TLog.i(TAG, "ClassName = " + thisJoinPoint.getThis().getClass().getName());
TLog.i(TAG, "onKeyDown : \n\tkeyCode = " + keyCode
+ "\n\tKeyEvent = " + event);
}
before(MenuItem menuItem):onMenuItemClick(menuItem)
{
TLog.i(TAG, "onMenuItemClick : \n\tmenuItem = " + menuItem.getTitle());
}
代码段9:UI Action Tracing: 记录UI操作流
代码段9中的UIUtil.getTextFromUIElement()函数是自定义的函数用于遍历View的子对象(该View对象可能是个Layout容器)找到一个有TextView对象获取其text属性,若是其他非TextView对象,则返回IDName作为该控件的文本标识。
下面我们举个实际的例子来看看这种方式记录下来的操作流。
图8 UI操作流举例
从图8,我们可以很清楚的了解到在最后一个SocketException异常前我们做了哪些操作以及相关的控件信息。有了这些信息,可以很好的帮助我们理解bug出现的上下文。
以上就是一些通过AOP来采集程序异常行为的场景举例。当然不仅仅是这些,只要我们能够清楚的描述出要过滤的pointcuts,然后编写相应的监控advice代码,就可以收集相应的程序行为数据了,比如:自动开启android的strictmode来发现ANR及资源泄露问题、自动记录app crash事件并生成内存转储文件等等。
AOP除了可以监控程序的异常行为外,还可以帮助我们构造一些特定的异常,覆盖手工测试难以模拟的场景。下面我们将讲解几个典型的场景,帮助大家理解AOP在这方面的能力。
基于功能的黑盒测试方案,如果要覆盖一些特殊的异常场景,存在一定的难度及测试时间成本,比如机器内存不足,需要开启大量的程序将系统的内存耗尽;磁盘空间不足,则需要通过拷贝文件等方式将本地磁盘空间占满;同时对于一些程序内部的异常分支,如某个函数异常返回,黑盒测试更加难以模拟。
AOP通过按照指定的规则对代码中的函数触发点(包括系统函数和应用自定义函数)进行截获并插入一定的测试桩代码,按照一定的规则修改程序的运行行为,如修改函数的指定返回值,从而达到覆盖各类场景的目的。比如:机器内存不足的场景,当应用调用new函数时,对该系统函数进行截获,不是继续系统函数的调用,而是直接抛出Out of Memory异常,从而模拟到测试内存不足的场景;同样,对于其他的测试场景也可以按照这种方式进行模拟。
下面我们看个例子:
package helloworld;
public class Hello {
//访问数组,可能会存在数组下标越界异常
private void accessArray() throws ArrayIndexOutOfBoundsException
{
int a[] = new int[2];
a[0] = 0;
a[1] = 1;
System.out.println(a[0]);
System.out.println(a[1]);
System.out.println("within access Array");
}
//调用accessArray()函数,捕获可能的数组下标越界异常
public void helloException()
{
try{
accessArray();
}catch(ArrayIndexOutOfBoundsException e){
System.out.println("Caught Exception :" + e);
}
}
public static void main(String[] args) {
Hello h = new Hello();
h.helloException();
}
}
代码段10:hello exception的业务代码
package helloworld;
public aspect MyException {
pointcut hiException():execution(* Hello.accessArray(..));
//在accessArray被执行时,抛出数组下标越界异常
void around():hiException()
{
System.out.println("[Aspect MyException]: before executing accessArray,"
+ " throw an exception.");
throw new ArrayIndexOutOfBoundsException();
}
}
代码段11:抛出exception的aspectj代码
代码段10里我们有一个accessArray函数,它可能会抛出访问数组越界的异常(ArrayIndexOutOfBoundsException),这个异常会被调用函数helloException()捕获,在测试时,想覆盖这个异常场景,但因为当前的代码通过黑盒的方式很难模拟出这个异常。
代码段11展示了如何通过aspectj的around机制完成异常的模拟。
通过代码段12,我们可以看到这个抛出的异常被helloException()捕获到,这样我们就轻松的验证到了当这个异常发生时,helloException()是否正确处理了这个异常。
[aspect MyException]: before executing accessArray,throw an exception.
Caught Exception :java.lang.ArrayIndexOutOfBoundsException.
代码段12:抛出exception的程序输出
关于通过修改函数返回值来篡改程序行为,我们看个网络类型欺骗的例子。
在与网络相关的手机终端测试中,需要覆盖各类不同的网络类型,如:wifi/5G/4G/3G等,现有的测试方案基本上都是基于实际的物理手机卡在真实的物理环境下进行测试。当前基于物理手机卡来覆盖各类网络类型的测试方案存在一定的缺陷,如:一些网络场景很难自由的切换和覆盖到,如从4G模式变更到3G模式,需要寻找到4G信号较弱的场所;同时各种不同的运营商的网络类型,需要更多的实体卡和终端手机,带来一定的测试成本开销。
在Android平台,查询网络类型的API主要有android.net.NetworkInfo.getType()、android.net.NetworkInfo.getSubType()和android.net.NetworkInfo.getExtraInfo()等,AOP可以通过截获被测程序对网络类型系统API函数的调用,按照指定的规则篡改系统API的返回值,返回指定的网络类型,而不是当前手机的真实网络类型,从而达到网络类型欺骗的目的。
pointcut getNetworkType():call(int android.net.NetworkInfo.getType());
pointcut getSubtype():call(int android.net.NetworkInfo.getSubtype());
pointcut getExtraInfo():call(String android.net.NetworkInfo.getExtraInfo());
int around():getNetworkType()
{
//read Cheated NetType from a config file.
int typeFromFile = readNetTypeFromConfig();
TLog.d(TAG,"netType = " + typeFromFile );
if (typeFromFile == -1)
return proceed();
return typeFromFile;
}
int around():getSubtype()
{
//read cheated Subtype from a config file.
int subTypeFromFile = readSubtypeFromConfig();
TLog.d(TAG,"netSubtype = " + subTypeFromFile );
if (subTypeFromFile == -1)
return proceed();
return subTypeFromFile;
}
String around():getExtraInfo()
{
//read cheated extraInfo from a config file.
String extraInfo = readExtraInfoFromConfig();
TLog.d(TAG,"extraInfo = " + extraInfo );
if (extraInfo == "")
return proceed();
return extraInfo;
}
代码段13:NetworkTypeCheater:模拟不同网络类型
在4.1.4发现异常线程章节里,我们讲解了如何通过截获线程相关的函数调用来获取线程的生命期信息,同时我们也可以对线程执行的关键函数进行一定的篡改,从而达到扰乱线程时序的效果,进一步验证线程间是否存在一定的时序关系。
首先我们可以先根据日志绘制出线程间的父子关系、线程的创建和消亡时间,来帮助我们更好的理解业务的代码逻辑,如图9所示。另外,图中的线程名是线程id,而不是有含义的线程名,这个是因为业务代码中在创建线程时,没有指定线程名称,如果指定线程名称的话,线程关系图将有更好的可读性。
图9 线程创建时序和父子关系图举例
接着尝试扰乱一些线程的执行时序,一个简单的方式是模拟monkey test的思路,我们可以在每个线程的执行开始前随机sleep一段时间,尝试模拟不同线程间的执行顺序。当然这种方式存在一定的局限性,因为是随机sleep,测试的完备性是无法保证的,存在一些线程间的时序可能没有覆盖的情况。更合理的方式是首先对线程/进程间的关系进行时序建模,然后再通过控制各个线程的执行时间来模拟这些时序场景。
对于一些复杂的业务应用来说,前端app之间以及前端app和后台服务会有比较多的消息往来。因为网络传输的不确定性,存在消息丢失、消息延迟、消息乱序等各类可能的场景出现。
我们先举个例子,图10是在线K歌合唱的简化版的时序图,我们有一个主唱,一个合唱者(听众A),一个听众(听众B)。主唱先上麦唱歌(消息1),K歌后台会通知所有听众(消息2,消息3),接着一个听众A(即合唱者)申请合唱(消息4,消息5),主唱同意后(消息6,消息7),K歌后台广播通知听众B(消息8),接着合唱者开始上麦唱歌(消息9),K歌后台将此消息通知主唱和听众B(消息10,消息11)。期间,合唱者可能会在在消息4之后的任意时刻选择放弃合唱(消息12),如在收到主唱的同意合唱消息7之前,也可能是在收到消息7之后。K歌后台会将该消息广播告知主唱及听众B(消息13,消息14)。
这是一个简化版的例子,还未涉及到鉴权之类的权限,也未涉及到多个听众同时申请合唱等复杂的场景。从上面的分析来看,这个简化的功能已经涉及跨网络跨App的多个消息,在现实中因为网络原因,消息会存在丢失和延迟,无法保证每个消息的顺序到达,也存在消息乱序的情况。比如:合唱者的收到同意合唱的消息(消息7)的时间点是无法保证的,主唱收到消息10和消息13的顺序性是无法保证的,同样听众B收到的消息11和消息14也同样无法保证。就需要我们验证这些不同场景下我们的App是否能够正常工作。
比如:合唱者请求合唱(消息4),在收到同意合唱前(消息7),直接放弃合唱(消息12),但后面又收到同意合唱消息(消息7),App侧可能会又变成允许合唱状态,这样就是个bug。
图10 在线K歌合唱示例时序图
那如何模拟时序异常呢?
常见的网络工具可以模拟网络抖动、延迟、丢包等异常,但这类工具不能针对某些特定的网络消息进行设置异常,无法确保消息时序异常场景模拟的完备性,故而存在一定的局限性。
AOP在消息时序异常构造和网络工具的不同在于:AOP通过对业务代码中特定的收发包函数进行拦截,可以更加精准的控制消息的收发,从而可以模拟和穷举各种消息相关的异常构造。
例如:收包函数的advice代码里:
(1)收到消息后不返回给消息消费函数,模拟消息丢失的场景;
(2)收到消息后sleep一段时间后再返回给消息消费函数,模拟消息延迟的场景;
(3)收到消息后先暂存下来,等收到多个特定的消息后,打乱它们的顺序,然后再依次返回给消息消费函数,模拟消息乱序的场景。可以通过穷举消息间的不同顺序,保证消息时序测试的完备性。
在现实的业务中,我们采用这种策略,发现了不少有价值的业务bug。因为这些实现和业务逻辑和业务代码比较强相关,这里就不展开具体讲解了,读者可以参考这个思路,根据自己的业务情况进行实现。
虽然AOP在一定程度上解决了程序异常行为监控及注入的测试难题,但基于AOP的代码注入方案仍有一定的局限性。
(1)切入点仅支持部分连接点,不能对所有的函数执行点进行插桩,如:条件分支、顺序执行的某条语句等,对于这类插桩点AOP将无法支持。
(2)插桩后的代码存在一定的性能开销,故不太适合收集性能数据的测试场景,但仍可以收集数据做趋势类的数据分析。
本节介绍了一种基于AOP的测试方案,它可以有效的将测试代码和开发代码隔离,同时借助于AspectJ灵活的语法,高效注入测试代码到被测对象,进而发现程序的多种异常行为,同时还能够灵活的注入各类异常、控制程序的时序行为等,从而提升异常测试的覆盖度。
[1] AspectJ in Action. Second Edition. Manning Publications
[2] https://en.wikipedia.org/wiki/Aspect-oriented_programming
[3] https://www.eclipse.org/aspectj/
[4] https://www.baeldung.com/aspectj
扫码关注腾讯云开发者
领取腾讯云代金券
Copyright © 2013 - 2025 Tencent Cloud. All Rights Reserved. 腾讯云 版权所有
深圳市腾讯计算机系统有限公司 ICP备案/许可证号:粤B2-20090059 深公网安备号 44030502008569
腾讯云计算(北京)有限责任公司 京ICP证150476号 | 京ICP备11018762号 | 京公网安备号11010802020287
Copyright © 2013 - 2025 Tencent Cloud.
All Rights Reserved. 腾讯云 版权所有