先简单了解一下单元测试,对软件中的最小可测试单元进行测试,一般是函数。
接下来说说它的作用,(1)能够验证程序的准确性,为开发提供保障,能放心大胆的修改和重构。
(2)能规范我们的设计,能写单元测试的程序其耦合度更低。
(3)通过测试case,能很好的了解一个功能点涉及到的其他隐藏功能点,从这一点上来看是很好的文档。
好了,上面说到了这么多好处,那么开发人员有多少人写单元测试呢?嗯,大约58.6的人, 基本上不写单元测试,还有16.6的人从来不写单元测试。
再来说说Android,ios这类GUI程序,由于有很多交互行为,耦合度较高,写单元测试的难度大大增加(从谷歌的一份demo中看到纯java的工具类测试
覆盖率可以到60以上,但是Android部分基本在10-30之间),估计写的人就更少了。
写的人虽然少,但是单元测试还是很有意义的,下面开始我们的Android单元测试之旅。
首先,来认识一下UT(单元测试,以下用UT替换)相关的概念和框架,由于资料的混杂和对UT的不熟悉,一开始真是把我整懵逼了。
(1)JUnit(推荐使用JUnit4)
java语言的UT框架。
(2)AndroidJUnitRunner:是一个测试运行器,用于运行JUnit的Android测试包。
(3)Roboletric:比Instrumenttation更强大,运行更快的Android自动化测试工具类 ,只在JVM中运行。
(4) Mockito:一个测试框架,用于解耦程序中耦合度较高的部分。
(4)Espresso:Google推出的Instrumentation UI测试框架,API支持丰富。
(5)Instrumentation:早期Google提供的用于Android的自动化测试工具类,可以测试按键点击,滚动等Android相关问题。
说明:经过实践比对,我们最终用到的是前4个,后两个可以了解一下。
下面开始实战,从最简单的入手,用JUnit测试一个工具类。
环境部署:AS中集成了JUnit框架,无须额外部署。
(1)我们先创建一个被测试类,util.class,其中有一个读取文件的方法,我们接下来就测试这个方法。
public class util {
public static String readTxtFile(String filePath){
String encoding="utf-8";
File file=new File(filePath);
StringBuilder sb = new StringBuilder();
if(file.isFile() && file.exists()){ //判断文件是否存在
InputStreamReader read = null;//考虑到编码格式
try {
read = new InputStreamReader(
new FileInputStream(file),encoding);
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
} catch (FileNotFoundException e) {
e.printStackTrace();
}
BufferedReader bufferedReader = new BufferedReader(read);
String lineTxt = null;
try {
while((lineTxt = bufferedReader.readLine()) != null){
sb.append(lineTxt);
System.out.println(lineTxt);
}
} catch (IOException e) {
e.printStackTrace();
}
try {
read.close();
} catch (IOException e) {
e.printStackTrace();
}
return sb.toString();
}else{
System.out.println("找不到指定的文件");
return null;
}
}
}
(2)然后打开这个类,右键 → go to → Test → Create new Test…
setUp@Before :用以unitTest前的数据初始化等。生成的Test类会有一个@Before注解的setUp方法。
tearDown@After :用以unitTest后的垃圾回收等操作。生成的Test类会有一个@After注解的tearDown方法。
然后在setUp方法中new 一个util对象。
然后就可以在@Test注解的方法中愉快的写测试case了。
怎么写呢?
这里可以用assertEquals(), 第一个参数是期望值,第二个参数是实际值。 JUnit框架还有很多其他的方法。
assertEquals(null , util.readTxtFile(null));
1
好,然后我们来运行一下,右键run。看结果:
嗯,错了,报了NullPoint错误,我们来修改一下方法,加一个判空。
在运行,绿色的读条,嗯,对了。
很简单是不是,那下面开始Android的测试。
开始之前先说一下为什么我们选择Roboletric而不是Espresso,因为前者是在jvm上运行的,快啊;后者还是需要在模拟器或者真机上跑一次的,
想一下AS那编译速度,知道为什么选前者了吧。
环境配置:
testCompile'org.robolectric:robolectric:3.0'
androidTestCompile 'com.android.support:support-annotations:23.4.0'
androidTestCompile 'com.android.support.test:runner:0.4'
androidTestCompile 'com.android.support.test:rules:0.4'
到这儿,你应该已经出错了:
Conflict with dependency ‘com.android.support:support-annotations’. Resolved versions for app (23.1.0) and test app (23.0.1) differ)
第一种解决方法:androidTestCompile ‘com.android.support:support-annotations:23.1.0’
第二种解决方法:在Top Level Gradle文件中配置所有(如果还有其他地方用到了annotations,第一种方法就又会报错):
allprojects {
configurations.all {
resolutionStrategy.force ‘com.android.support:support-annotations:23.4.0’
}
}
参考:http://stackoverflow.com/questions/33317555/conflict-with-dependency-com-android-supportsupport-annotations-resolved-ver
解决了上面的问题后我们继续:
我们创建一个LoginActivity,输入手机号,密码,点击登陆后先验证手机号和密码,如果符合规则,进度条就显示,并开始登陆。
先看一下我们的验证程序:
/*check rule*/
public boolean checkRule(String phone , String password){
Pattern pattern = Pattern.compile("^1[3|4|5|7|8][0-9]\\d{8}]$");
if(phone == null)
return false;
Matcher matcher = pattern.matcher(phone);
if(!matcher.find()) {
Toast.makeText(mContext , "手机号格式不正确!" , Toast.LENGTH_SHORT).show();
return false;
}
if("".equalsIgnoreCase(password)){
Toast.makeText(mContext , "密码不能为空!" , Toast.LENGTH_SHORT).show();
return false;
}
return true;
}
先开一下数据和控件的初始化:
@Before
public void setUp() throws Exception {
loginActivity = Robolectric.setupActivity(LoginActivity.class);
mEtPwd = (EditText) loginActivity.findViewById(R.id.activity_login_et_pwd);
mEtUser = (EditText) loginActivity.findViewById(R.id.activity_login_et_user);
mProgressBar = (ProgressBar) loginActivity.findViewById(R.id.activity_login_progressbar);
mTvLogin = (TextView) loginActivity.findViewById(R.id.activity_login_tv_login);
}
然后是测试验证程序,注意Toast弹出的测试:
@Test
public void testCheckRule() throws Exception {
// assertEquals(true , loginActivity.checkRule("13992758986" , "123"));
assertEquals(false , loginActivity.checkRule("13992758986" , ""));
assertEquals(false , loginActivity.checkRule("13992758986" , null));
assertEquals(false , loginActivity.checkRule("1399275898" , "wang123"));
assertEquals(false , loginActivity.checkRule("10992758986" , "wang123"));
assertEquals(false , loginActivity.checkRule("" , "wang123"));
assertEquals(false , loginActivity.checkRule(null , "wang123"));
loginActivity.checkRule("123" , "");
assertEquals("手机号格式不正确!" , ShadowToast.getTextOfLatestToast());
assertEquals(false , loginActivity.checkRule("109927589861" , "wang123"));
assertEquals(false , loginActivity.checkRule("13992758*86" , "wang123"));
assertEquals(false , loginActivity.checkRule("139927&^%%#" , "wang123"));
}
然后是点击登陆按钮,注意进度条的显隐测试:
public void testLogin() throws Exception {
mEtUser.setText("18380173957");
mEtPwd.setText("wang123");
assertEquals(View.VISIBLE , mProgressBar.getVisibility());
mTvLogin.performClick();
assertEquals(View.VISIBLE , mProgressBar.getVisibility());
}
以上只是简单的测试了Toast的弹出,控件的隐藏显示等。其他诸如Fragment,广播,资源文件访问,Activity跳转等
只是不同的方法,查看具体API即可。
前面讲的都是耦合度比较低的情况,那么当耦合度比较高,比如网络调用怎么办呢?结果回调里面的数据怎么模拟呢?
这就需要另外一个框架来解耦了:mockito
环境搭建:testCompile “org.mockito:mockito-core:1.+”
先看被测试类,是一个OkHttp调用网络的例子,写正常情况下是不会这样写的,先别问为什么这样,代码如下:
public class CallbackTestActivity extends AppCompatActivity {
public RequestCall call;
public TextView mTvTest;
public String result;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_callback_test);
call = OkHttpUtils.get()
.url("http://120.132.7.14:8080/lanmao/api/register.php")
.addParams("phone" , "1")
.build();
mTvTest = (TextView) findViewById(R.id.activity_callback_tv_test);
mTvTest.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
register();
}
});
}
/*设置单元测试传递过来的call*/
public void setCall(RequestCall call){
this.call = call;
}
/*点击调用网络*/
public void register(){
call.execute(new Callback() {
@Override
public Object parseNetworkResponse(Response response, int id) throws Exception {
return null;
}
@Override
public void one rror(Call call, Exception e, int id) {
}
@Override
public void onResponse(Object response, int id) {
result = response.toString();
}
});
}
}
测试类必须加注解:
@RunWith(RobolectricGradleTestRunner.class)
@Config(constants = BuildConfig.class, sdk = 21)
然后我们mock一个RequestCall对象(用注解实现)。
将上一步创建的RequestCall对象set到被测试类中。
完整代码如下:
@RunWith(RobolectricGradleTestRunner.class)
@Config(constants = BuildConfig.class, sdk = 21)
public class CallbackTestActivityTest {
private CallbackTestActivity callbackTestActivity;
@Mock
private com.zhy.http.okhttp.request.RequestCall call;
@Captor
private ArgumentCaptor<com.zhy.http.okhttp.callback.Callback> callbackArgumentCaptor;
@Before
public void setUp() throws Exception {
MockitoAnnotations.initMocks(this);
callbackTestActivity = Robolectric.setupActivity(CallbackTestActivity.class);
callbackTestActivity.setCall(call);
}
@Test
public void testCallback(){
/*开始请求*/
callbackTestActivity.register();
/*假结果*/
final String result = "hello , world";
Mockito.verify(call, Mockito.times(1)).execute(callbackArgumentCaptor.capture());
assertEquals(callbackTestActivity.result , null);
callbackArgumentCaptor.getValue().onResponse(result , 1);
assertEquals(callbackTestActivity.result , result);
}
}
现在再来看为什么我们的被测试类要将RequestCall单独new出来,再execute了,因为需要在测试类中传一个mock的RequestCall对象回去。
同时我们还提供了一个set方法。
所以这里暴露了一个缺点,就是侵入性很强,还有一个体现点是要测试的方法需要设置为public。
到这里,一个完整的单元测试教程就完了。里面的框架都只举了一个例子,但是难度比较大的配置和使用问题都解决了,另外API数量
众多,需要在实际使用中学习。
总结:单元测试好处有很多,但是也有一些弊端,比如侵入性强,工作量比较大(一般是被测试代码的3倍),Android等GUI程序测试覆盖率不高等。在实际环境中应灵活选择。希望更多的开发者重视并开始对项目做单元测试。
附录:
(1)美团点评技术团队的一篇博客,里面讲了单元测试的设计流程,可以作为学习单元测试的大纲:
http://tech.meituan.com/Android_unit_test.html
(2)学习 Robolectric其他API用法的博客:
http://www.jianshu.com/p/9d988a2f8ff7