起因
最近摊上个大事儿。某次迭代发布中,某一个注册流程因为密码/验证码参数传反,又恰好因为种种原因黑盒测试没有覆盖到,最终只能通过重新发包解决。反思这个问题的原因,其一,这种参数类型一致的函数调用,编译器没办法判断对错,又难免由于手抖、神智不清、敲码过快等莫名其妙的原因输入错误;其二,不能过分依赖黑盒覆盖,特别是在测试资源有限的前提下。根据很久以前的一些经验,觉得重拾一下单元测试这个技能,应该可以作为一种测试的补充,来规避这些低级错误。于是,就针对现有代码,做了一次单元测试实践。
环境与平台
android studio 2.1.2
JRE 1.8.0
单元测试工具 AndroidJUnitRunner
gradle 版本 2.10
被测试代码架构 MVP
建立用例与重构
当初团队内部刚刚推行MVP的时候,就了解到这个架构对单元测试比较友好,这次可以体验一下究竟有多友好了。闲话少叙,书接上文,首先要明确一下我们的初始目标:避免某些函数调用中,同类型参数传反的笔误。那么从大的方向上来思考,针对MVP这种结构,我们应当伪造一个View,然后以测试Presenter为主,并且需要在Presenter与Model的数据传递中来判别参数是否传的正确;然而具体到具体的问题来具体分析的时候,就发现情况没那么简单。不是以测试驱动开发的代码,在添加单元测试用例的时候很难不去修改源码——小范围的重构是难免的,或许还要破坏一些原有类的封装。在写用例之前,你需要模拟好测试运行前的环境、解掉该解掉的依赖、定义需要注入断言程序运行的接口等等事项。
真实的代码环境
网上似乎所有的单元测试相关的博客都喜欢拿计算器来说事儿,MVP的则喜欢用登陆注册做示例,然而真实的登陆注册往往要复杂得多。手机、邮箱、社交账号几种方式,再根据界面设计上的相似性抽象复用,最终的要考虑的实际问题有很多。现在单以注册流程为例,被测试的 presenter 类,针对的是注册的最后一步界面。假设现在要实现一个邮箱账户注册的单元测试用例,那么针对邮箱账号的注册接口的调用,就是界面上确认按钮的点击响应,在被测试的 presenter中 实现是这个样子的:
public void confirm() {
switch (mView.getMode()) {
case AccountConfirmActivity.MODE_CHANGE_PASSWORD:
resetPassword();
break;
case AccountConfirmActivity.MODE_FORGET_PASSWORD:
resetPassword();
break;
case AccountConfirmActivity.MODE_REGISTER:
register();
break;
}
}
private void register() {
switch (mView.getPlatform()) {
case AccountConfirmActivity.PLATFORM_EMAIL:
TaoXiaoQiUser.getInstance().registerAccountWithEmail(mCountryCode, mEmail, mView.getPassword(), mIRegisterCallback);
break;
case AccountConfirmActivity.PLATFORM_PHONE:
TaoXiaoQiUser.getInstance().registerAccountWithPhone(mCountryCode,mPhoneNum,mView.getPassword(),mView.getValidateCode(),mIRegisterCallback);
break;
}
}
首先,界面的复用导致代码上对确定按钮的响应有不同的处理,具体是那种情况取决于View层的回调;其次,由于网络请求接口直接使用SDK封装过的,所以代码中并没有model这一层,而是直接调用SDK、传回调接口的方式实现。整个流程还没完,对于SDK的注册调用,传入的回调接口定义如下:
private IRegisterCallback mIRegisterCallback = new IRegisterCallback() {
@Override
public void onSuccess(User user) {
mHandler.sendEmptyMessage(MSG_REGISTER_SUCC);
}
@Override
public void onError(String errorCode, String errorMsg) {
Message msg = MessageUtil.getCallFailMessage(MSG_REGISTER_FAIL, errorCode, errorMsg);
mHandler.sendMessage(msg);
}
};
可以看出,回调经过了简单的包装之后交给了handler:
@Override
public boolean handleMessage(Message msg) {
switch (msg.what) {
case MSG_REGISTER_FAIL:
mView.modelResult(msg.what, (Result) msg.obj);
break;
case MSG_REGISTER_SUCC:
loginSuccess();
break;
}
return super.handleMessage(msg);
}
成功的分支走登陆成功的逻辑,这里面耦合了登陆成功之后的统计、配置处理;失败的分支回调给了View层,一般是做显示出错提示处理。
差点漏掉了,要测试这个类的前提是要有这个类的对象实体,显然我们需要知道它的构造是什么:
public AccountConfirmPresenter(Activity activity, IAccountConfirmView validateCodeView){
super(activity);
mView = validateCodeView;
mContext = activity;
initData(activity);
}
下手前的问题
要知道写一个单元测试用例跟把大象装进冰箱差不多——都要分三步,先模拟好运行环境;再输入用例数据;最后断言函数运行导致的变化。说起来简单,但看到上边整个注册流程,总有些无从下手,把能想到的问题都罗列下来。
- 要测试函数confirm,无参,却要运行注册-邮箱这个流程上,如何控制;
- 回到最初的问题,注册接口的参数传反的预判,可是最终调用到的私有函数register中直接调用了SDK的接口,如何断言参数的传递是正确的;
- 在哪里断言所有流程跑通的结果;
- loginSuccess涉及到应用配置、统计结果,这部分是不应当放入在单元测试中的,如何处理;
- 用例数据应当如何设计;
- 构造函数中,initData 根据Activity的Intent获取了几个初始数据,手机号/邮箱/默认国家码等,这些数据怎么处理。
破题
有问题自然有答案,没有答案的话我们可以修改问题,再弄出个答案。换句话说,实在搞不定,只能修改源码,让源码具有更强的可测试性。好在以上几个问题还不至于需要大规模的修改功能代码。
第一个与第六个问题其实就是模拟运行环境的问题,从confirm到register,可以看到分支的选区依赖于View的接口getMode、getPlatform,回到构造函数,本身我们也是要伪造一个View层传递进去的。所以我们需要伪造一个可以传递我们用例数据的View传递给被测试类,至于构造函数中的initData,这个方法我们不需要它执行,因为我们需要自己的测试数据来设定手机号/邮箱/默认国家码,这种情况下,就需要修改 initData 函数的权限为 protected ,新建一个 ForkAccountConfirmPresenter 类覆盖掉这个方法的运行。那么,现在我们的测试对象变成了 ForkAccountConfirmPresenter;
第二个问题是核心要解决的问题,乍一看也是一大难点。好在SDK函数TaoXiaoQiUser.getInstance()返回的是一个接口 ITaoXiaoQiUser,让问题找到了答案。可以把这个接口的获取抽象成一个protected函数,同样利用继承覆盖的方式,注入我们自己的mock User;
第三、四个问题需要一起来说明。loginSuccess本身是一个不需要执行的、需要解掉的依赖,跑个测试用例影响到数据统计和应用配置是不允许的,那么这里的处理跟 initData 函数一样就好。至于断言的位置,在本用例中传递参数是否正确是需要断言的,也就是说我们 mock 的 User 中需要有断言能力;再一点就是整个流程的走通,注册逻辑无非是成功与否两种情况,根据代码可以知道,注册失败的逻辑最后会返回到View层的 modelResult 函数、成功的逻辑会调用loginSuccess函数。也就是说,伪造的View的modelResult、继承覆盖的loginSuccess需要有直接或者间接的断言能力;
上面几个问题讲的是用例实现需要源码改动、新建辅助测试类等需要行的方便,第五个问题则是用例数据设计的本身。用例要覆盖到代码的所有分支,一般就要看输入数据是否覆盖到了所有的情况,通常是普通数据、边界数据和无效数据三种情况,再考虑到测试与断言使用的便捷性。先定义一个数据类包含输入参数、输出断言的数据等,再定义一个专用的数据类,描述所有可能的情况即可。在测试用例中,调用不同的数据断言对应的不同输出。
实现
这里贴部分代码,足以解释上面破题的思路就好。
被测试类
public class AccountConfirmPresenter extends BasePresenter{
private void register() {
switch (mView.getPlatform()) {
case AccountConfirmActivity.PLATFORM_EMAIL:
getTaoXiaoQiUser().registerAccountWithEmail(mCountryCode, mEmail, mView.getPassword(), mIRegisterCallback);
break;
case AccountConfirmActivity.PLATFORM_PHONE:
getTaoXiaoQiUser().registerAccountWithPhone(mCountryCode,mPhoneNum,mView.getPassword(),mView.getValidateCode(),mIRegisterCallback);
break;
}
}
protected ITaoXiaoQiUser getTaoXiaoQiUser(){
return TaoXiaoQiUser.getInstance();
}
protected void initData(){
//...
}
protected void loginSuccess() {
//...
}
//for test
public void setCountryCode(String mCountryCode) {
this.mCountryCode = mCountryCode;
}
//for test
public void setPhoneNum(String mPhoneNum) {
this.mPhoneNum = mPhoneNum;
}
//for test
public void setEmail(String mEmail) {
this.mEmail = mEmail;
}
}
原本的被测试类 AccountConfirmPresenter ,开放了三个私有变量的set函数供测试用例配置测试数据;两个函数权限由private降级到protected,方便Fork类覆盖;SDK调用接口被抽象为protected级函数调用,同样为了传入山寨货User。
山寨被测试类
public class ForkAccountConfirmPresenter extends AccountConfirmPresenter {
private MockTaoXiaoQiUser mMockUser;
private boolean mIsCallLoginSuccess = false;
public ForkAccountConfirmPresenter(Activity activity, IAccountConfirmView validateCodeView,MockTaoXiaoQiUser user) {
super(activity, validateCodeView);
mMockUser = user;
}
@Override
protected ITaoXiaoQiUser getTaoXiaoQiUser() {
return mMockUser;
}
@Override
protected void initData(Activity activity) {
//do nothing
}
@Override
protected void loginSuccess() {
mIsCallLoginSuccess = true;
}
public boolean isIsCallLoginSuccess(){
return mIsCallLoginSuccess;
}
}
山寨类通过继承覆盖掉了众多不该调用的函数、接入了伪造的User、提供了loginSuccess被调用的断言标志。
伪造User
public class MockTaoXiaoQiUser implements ITaoXiaoQiUser {
@Override
public void registerAccountWithEmail(final String countryCode, final String email, final String passwd, final IRegisterCallback callback) {
if(email.equals(UnitCaseData.MOCK_REGIST_EMAIL_INPUT_SUCCESS.userName)){
Assert.assertSame(passwd, UnitCaseData.MOCK_REGIST_EMAIL_INPUT_SUCCESS.passWord);
callback.onSuccess(null);
}else if(email.equals(UnitCaseData.MOCK_REGIST_EMAIL_INPUT_FAIL.userName)){
Assert.assertSame(passwd, UnitCaseData.MOCK_REGIST_EMAIL_INPUT_FAIL.passWord);
callback.onError(UnitCaseData.MOCK_REGIST_EMAIL_INPUT_FAIL.errorCode, UnitCaseData.MOCK_REGIST_EMAIL_INPUT_FAIL.errorMsg);
}else{
Assert.fail();
}
}
}
伪造的User类断言了参数是否正确、要覆盖到所有测试数据的调用返回,实际上是个模拟服务端返回的家伙。
伪造的View
public class MockAccountConfirmView implements IAccountConfirmView {
private int mMockMode = 0;
private int mMockPlatform = 0;
private String mMockCode;
private String mMockPassword;
private IAccountAssert mIAccountAssert;
public interface IAccountAssert{
void check(int what, Result result);
}
public MockAccountConfirmView(){
}
public void setmMockMode(int mMockMode) {
this.mMockMode = mMockMode;
}
public void setmMockPlatform(int mMockPlatform) {
this.mMockPlatform = mMockPlatform;
}
public void setmMockCode(String mMockCode) {
this.mMockCode = mMockCode;
}
public void setmMockPassword(String mMockPassword) {
this.mMockPassword = mMockPassword;
}
public void setmIAccountAssert(IAccountAssert mIAccountAssert) {
this.mIAccountAssert = mIAccountAssert;
}
@Override
public String getValidateCode() {
return mMockCode;
}
@Override
public String getPassword() {
return mMockPassword;
}
@Override
public void modelResult(int what, Result result) {
if(mIAccountAssert != null){
mIAccountAssert.check(what,result);
}
}
@Override
public int getMode() {
return mMockMode;
}
@Override
public int getPlatform() {
return mMockPlatform;
}
}
伪造的View是对Activity的替代。它伪造了环境标志MODE&PLATFORM、伪造了密码/验证码供测试用例设置、建立了IAccountAssert断言接口供测试用例断言modelResult函数的回调情况。
测试用例
@Test
public void emailRegister() throws Exception {
//配置环境
mMockAccountConfirmView.setmMockCode(AccountConfirmActivity.EXTRA_ACCOUNT_CONFIRM_MODE);
mMockAccountConfirmView.setmMockPlatform(AccountConfirmActivity.PLATFORM_EMAIL);
mMockAccountConfirmView.setmMockPassword(UnitCaseData.MOCK_REGIST_EMAIL_INPUT_SUCCESS.passWord);
mPresenter.setEmail(UnitCaseData.MOCK_REGIST_EMAIL_INPUT_SUCCESS.userName);
mMockAccountConfirmView.setmIAccountAssert(new MockAccountConfirmView.IAccountAssert() {
@Override
public void check(int what, Result result) {
Assert.assertEquals(what, AccountConfirmPresenter.MSG_REGISTER_SUCC);
}
});
//调用被测试接口
mPresenter.confirm();
//结果断言
Assert.assertTrue(mPresenter.isIsCallLoginSuccess());
}
严格来讲这只是小半个测试用例,因为它只包含了注册成功的部分流程。
一步一个脚印(kēng)
实现上当然不是一气呵成的。android studio上第二次使用AndroidJUnitRunner依然是坑点多多,上一次是半年以前、也只是基本过了一下,记忆并非犹新、跟没有一样。加之android studio本身升级、工程各路依赖冲突,真是举步维艰。
配置
关于单元测试的配置都是在gradle配置中完成,首先AndroidJUnitRunner的配置:defaultConfig { //... testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" }
其次,依赖:
dependencies { //... testCompile 'junit:junit:4.12' //ADD THESE LINES: androidTestCompile 'com.android.support.test:runner:0.2' androidTestCompile 'com.android.support.test:rules:0.2' androidTestCompile 'com.android.support.test.espresso:espresso-core:2.1' }
这应该不是最新版本的依赖,也可能并非都是必须的,完全取决于用例的需求。不依赖于android特殊实例(Activity、Context等)的用例属于纯功能型的,只需要testCompile的依赖,它的代码位置在src目录下的test目录中;需要android界面支持的依赖是androidTestCompile,用例代码在src的androidTest目录下。测试用例的代码最好使用android studio自带的工具方法生成,它可以保证你的用例代码在正确的位置。
异常: ‘testClasses’ not found
正常的工程目录一般都有不少的module依赖,而不是像各种实例demo中只有一个干巴巴的app module。这个时候或许会遇到这个异常:
Error:FAILURE: Build failed with an exception.
* What went wrong:
Task 'testClasses' not found in project ':TaoXiaoQiSdk'.
* Try:
Run gradle tasks to get a list of available tasks. Run with --stacktrace option to get the stack trace. Run with --info or --debug option to get more log output
这个时候,需要在提示的model的gradle配置文件中添加以下配置,表示不要瞎管闲事,这里没有需要测试的类:
task testClasses { doLast { println 'This is a dummy testClasses task' } }
错误: 程序包android.support.test.rule不存在
这个时候需要检查你的gradle配置,各种依赖是否配置到位,最重要的是AndroidJUnitRunner在defaultConfig中是否漏配。早期的android studio版本需要在Build Variants中选择对应的测试用例方式,2.1之后已经默认使能了,如果想回复之前的样子需要在setting中取消勾选:
按目录找不是很靠谱,因为android studio在不断升级中各种功能似乎会换位置,直接搜关键字这招简单粗暴又实用。
测试用例的位置
如果不依赖android的实例,仅仅使用JUnit Test的话,自然是百无禁忌,哪个module的测试类,就放到哪个目录下就好;但如果需要android页面支持,则最好统一放到application module的代码目录下。我遇到的问题是在子module下运行子module的测试用例时,会报dex方法数超出65535的错误,关于多dex处理只有在application module的配置中处理时,最好把用例都放在此目录下,避免这种问题。
依赖异常
遇到了几种冲突,其一:
Error:Conflict with dependency ‘com.google.code.findbugs:jsr305’. Resolved versions for app (3.0.0) and test app (2.0.1) differ. See http://g.co/androidstudio/app-test-app-conflict for details.
在gradle文件中增加如下配置:
android{ //... configurations.all { resolutionStrategy.force 'com.google.code.findbugs:jsr305:3.0.0' } }
其二:
Error:Conflict with dependency ‘com.android.support:support-annotations’. Resolved versions for app (23.1.1) and test app (22.0.0) differ. See http://g.co/androidstudio/app-test-app-conflict for details.
在依赖配置中增加annotations的设置:
androidTestCompile 'com.android.support:support-annotations:23.1.1'
以上解决方案来源于 stackoverflow 。
奔跑吧,用例
把源码注册部分参数改反,之后运行用例,可以得到:
java.lang.AssertionError
at org.junit.Assert.fail(Assert.java:92)
at org.junit.Assert.fail(Assert.java:100)
at login.mock.MockTaoXiaoQiUser.registerAccountWithEmail(MockTaoXiaoQiUser.java:58)
at com.TaoXiaoQi.stencil.presenter.login.AccountConfirmPresenter.register(AccountConfirmPresenter.java:214)
at com.TaoXiaoQi.stencil.presenter.login.AccountConfirmPresenter.confirm(AccountConfirmPresenter.java:193)
at login.AccountConfirmPresenterTest.emailRegister(AccountConfirmPresenterTest.java:70)
at java.lang.reflect.Method.invokeNative(Native Method)
at java.lang.reflect.Method.invoke(Method.java:511)
at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:45)
at ...
Tests ran to completion.
成功预防了参数传反的低级错误。
总结
- 在类似MVP架构的代码中做单元测试确实十分便捷,无法想象如果是之前把控制逻辑糅合在Activity代码中的做法如何进行测试。并且在理论上,只对presenter/module进行测试的话无需依赖android环境,测试效率会大大提高。当然,为图省事儿在本例中还是使用了android环境,后续可以考虑采用mock库来模拟Activity的行为,来摆脱对Instrumentation的依赖。
用例的添加,会对源码进行一定程度的“破坏”。测试需要,暂时无法避免,目前想到降低损失的方法,可以增加必要的文字注释;类似android源码的“@Test”注释,也是一个可以考虑的方向;一些属性开set接口的行为,可以使用反射代替。此外,新代码编写的过程中,要考虑到代码的可测试性也是必要的。
时间损耗。以上编写一个接口函数之中一种流程的一种情况的测试用例,大概消耗了我4个工时的时间。当然这里包含了源码重构、用例设计、数据结构构思、开发工具单元测试部分熟悉、异常解决等等各方面的时间,实际上第一个用例跑通之后,同一个被测试类的其他用例和测试数据的建立速度是很快的。但用例的规模和有效性,是由业务的复杂程度决定的,并且用例也是需要维护的,这部分成本是需要衡量的。
接口是个好东西,山寨伪造、接缝注入,单元测试必备良药。