前言:
负责公司的单元测试体系的搭建,大约有一两个月的时间了,从最初的框架的调研,到中期全员的培训,以及后期对几十个项目单元测试的引入和推进,也算是对安卓的单元测试有了一些初步的收获以及一些新的认知,因此写下这篇文章来进行一个记录和总结。
以下的所有内容纯属个人观点,欢迎讨论。
一.单元测试标准
1.测试维度
单元测试有很多维度,比如针对功能点的维度,或者针对方法的维度。那么我们的项目,该如何定义这个维度呢?
安卓源码的项目中,是以功能点的维度来写单元测试的,比如验证发送一个广播的功能,验证就是广播接收者是否收到通知。这其中的流程,包含广播发送到系统侧,系统侧接收和处理,系统侧通知应用,应用分发给接收者等四个步骤。对于安卓系统来说,发送广播后,其一定能保证接收到广播,但是这样单元测试就存在耦合度,因为如果系统侧代码有问题的话,整个流程是跑不通的。对于安卓的项目,很多点我们是不能使用原生的对象,而是需要使用Mock的对象,但是随着项目的耦合度增加,环节的增多,需要mock的对象和验证点会爆炸性的增长,导致后期的单元测试方法的成本会几何式增长。
当然,以功能点为维度的话,也会有其好的一方面,如果单元测试不通过,则代表着流程中必有一环出现了问题,更容易暴露出问题。
如果以方法为维度,则不会存在依赖和耦合的问题,但是覆盖的范围则会小很多。所以到底应该以功能点为维度,还是以方法为维度呢?
我认为,这个最终还是取决于项目的结构和形式。如果是UI级别的项目,项目复杂度相对较轻,或者使用了各种框架完成了视图绑定,这种项目,自然适合针对功能点的维度来写单元测试。但是如果项目耦合度比较高,复杂度较高的话,则应该选择基于方法的维度,对耦合的部分使用mock对象进行切割。
2.覆盖范围
功能点:核心功能点
首先,单元测试应该覆盖所有的核心功能点。验证一个方法的时候,并不是方法中所有的点都需要验证,就比如Activity中onCreate方法中的某些内容,比如针对onCreate写单元测试的时候,验证initView/initListener/init三个方法是否被执行其实并没有意义,我们应该写单元测试代码分别对这三个方法进行验证,这才是我们的业务逻辑点。
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
initView();
initListener();
init();
}
另外,我们应该验证具体的实现逻辑方法,对于一些中转方法,也不应该验证,比如ActivityThread中的ApplicationThread类就不应该被验证。部分ApplicationThread中的代码参考:
private class ApplicationThread extends IApplicationThread.Stub {
public final void scheduleReceiver(Intent intent, ActivityInfo info,
CompatibilityInfo compatInfo, int resultCode, String data, Bundle extras,
boolean sync, int sendingUser, int processState) {
updateProcessState(processState, false);
ReceiverData r = new ReceiverData(intent, resultCode, data, extras,
sync, false, mAppThread.asBinder(), sendingUser);
r.info = info;
r.compatInfo = compatInfo;
sendMessage(H.RECEIVER, r);
}
public final void scheduleCreateBackupAgent(ApplicationInfo app,
CompatibilityInfo compatInfo, int backupMode, int userId, int operationType) {
CreateBackupAgentData d = new CreateBackupAgentData();
d.appInfo = app;
d.compatInfo = compatInfo;
d.backupMode = backupMode;
d.userId = userId;
d.operationType = operationType;
sendMessage(H.CREATE_BACKUP_AGENT, d);
}
...
}
3.覆盖率要求
单元测试会有一个覆盖率,分别针对类,方法,行,大多数公司关注的是行代码覆盖率,并且会把其目标定在90%甚至95%的高目标。
个人感觉,至少对于安卓的项目,这样的高覆盖率其实是不可取的。比如上面的例子中,安卓源码中的单元测试类ActivityThreadTest中,对ApplicationThread中的内容就完全没有验证,因为这部分代码属于对输入数据的组装和逻辑的分发,并没有什么可执行的逻辑。从我们单元测试作用的角度上讲,其并符合任何一条作用,因此对于这种代码写单元测试也是没有意义的。
所以,单元测试应该覆盖的是我们的所有逻辑处理代码。如果是我来做的话,我会选择把ApplicationThread抽成单独的类,并且打上不需要单元测试的标记避免被统计在内。
在这种场景下,我认为行覆盖率的目标应该设定在85%。未能覆盖的部分,包含不方便抽成单独类的部分,常量定义的部分等等。
4.命名规范
单元测试也是写代码,写代码就应该有一定的规范。
参照安卓源码中的单元测试类,按照如下的方案来制定单元测试的命名规范更为合适。
1).单元测试类对应被测试类,在被测试类后面添加Test代表是对其的单元测试。
比如被测试类为ActivityA的话,则单元测试类的命名为ActivityATest。
2).如果是以方法为维度的单元测试类,则对应的测试方法命名为:test+测试方法。
比如被测试方法为methodA的话,则测试方法为testMethodA(驼峰命名)。
一个测试方法可以覆盖多个源方法,同样,一个源方法也可以拆分成多个测试方法分别验证。
3).如果是以功能点为维度的单元测试类,则对应的测试方法命名为:test+对应的功能点。
比如验证广播能否发送到接收者,则其方法名为:testResult。
二.单元测试的作用
1.单元测试并不能有效提高项目质量
了解到,有的公司用单元测试来替代集成测试甚至是黑盒测试,个人感觉是不可取的。也许,这些公司的单元测试的范围已经覆盖了部分的功能测试,但是单元测试终归是验证的方法级的功能点,如果强行的使其覆盖功能测试,会造成一些不好的效果。
单元测试保证的是一个方法内的输入输出项,当项目开发新需求或者重构的时候,单元测试可以帮助我们快速识别到对原有项目的影响点,这才是单元测试应保证的内容。
2.快速识别新功能的影响
这个很容易理解,如果新的改动影响到了方法中原有的逻辑,则老的单元测试是跑不通的。这时候我们就需要判断,是按照需求修改单元测试用例,还是新写的逻辑有问题了。
3.发现隐藏的问题
这也属于单元测试推行过程中意外的收获。进入到某个页面后,refreshData方法被调用了两次,但是由于两次调用的逻辑都是一致的,所以单看表现,确实不知道这个方法被调用了两次。
但是通过单元测试验证,验证方法执行此时是否为1,则验证出该方法被调用次数并不是1次,而从发现了这个多次调用的问题。
//被检测的类型
public class MVPPresenter implements IMVPActivityContract.IMainActivityPresenter {
//这个方法被调用了多次
@Override
public void requestInfo() {
//请求数据,订阅,并显示
Consumer<InfoModel> consumer = this::processInfoAndRefreshPage;
Flowable<InfoModel> observable = DataSource.getInstance().getDataInfo();
Disposable disposable = observable
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(consumer);
}
}
//单元测试类
public class MVPActivityTest {
@Test
public void testInit() {
...
MVPPresenter mockPresenter = mock(MVPPresenter.class);
...
//验证requestInfo方法被调用的此时是否是1次
verify(mockPresenter, times(1)).requestInfo();
}
}
4.督促我们解决项目中的耦合性
单元测试可以很好的衡量项目耦合度。负责公司项目单元测试体系搭建的时候,和很多项目的负责人进行沟通,有很多人表示,单元测试的case十分难写。排查下来,无一例外,全部都是耦合度太高的原因。他们把大量的功能,以及需要分开执行的环节耦合到一个方法里面。
比如BroadcastReceiver的onReceive方法中,不但执行参数的接收逻辑,还把后续的逻辑操作逻辑一并写在了onReceive方法中,甚至于有的还会在这里new一个线程去执行相关逻辑,这样的耦合度,单元测试方法必然是很难写的。反之,如果项目的耦合度很低,那么单元测试就会很好写。
5.对方法的客观评价
我们经常提到一个概念叫做圈复杂度,对于衡量方法内的圈复杂度,那么单元测试就是一个很好的指标。圈复杂度越高,单元测试代码中,其case就会越多。对于那种方法很短,但是验证case很多的,其圈复杂度往往就会很高。
所以,通过对于单元测试代码的阅读,就可以很容易的衡量出其圈复杂度。
6.注释的补充
有的文章把单元测试称之为最好的注释,因为它可以对一个方法的输入和输出进行一个直观的展示,会比方法的注释更大的详细。但是在我看来,注释和单元测试应该各有各的优势,对于一个方法来说,单元测试的介绍固然比注释介绍的更清晰,但是同时也增加了我们的阅读成本。所以我更愿意称之为是注释的一个补充。
如果只是为了reiview,或者对项目有一些初步的了解,那么注释就足够了。
但是如果阅读源代码是为了在其基础上进行进一步的改造,那么就必须对原有方法有一个深入的了解,因为单元测试就是一个很好的工具。
比如安卓源码中,对ContextImpl中对粘性广播sendStickyBroadcast的介绍有很多,但是反观其单元测试方法:
public void testSetSticky() throws Exception {
Intent intent = new Intent(LaunchpadActivity.BROADCAST_STICKY1, null);
intent.putExtra("test", LaunchpadActivity.DATA_1);
ActivityManager.getService().unbroadcastIntent(null, intent,
UserHandle.myUserId());
ActivityManager.broadcastStickyIntent(intent, UserHandle.myUserId());
addIntermediate("finished-broadcast");
IntentFilter filter = new IntentFilter(LaunchpadActivity.BROADCAST_STICKY1);
Intent sticky = getContext().registerReceiver(null, filter);
assertNotNull("Sticky not found", sticky);
assertEquals(LaunchpadActivity.DATA_1, sticky.getStringExtra("test"));
}
通过单测代码,我们就可以清楚的知道,粘性广播是一种允许先发送,后注册也可以接收到的广播。
最后感谢每一个认真阅读我文章的人,礼尚往来总是要有的,虽然不是什么很值钱的东西,如果你用得到的话可以直接拿走:
这些资料,对于【软件测试】的朋友来说应该是最全面最完整的备战仓库,这个仓库也陪伴上万个测试工程师们走过最艰难的路程,希望也能帮助到你!有需要的小伙伴可以点击下方小卡片领取