为什么要做单元测试
学习过或者了解软件工程的人一定对这个东西不陌生,很多人也知道这个东西很重要,但是总是以各种借口来推脱,这其中就包括我。大学我学习的并不是软件工程,所以对什么黑盒测试、白盒测试、灰盒测试只是听说过,并没有什么具体感觉。前段时间正好看了bob大叔的的《代码整洁之道》和 另外一本经典《重构:改善既有代码的设计》,两位作者都对单元测试以及TDD(测试驱动开发)推崇备至,我看完之后也是激动不已,正好手头有个新项目。于是当即决定将这种开发模式引入进来。
题外话说了这么多,我们就先来看看写单元测试的一些好处吧!
1. 开发新功能时,避免遗漏功能点
我们在开发的过程中,常常出现因为新功能太多而遗忘的情况。等我们提测之后,看到满屏的bug,心里一定是崩溃的。但是如果我们先写好单元测试,或者说打好桩,然后根据功能点一个一个的开发,既避免了遗忘,又能测试我们的代码,一举两得啊!
2. 重构代码时,避免影响其他功能
重构的时候,我们最担心的就是影响其他模块或功能。但是如果我们提前写好了单元测试,我们就能很轻易的就发现我们在重构的过程中出现的side effect
3. 提高我们的编程能力
单元测试写多了之后,我们很容易就能在编码的过程中注意到各种边界条件,从而写出健壮性更好的程序
4. 提高我们的效率
很多人看到这点的时候会觉得奇怪。虽然看起来我们花了很多时间在编写单元测试上,但是一旦单元测试写好了之后,基本上就是一劳永逸的,难道你不觉得让电脑自动去测试比我们挨个挨个去点我们的app效率更高吗?更何况,假设有人过来接手我们写的项目的时候,他们只需要打开单元测试就知道我们这个项目,这个模块做了些什么事情,就能更快速的上手了。
看了这么多单元测试的好处之后,是不是有些跃跃欲试了?先不着急,我们先来看看Clean Architecture 的结构,分析分析Clean Architecture 的特点再对症下药。
Clean Architecture 中,它的业务逻辑代码放在了domain 层,是纯 java 代码,data 层用到了一些 android 平台的东西,包括网络访问、数据存储等。UI 层又划分成了P(presentor) 和 V(view),presentor 是纯 java代码,view 部分才是跟 android 紧密相关的。所以我们需要两类工具:测试纯java代码的和测试android相关的。
Java 代码的单元测试
纯 Java 代码测试框架很多,最出名的应该是 Junit 和 TestNG 了。Android Studio 默认使用的是 JUnit 4 ,大概是因为JUnit 4的使用人群最多吧。我们接下来就介绍JUnit 4的功能和特点。
使用JUnit进行单元测试
最开始的JUnit 是由Kent Beck 和Eric Gamma 在飞机上写出来的(膜拜ing)。因为Android Studio已经将其内置了,所以我们就不需要再额外引入了。只需要在我们想要添加单元测试的module 的build.gradle 中添加下面这句话:
testCompile "junit:junit:4.12"
一般来说,单元测试分为三步:
setup:即new 出待测试的类,设置一些前提条件
执行动作:即调用被测类的被测方法,并获取返回结果
验证结果:验证获取的结果跟预期的结果是一样的
一个简单的例子
假设我们的代码中有一个购物车类和一个商品类,每当一个商品加入到购物车之后,购物车会计算商品总价。
public class Goods {
private String name;
private long id;
private long price;
private int quantities;
public Goods(long id){
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public long getId() {
return id;
}
public long getPrice() {
return price;
}
public void setPrice(long price) {
this.price = price;
}
public int getQuantities() {
return quantities;
}
public void setQuantities(int quantities) {
this.quantities = quantities;
}
public long getTotalPrice() {
return price * quantities;
}
}
生成测试代码
我们在Goods.java 的源码中任意位置单击右键,按照下图所示,Android Studio 就会引导我们生成测试类。
Testing library 一栏选择JUnit4。class name 就用默认的,否则就无法从被测试类快速跳转到测试类中了。如果我们要做一些初始化工作,我们就需要勾选setUp。然后在下面方法列表中选择我们想要测试哪些方法。一般来说,我们不会去测试代码中简单的 setter 和 getter , 除非里面有很复杂的逻辑代码。所以,我们这里只测试 getTotalPrice 这个方法;
最后在生成的代码中添加以下测试代码:
public class GoodsTest {
private Goods goods;
@Before
public void setUp() throws Exception {
goods = new Goods(1);
}
@Test
public void testGetTotalPrice() throws Exception {
goods.setPrice(112);
goods.setQuantities(10);
Assert.assertEquals(112*12,goods.getTotalPrice());
}
}
其中 setup 方法对应的前面所说的 setup 部分,testGetTotalPrice 方法的前两句对应前面所说的执行动作,而最后一句 Assert 就是第三部分验证结果。
我们可以单击方法名左侧的绿色三角按钮来测试单个方法,或者快捷键 Ctrl+Shift+F10 来运行整个测试类。测试结果会在下方显示:
测试失败,运行结果会告诉我们在哪一行出错了以及期望的结果是什么,而实际结果又是什么。
使用Mock框架
在写单元测试的过程中,一个很普遍的问题是,要测试的类会有很多依赖,这些依赖的类/对象/资源又会有别的依赖,从而形成一个大的依赖树,要在单元测试的环境中完整地构建这样的依赖,是一件很困难的事情。所幸我们有一个应对这个问题的解决方案:mock。我们使用 mock 框架可以模拟任何类,构建一个假对象,而不需要实际运行这个类,我们可以定义这些假对象上的行为,提供给被测试对象使用。被测试对象像使用真的对象一样使用它们。用这种方式,我们可以把测试的目标限定于被测试对象本身,就如同在被测试对象周围做了一个划断,形成了一个尽量小的被测试目标。
引入Mock框架
Mock的框架有很多,最为知名的一个是Mockito,这是一个开源项目,使用广泛。同样,在build.gradle中添加以下语句:
testCompile "org.mockito:mockito-core:1.9.5"
我们先来看一个官方的示例:
import org.mockito.Mockito;
// 创建mock对象
List mockedList = Mockito.mock(List.class);
// 设置mock对象的行为 - 当调用其get方法获取第0个元素时,返回"one"
Mockito.when(mockedList.get(0)).thenReturn("one");
// 使用mock对象 - 会返回前面设置好的值"one",即便列表实际上是空的
String str = mockedList.get(0);
Assert.assertTrue("one".equals(str));
Assert.assertTrue(mockedList.size() == 0);
// 验证mock对象的get方法被调用过,而且调用时传的参数是0
Mockito.verify(mockedList).get(0);
代码中的注释描述了代码的逻辑:先创建mock对象mockedList,然后设置mock对象上的方法get,指定当get方法被调用,并且参数为0的时候,返回”one”;然后,调用被测试方法(被测试方法会调用mock对象的get方法);
上面这个示例揭示了最简单的使用情况,当我最开始看到这个示例的时候,对最后一句很是困惑,觉得这句完全没有必要,直到我在项目中写下面这些代码:
被测试类
public class UserLoginImpl extends UseCase implements UserLogin {
UserRepository userRepository;
private String name;
private String pwd;
@Inject
public UserLoginImpl(UserRepository userRepository,ThreadExecutor threadExecutor, PostExecutionThread postExecutionThread){
super(threadExecutor,postExecutionThread);
this.userRepository = userRepository;
}
@Override
protected Observable buildUseCaseObservable() {
return userRepository.userLogin(name,pwd);
}
@Override
public UserLogin setAccount(String name) {
this.name = name;
return this;
}
@Override
public UserLogin setPwd(String pwd) {
this.pwd = MD5.parseStrToMd5U32(pwd);
return this;
}
}
测试类
public class UserLoginTest {
private UserLoginImpl userLogin;
@Mock private ThreadExecutor mockThreadExecutor;
@Mock private PostExecutionThread mockPostExecutionThread;
@Mock private UserRepository mockUserRepository;
private static final String FAKE_USER_ACCOUNT = "13478969876";
private static final String FAKE_USER_PWD = "13478969876";
@Before
public void setUp() throws Exception {
MockitoAnnotations.initMocks(this);
userLogin = new UserLoginImpl(mockUserRepository,mockThreadExecutor,mockPostExecutionThread);
userLogin.setAccount(FAKE_USER_ACCOUNT);
userLogin.setPwd(FAKE_USER_PWD);
}
@Test
public void testBuildUseCaseObservable() throws Exception {
userLogin.buildUseCaseObservable();
verify(mockUserRepository).userLogin(FAKE_USER_ACCOUNT,FAKE_USER_PWD);
verifyNoMoreInteractions(mockUserRepository);//验证mockUserRepository是否还有其他地方被调用过
verifyZeroInteractions(mockPostExecutionThread); //验证mockPostExecutionThread是否被调用过
verifyZeroInteractions(mockThreadExecutor);//验证mockThreadExecutor是否被调用过
}
}
竟然失败了!它告诉我它期望的密码是13478969876,而实际调用的确是一长串的字符。研究了很久,终于发现了原因:在设置密码的时候,我算出密码的MD5值后,将MD5值存成为密码,执行 userLogin 方法的时候调用的是加密后的字符串,而我在 verify 函数中使用的是没有加密的密码,两者不一致,因而编译器报错。
使用 Mock 框架,不但能让其返回任何我们任何我们需要的数据,而且还能验证我们是否正确调用了,这么强大又好用的功能,还不赶快用起来!
Android 代码的单元测试
android 的单元测试比纯java代码的复杂多了。纯 java 代码我们直接在PC上就能运行了,因为它只依赖JVM。但是android 代码要跑起来当然需要android的运行环境了,比如TextView、Toast等,虽然它也是一个变种的JVM。如果我们的代码还需要在模拟器或真机上才能测试,那效率可想而知有多慢了。有没有能在PC的JVM上就能运行测试代码的工具呢?当然有,接下来我们就介绍我们的主角——Robolectric。
使用Robolectric进行单元测试
Robolectric 是一个开源框架,它实现了一套在 JVM 上能运行的 Android 开发环境。它实现一套 Shadow* 的东西,比如ShadowTextView , ShadowToast等控件。顾名思义,影子对象(Shadow Object)并不是真正的对象,它只是真实对象的一个影子。真实对象做了任何动作,产生了任何效果,我们通过影子对象就能知道,并能够通过影子对象就能知道真实对象的结果。
Robolectric环境搭建
把下面这段话加入到您的build.gradle中来就可以将Robolectric 引入你的项目中了:
testCompile "org.robolectric:robolectric:3.0"
但是如果您的项目使用了MultiDex,那您就需要使用最新的3.2了。
按照前文介绍过的方法,我们生成对应的测试代码,然后通过注解配置TestRunner
@RunWith(RobolectricGradleTestRunner.class)
@Config(constants = BuildConfig.class)
public class LoginActivityTest {
}
Activity 的测试
创建activity 实例
@Test
public void testActivity() {
SampleActivity sampleActivity = Robolectric.setupActivity(SampleActivity.class);
assertNotNull(sampleActivity);
assertEquals(sampleActivity.getTitle(), "SimpleActivity");
}
activity 生命周期
@Test
public void testLifecycle() {
ActivityController activityController = Robolectric.buildActivity(SampleActivity.class).create().start();
Activity activity = activityController.get();
TextView textview = (TextView) activity.findViewById(R.id.tv_lifecycle_value);
assertEquals("onCreate",textview.getText().toString());
activityController.resume();
assertEquals("onResume", textview.getText().toString());
activityController.destroy();
assertEquals("onDestroy", textview.getText().toString());
}
跳转
@Test
public void testStartActivity() {
forwardBtn.performClick();
Intent expectedIntent = new Intent(sampleActivity, LoginActivity.class);
Intent actualIntent = ShadowApplication.getInstance().getNextStartedActivity();
assertEquals(expectedIntent, actualIntent);
}
UI组件状态
@Test
public void testViewState(){
CheckBox checkBox = (CheckBox) sampleActivity.findViewById(R.id.checkbox);
Button inverseBtn = (Button) sampleActivity.findViewById(R.id.btn_inverse);
assertTrue(inverseBtn.isEnabled());
checkBox.setChecked(true);
inverseBtn.performClick();
assertTrue(!checkBox.isChecked());
inverseBtn.performClick();
assertTrue(checkBox.isChecked());
}
Dialog
@Test
public void testDialog(){
//点击按钮,出现对话框
dialogBtn.performClick();
AlertDialog latestAlertDialog = ShadowAlertDialog.getLatestAlertDialog();
assertNotNull(latestAlertDialog);
}
Toast
@Test
public void testToast(){
toastBtn.performClick();
assertEquals(ShadowToast.getTextOfLatestToast(),"we love UT");
}
Fragment
如果使用support的Fragment,需添加以下依赖
testCompile "org.robolectric:shadows-support-v4:3.0"
shadow-support包提供了将Fragment主动添加到Activity中的方法:SupportFragmentTestUtil.startFragment(),简易的测试代码如下
@Test
public void testFragment(){
SampleFragment sampleFragment = new SampleFragment();
//此api可以主动添加Fragment到Activity中,因此会触发Fragment的onCreateView()
SupportFragmentTestUtil.startFragment(sampleFragment);
assertNotNull(sampleFragment.getView());
}
访问资源
@Test
public void testResources() {
Application application = RuntimeEnvironment.application;
String appName = application.getString(R.string.app_name);
String activityTitle = application.getString(R.string.title_activity_simple);
assertEquals("LoveUT", appName);
assertEquals("SimpleActivity",activityTitle);
}
Service的测试
Service的测试类似于BroadcastReceiver,以IntentService为例,可以直接触发onHandleIntent()方法,用来验证Service启动后的逻辑是否正确。
public class SampleIntentService extends IntentService {
public SampleIntentService() {
super("SampleIntentService");
}
@Override
protected void onHandleIntent(Intent intent) {
SharedPreferences.Editor editor = getApplicationContext().getSharedPreferences(
"example", Context.MODE_PRIVATE).edit();
editor.putString("SAMPLE_DATA", "sample data");
editor.apply();
}
}
以上代码的单元测试用例:
@Test
public void addsDataToSharedPreference() {
Application application = RuntimeEnvironment.application;
RoboSharedPreferences preferences = (RoboSharedPreferences) application
.getSharedPreferences("example", Context.MODE_PRIVATE);
SampleIntentService registrationService = new SampleIntentService();
registrationService.onHandleIntent(new Intent());
assertEquals(preferences.getString("SAMPLE_DATA", ""), "sample data");
}
BroadcastReceiver 的测试
首先看下广播接收者的代码
public class MyReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
SharedPreferences.Editor editor = context.getSharedPreferences(
"account", Context.MODE_PRIVATE).edit();
String name = intent.getStringExtra("EXTRA_USERNAME");
editor.putString("USERNAME", name);
editor.apply();
}
}
广播的测试点可以包含两个方面,一是应用程序是否注册了该广播,二是广播接受者的处理逻辑是否正确,关于逻辑是否正确,可以直接人为的触发onReceive()方法,验证执行后所影响到的数据。
@Test
public void testBoradcast(){
ShadowApplication shadowApplication = ShadowApplication.getInstance();
String action = "com.geniusmart.loveut.login";
Intent intent = new Intent(action);
intent.putExtra("EXTRA_USERNAME", "geniusmart");
//测试是否注册广播接收者
assertTrue(shadowApplication.hasReceiverForIntent(intent));
//以下测试广播接受者的处理逻辑是否正确
MyReceiver myReceiver = new MyReceiver();
myReceiver.onReceive(RuntimeEnvironment.application,intent);
SharedPreferences preferences = shadowApplication.getSharedPreferences("account", Context.MODE_PRIVATE);
assertEquals( "geniusmart",preferences.getString("USERNAME", ""));
}
自定义控件的测试
Robolectric 定义了很多影子类,它扩展或继承了Android OS对应的类。当创建一个Android 类,Robolectric 就会查找对应的影子类,如果找到了,Robolectric 就会创建一个影子对象与之对应。当调用Android 类的方法的时候,Robolectric 就会确保对应的方法被调用了。如果 Robolectric 的影子类不能满足您的要求,你还可以安装一定的要求编写自己的影子类。最常见的应该就是自定义控件了。
我们封装了一个toast,它的代码如下所示:
public class FBToast {
private static Toast toast = null;
private static TextView view = null;
public static void showShortToast(Context context, String msg) {
showToast(context,msg,Toast.LENGTH_SHORT);
}
public static void showToast(Context context, String msg, int duration) {
if (toast == null) {
toast = new Toast(context);
view = (TextView) LayoutInflater.from(context)
.inflate(R.layout.publish_toast, null);
view.setText(msg);
view.setPadding(30, 80, 30, 80);
toast.setView(view);
toast.setDuration(duration);
toast.setGravity(Gravity.CENTER, 0, 0);
} else {
view.setText(msg);
toast.setDuration(duration);
}
toast.show();
}
public static void cancel(){
if(toast != null) {
toast.cancel();
}
}
}
代码中使用如下:
@Override
public void showErrorMessage(String message) {
FBToast.showShortToast(this,message);
}
如果我们不扩展它的影子类,那我们是无法测试程序是否正确调用了Toast的相关代码。
FBToast的shadow 对象:
@Implements(FBToast.class)
public class ShadowFBToast extends ShadowToast{
@Implementation
public static void showShortToast(Context context, String msg){
showToast(context,msg,Toast.LENGTH_SHORT);
}
@Implementation
public static void showToast(Context context, String msg, int duration){
ShadowToast.makeText(context,msg,duration).show();
}
public static String getTextOfLatestToast(){
return ShadowToast.getTextOfLatestToast();
}
}
自定义shadow对象要求必须在类定义中加上@Implements(AndroidClassName.class)
注解,并在你在代码中使用的公共方法上也加上@Implementation
注解。最后,你需要在你的测试代码的config注解加上这个自定义Shadow 对象。
@RunWith(RobolectricGradleTestRunner.class)
@Config(constants = BuildConfig.class,shadows = {ShadowFBToast.class})
public class LoginActivityTest {
....
}
RxJava 单元测试的注意事项
RxJava 给我们带来了非常大的便利,它简化了逻辑,避免了"嵌套地狱",逻辑清晰简单,如水银泄地。但是它也给我们进行单元测试带来了一些麻烦。
匿名类的问题
由于Mockito 不支持匿名类,所以我们在使用RxJava的时候要特别注意。相信很多人跟我一样,Subscriber 都是像下面这样写的。
userLogin.setAccount(account)
.setPwd(pwd)
.execute(new BaseSubscriber() {
@Override
public void onCompleted() {
}
@Override
public void onError(Throwable e) {
}
@Override
public void onNext(Boolean o) {
view.renderSuccessView();
}
});
@Test
public void testSubmit() throws Exception {
TestSubscriber testSubscriber = new TestSubscriber<>();
loginPresenter.submit(FAKE_ACCOUNT,FAKE_PWD);
Mockito.verify(userLogin).setAccount(FAKE_ACCOUNT);
Mockito.verify(userLogin).setPwd(FAKE_ACCOUNT);
Mockito.verify(userLogin).execute(testSubscriber);
}
控制台就会报下面这个错误:
为了解决这个问题,你就必须将匿名类变成内部类。
@Override
public void submit(String account, String pwd) {
if(validate(account,pwd)){
userLogin.setAccount(account)
.setPwd(pwd)
.execute(new UserLoginSubscriber());
}
}
public final class UserLoginSubscriber extends BaseSubscriber {
@Override
public void onCompleted() {
}
@Override
public void onError(Throwable e) {
}
@Override
public void onNext(Boolean aBoolean) {
view.renderSuccessView();
}
}
异步回调的测试
还是以前一节的代码为例,如果我们想测试UserLoginSubscriber这个类里面的三个方法,我们该怎么做呢?
Mockito 为我们提供了两个解决方案:
1. doAnswer
我们可以使用都doAnswer为一个函数进行打桩以测试异步函数。当被测试的方法被调用时我们生成了一个通用的anwser,这个回调会被执行。UserLoginSubscriber 是回调函数所在的类,LoginPresenter是我们要测试的类,UserLogin是我们mock的对象,UserLogin执行了UserLoginSubscriber的回调方法。具体看代码:
@Mock
UserLogin userLogin;
@Mock
LoginPresenter.View mockView;
private LoginPresenter loginPresenter;
@Test
public void testSubmit() throws Exception {
Mockito.doAnswer(new Answer() {
@Override
public Object answer(InvocationOnMock invocation) throws Throwable {
((UserLoginSubscriber)invocation.getArguments()[0]).onNext(true);
return null;
}
}).when(userLogin).execute(Mockito.any(UserLoginSubscriber.class));
loginPresenter.submit(FAKE_ACCOUNT,FAKE_PWD);
Mockito.verify(userLogin,Mockito.times(1)).execute(Mockito.any(BaseSubscriber.class));
Mockito.verify(mockView).renderSuccessView();
}
2. ArgumentCaptor
在这里我们的UserLoginSubscriber是异步的: 我们通过ArgumentCaptor捕获传递到UserLogin对象的UserLoginSubscriber回调。
@Mock
UserLogin userLogin;
@Mock
LoginPresenter.View mockView;
@Captor
ArgumentCaptor argumentCaptor;
private LoginPresenter loginPresenter;
@Test
public void testSubmit() throws Exception {
loginPresenter.submit(FAKE_ACCOUNT,FAKE_PWD);
Mockito.verify(userLogin).execute(argumentCaptor.capture());
argumentCaptor.getValue().onNext(true);
Mockito.verify(mockView).renderSuccessView();
}
doAnswer 和 ArgumentCaptor 都是值得大书特书的东西,这里我们就只简单介绍到这里。如果有机会,会单独写一篇来介绍这两个东西。
RxJava 的Subscriber 的测试
RxJava 由于使用链式调用,而且通常最后subscribe方法是没有返回值的,所以我们没有办法去像常规单元测试一样对其进行测试。所以,我们不得不动用一些非常规武器---TestSubscriber。哈哈,其实不是,这是官方提供的,使用方法如下:
@Test
public void testGetGoodsCategories() throws Exception {
TestSubscriber> testSubscriber = new TestSubscriber<>();
GoodsRepository.getGoodsCategories(FAKE_CATEGORY_ID).subscribe(testSubscriber);
testSubscriber.assertNoErrors();
}
@Test
public void testInvalidCategoryId(){
TestSubscriber> testSubscriber = new TestSubscriber<>();
GoodsRepository.getGoodsCategories(INVALID_CATEGORY_ID).subscribe(testSubscriber);
testSubscriber.assertError(IllegalArgumentException.class);
}
非常简单,我们通过检查TestSubscriber 的回调结果,就能知道我们的程序是否按照我们预想的运行。TestSubscriber 还有其他方法,诸如assertValue(),assertCompleted()等等。总之,RxJava 为我们提供了丰富的工具来进行测试。
结束语
本文只是最基本的单元测试指南,许多高级使用技巧我们并没有涉及到,比如JUnit 的Rule。 这需要我们在日后的工作中一点点去学习和积累。其实,关于Dagger2 的,还有一个据说很神奇的开源库DaggerMock,但由于我没有试验成功,所以这里就不介绍了,等到哪天我学会如何使用之后,再把这块内容补上。