Android 四大组件单元测试之 Receiver

发布于:

编程

测试工具

Junit4 Mockito Robolectric

本文假定读者已经配置好了库及环境。

测试内容

Receiver 测试相对比较简单,很容易得到 100% 的覆盖率。测试主要覆盖如下场景:

  • 可以被 Manifest 中 Intent-filter 正确拉起
  • onReceiver(Context, Intent)intent 输入
  • 其他内部逻辑方法
  • 如果有线程且其中有逻辑代码,分拆成独立方法测试

验证条件

  • 普通方法验证输入输出
  • onReceive() 可以验证 Context.startService(Intent) Context.startActivity(Intent) 方法是否被执行,且捕获 Intent 参数,验证参数是否与预期一致
  • 有其他操作如写入数据库等,可以验证这些操作被执行,且参数一致

通过分析验证条件,我们可以总结出需要 mockspy 的对象要有 ContextReceiver 本身。

代码实例

下面代码以日历中找的一个最简单的 LanguageChangeReceiver 为例一步一步完成测试覆盖:

Receiver 代码

<receiver android:name=".receiver.LanguageChangeReceiver"  
  android:exported="false">  
    <intent-filter>  
        <action android:name="android.intent.action.LOCALE_CHANGED"/>  
    </intent-filter>  
</receiver>
public class LanguageChangeReceiver extends BroadcastReceiver {  
    @Override  
  public void onReceive(Context context, Intent intent) {  
        if (intent == null || intent.getAction() == null) {  
            return;  
        }  
        if (intent.getAction().equals(Intent.ACTION_LOCALE_CHANGED)) {  
            loadFestXmlData(context);  
        }  
    }  
  
    void loadFestXmlData(final Context context) {  
        IoThread.get().post(new Runnable() {  
            @Override  
  public void run() {  
                FestXmlUtils.getInstance().reloadData(context);  
            }  
        });  
    }  
}

代码分析

从代码可以看出这个 receiver 只监听了一个 LOCALE_CHANGED 的事件,我们首先需要对能否接收到这个事件做校验;再看 onReceive(Context context, Intent intent) 方法,可以看到需要注入 ContextIntent 依赖,同时需要对 intent 的数据内容做分支测试。

测试代码

首先我们需要准备上下文环境,并准备 ContextLanguageChangeReceiver 对象。可以从 ShadowApplication 对象中拿到 receiver 对象,同时使用 spy()receivercontext 对象监听,这样我们就可以校验 contextreceiver 中的方法是否被执行。

@RunWith(RobolectricTestRunner.class)  
public class LanguageChangeReceiverTest {  
  
    private Context mContext;  
    private LanguageChangeReceiver receiver;  
  
    @Before  
  public void setup() {  
        ShadowApplication shadowApplication = Shadows.shadowOf(RuntimeEnvironment.application);  
        List<ShadowApplication.Wrapper> receivers = shadowApplication.getRegisteredReceivers();  
  
        for (ShadowApplication.Wrapper wrapper : receivers) {  
            if (wrapper.getBroadcastReceiver() instanceof LanguageChangeReceiver) {  
                receiver = spy((LanguageChangeReceiver) wrapper.getBroadcastReceiver());  
                break;  
            }  
        }  
  
        mContext = spy(RuntimeEnvironment.application);  
    }
}

1. 测试 intent-filter

我们首先要测试 receiver 是否能收到期望的事件,需要通过 PackageManager.queryBroadcastReceivers() 方法拉起期望过滤到的 intent,然后断言被拉起的广播中有当前的 LanguageChangeReceiver

// Test if LanguageChangeReceiver could receive defined intent action  
@Test  
public void testIntentHandling() {  
    ShadowApplication shadowApplication = ShadowApplication.getInstance();  
    PackageManager packageManager =  
            shadowApplication.getApplicationContext().getPackageManager();  
  
    List<String> stringList = new ArrayList<String>(  
            Arrays.asList(  
                    "android.intent.action.LOCALE_CHANGED"));  
    for (String action : stringList) {  
        Intent intent = new Intent(action);  
        Boolean hasReceiver = false;  
        for (ResolveInfo resolveInfo : packageManager.queryBroadcastReceivers(intent, 0)) {  
            if (LanguageChangeReceiver.class.getCanonicalName().equals(  
                    resolveInfo.activityInfo.name)) {  
                hasReceiver = true;  
                break;  
            }  
        }  
        Assert.assertTrue(hasReceiver);  
    }  
}

2. 测试空 Intent

这是一个边界条件,我们需要测试传入的 Intent 为空时不会出现异常且没有执行其他非预期操作。

@Test  
public void onReceive_withNoIntentAction() {  
    receiver.onReceive(mContext, null);  
  
    verify(receiver, never()).loadFestXmlData(any(Context.class));  
  
    receiver.onReceive(mContext, new Intent());  
  
    verify(receiver, never()).loadFestXmlData(any(Context.class));  
}

3. 测试预期的 Intent 行为

这个是我们的正常业务逻辑,我们需要传入正确的 Intent 并验证预期的方法被执行:

@Test  
public void onReceive_withAction() {  
    receiver.onReceive(mContext, new Intent(Intent.ACTION_LOCALE_CHANGED));  
  
    verify(receiver).loadFestXmlData(eq(mContext));  
}

总结

通过上面三个测试已经完整覆盖了 LanguageChangeReceiver 所有业务逻辑和分支。测试代码非常简单,更多的代码是在准备上下文环境。如果 Receiver 中含有大量业务逻辑是需要详细的测试覆盖的,这些测试内容和普通的 mockito 单元测试没有区别。

最后附一个业务逻辑较为复杂的 MobileCalendarBackupRecoverReceiver 测试代码供参考,测试的方法是一样的,只是含有更多的逻辑分支所有测试内容较多。

MobileCalendarBackupRecoverReceiver 测试实例,包含测试代码和 Receiver 代码:

MobileCalendarBackupRecoverReceiverTest.java

@RunWith(RobolectricTestRunner.class)  
public class MobileCalendarBackupRecoverReceiverTest {  
  
    private Context mContext;  
    private MobileCalendarBackupRecoverReceiver receiver;  
  
    @Before  
  public void setup() {  
        ShadowApplication shadowApplication = Shadows.shadowOf(RuntimeEnvironment.application);  
        List<ShadowApplication.Wrapper> receivers = shadowApplication.getRegisteredReceivers();  
  
        for (ShadowApplication.Wrapper wrapper : receivers) {  
            if (wrapper.getBroadcastReceiver() instanceof MobileCalendarBackupRecoverReceiver) {  
                receiver = spy(  
                        (MobileCalendarBackupRecoverReceiver) wrapper.getBroadcastReceiver());  
                break;  
            }  
        }  
  
        mContext = spy(RuntimeEnvironment.application);  
    }  
  
    @Test  
  public void testIntentHandling() {  
        ShadowApplication shadowApplication = ShadowApplication.getInstance();  
        PackageManager packageManager =  
                shadowApplication.getApplicationContext().getPackageManager();  
  
        List<String> stringList = new ArrayList<String>(  
                Arrays.asList(  
                        "com.qiku.android.mobile.backup.action.BACKUPCALENDAR",  
                        "com.android.providers.calendar.spacesize",  
                        "com.qiku.android.mobile.backup.action.CALENDARSIZE"));  
        for (String action : stringList) {  
            Intent intent = new Intent(action);  
            Boolean hasReceiver = false;  
            for (ResolveInfo resolveInfo : packageManager.queryBroadcastReceivers(intent, 0)) {  
                if (MobileCalendarBackupRecoverReceiver.class.getCanonicalName().equals(  
                        resolveInfo.activityInfo.name)) {  
                    hasReceiver = true;  
                    break;  
                }  
            }  
            Assert.assertTrue(hasReceiver);  
        }  
    }  
  
    @Test  
  public void onReceive_withoutIntentData() {  
        receiver.onReceive(mContext, null);  
  
        verify(receiver, never()).getPayload(any(Intent.class));  
    }  
  
    @Test  
  public void onReceive_withIntentData_querySize() {  
        Intent intent = new Intent();  
        intent.setAction(IntentUtil.MOBILE_ACTION_QUERY_SIZE);  
  
        receiver.onReceive(mContext, intent);  
  
        verify(receiver).getPayload(any(Intent.class));  
  
        ArgumentCaptor<Intent> argument = ArgumentCaptor.forClass(Intent.class);  
        verify(mContext).sendBroadcast(argument.capture());  
  
        assertEquals(  
                "Intent { act=com.qiku.android.backup.action.APPLICATIONSIZE cmp=com.qiku.android"  
  + ".backup/.receiver.SpaceReceiver (has extras) }",  
                argument.getValue().toString());  
        assertEquals(  
                "Bundle[{size=0, application=calendar}]",  
                argument.getValue().getExtras().toString());  
    }  
  
    @Test  
  public void onReceive_withIntentData_backup_cancel() {  
        MobileCalendarBackupRecoverReceiver.Data data =  
                new MobileCalendarBackupRecoverReceiver.Data();  
  
        data.action = IntentUtil.MOBILE_APPLICATION_BACKUP_RECEIVER;  
  
        assertEquals(ProcessResult.DATA_INVALID, receiver.processData(mContext, data));  
  
        data.notifyAction = "notify_action_test";  
        data.operation = MobileCalendarBackupRecoverReceiver.Operation.CANCEL;  
        data.identity = 1;  
  
        assertEquals(ProcessResult.PROCESS_CANCEL, receiver.processData(mContext, data));  
    }  
  
    @Test  
  public void onReceive_withIntentData_backup_fileError() {  
        MobileCalendarBackupRecoverReceiver.Data data =  
                new MobileCalendarBackupRecoverReceiver.Data();  
  
        data.action = IntentUtil.MOBILE_APPLICATION_BACKUP_RECEIVER;  
  
        assertEquals(ProcessResult.DATA_INVALID, receiver.processData(mContext, data));  
  
        data.notifyAction = "notify_action_test";  
        data.operation = MobileCalendarBackupRecoverReceiver.Operation.BACKUP;  
        data.identity = 1;  
  
        when(receiver.isFileTooLarge(mContext, data)).thenReturn(true);  
  
        assertEquals(ProcessResult.FILE_ERROR, receiver.processData(mContext, data));  
  
        when(receiver.isFileTooLarge(mContext, data)).thenReturn(false);  
        when(receiver.isEmptyBackupFile(mContext, data)).thenReturn(true);  
  
        assertEquals(ProcessResult.FILE_ERROR, receiver.processData(mContext, data));  
  
    }  
  
  
    @Test  
  public void onReceive_withIntentData_backup() {  
        MobileCalendarBackupRecoverReceiver.Data data =  
                new MobileCalendarBackupRecoverReceiver.Data();  
  
        data.action = IntentUtil.MOBILE_APPLICATION_BACKUP_RECEIVER;  
  
        assertEquals(ProcessResult.DATA_INVALID, receiver.processData(mContext, data));  
  
        data.notifyAction = "notify_action_test";  
        data.operation = MobileCalendarBackupRecoverReceiver.Operation.BACKUP;  
        data.identity = 1;  
  
        assertEquals(ProcessResult.FINISHED, receiver.processData(mContext, data));  
        verify(receiver).startBackupService(mContext, data);  
  
    }  
  
    @Test  
  public void onReceive_withIntentData_noSpace() {  
        MobileCalendarBackupRecoverReceiver.Data data =  
                new MobileCalendarBackupRecoverReceiver.Data();  
  
        data.action = IntentUtil.NO_SPACE_ACTION;  
  
        receiver.prepareBackupIntent();  
        assertEquals(ProcessResult.FINISHED, receiver.processData(mContext, data));  
        verify(receiver).stopBackupService(mContext, data);  
  
    }  
  
    @Test  
  public void startBackgroundService_withInvalidDataType() {  
        MobileCalendarBackupRecoverReceiver.Data data =  
                new MobileCalendarBackupRecoverReceiver.Data();  
  
        data.dataType = null;  
        data.notifyAction = "notify_action_test";  
        data.operation = MobileCalendarBackupRecoverReceiver.Operation.BACKUP;  
        data.identity = 1;  
        receiver.startBackupService(mContext, data);  
  
        verify(receiver).notifyBackupRecover(mContext, data);  
        ArgumentCaptor<Intent> argument = ArgumentCaptor.forClass(Intent.class);  
  
        verify(mContext).sendBroadcast(argument.capture());  
        assertEquals("Intent { act=notify_action_test (has extras) }",  
                argument.getValue().toString());  
        assertEquals("Bundle[{return=0, percent=0, identity=1, application=calendar}]",  
                argument.getValue().getExtras().toString());  
    }  
  
    @Test  
  public void startBackgroundService_withInvalidOperation() {  
        MobileCalendarBackupRecoverReceiver.Data data =  
                new MobileCalendarBackupRecoverReceiver.Data();  
  
        data.dataType = "calendar";  
        data.notifyAction = "notify_action_test";  
        data.identity = 1;  
        data.operation = MobileCalendarBackupRecoverReceiver.Operation.CANCEL;  
  
        receiver.startBackupService(mContext, data);  
  
        verify(receiver).notifyBackupRecover(mContext, data);  
        ArgumentCaptor<Intent> argument = ArgumentCaptor.forClass(Intent.class);  
  
        verify(mContext).sendBroadcast(argument.capture());  
        assertEquals("Intent { act=notify_action_test (has extras) }",  
                argument.getValue().toString());  
        assertEquals("Bundle[{return=0, percent=0, identity=1, application=calendar}]",  
                argument.getValue().getExtras().toString());  
    }  
  
  
    @Test  
  public void startBackgroundService_withValidOperation() {  
        MobileCalendarBackupRecoverReceiver.Data data =  
                new MobileCalendarBackupRecoverReceiver.Data();  
  
        data.dataType = "calendar";  
        data.notifyAction = "notify_action_test";  
        data.identity = 1;  
        data.operation = MobileCalendarBackupRecoverReceiver.Operation.BACKUP;  
  
        receiver.prepareBackupIntent();  
        receiver.startBackupService(mContext, data);  
  
        ArgumentCaptor<Intent> argument = ArgumentCaptor.forClass(Intent.class);  
  
        verify(receiver).startService(eq(mContext), argument.capture());  
        assertEquals(  
                "Intent { act=com.qiku.android.mobile.backup.action.BACKUPSERVICE pkg=com.qiku"  
  + ".android.calendar (has extras) }",  
                argument.getValue().toString());  
        assertEquals(  
                "Bundle[{qihoo_type=null, notify_action=notify_action_test, opr_type=0, "  
  + "identity=1, folder_path=null}]",  
                argument.getValue().getExtras().toString());  
    } 
  
    @After  
    public void tearDown() {  
        CalendarDatabaseHelperTest.closeDatabase(RuntimeEnvironment.application);  
    } 
}

MobileCalendarBackupRecoverReceiver.java

public class MobileCalendarBackupRecoverReceiver extends BroadcastReceiver {  
  
    private static final String TAG = MobileCalendarBackupRecoverReceiver.class.getSimpleName();  
  
    private static final String STR_OPER_TYPE = "opr_type";  
  
    private static final String STR_NOTIFY_ACTION = "notify_action";  
  
    private static final String STR_FOLD_PATH = "folder_path";  
  
    private static final String STR_PERCENT = "percent";  
  
    /**  
 * ics文件最大字节数(800K)  
 */  public static final int MAX_FILE_SIZE = 600 * 1024;  
  
    private static final String APPLICATION_STR = "application";  
  
    private static final String QIHOO_TYPE = "qihoo_type";  
  
    private static final String IDENTITY_STR = "identity";  
  
    public static Intent mServiceIntent = null;  
  
    public static int mServiceNum = 0;  
  
    @IntDef({ReturnOperation.INTERRUPT, ReturnOperation.CANCEL, ReturnOperation.COMPLETE})  
    @Retention(RetentionPolicy.SOURCE)  
    public @interface ReturnOperation {  
        int INTERRUPT = 0;  
        int CANCEL = -1;  
        int COMPLETE = 1;  
    }  
  
    @IntDef({Operation.CANCEL, Operation.BACKUP, Operation.RESTORE, Operation.UPDATE})  
    @Retention(RetentionPolicy.SOURCE)  
    public @interface Operation {  
        int CANCEL = -1;  
        int BACKUP = 0;  
        int RESTORE = 1;  
        int UPDATE = 2;  
    }  
  
    public static boolean mIsBackupRecover = false; // 是否在备份恢复  
  
  @Override  
  public void onReceive(Context context, Intent intent) {  
        if (intent == null || intent.getAction() == null) {  
            return;  
        }  
  
        Data data = getPayload(intent);  
  
        prepareBackupIntent();  
  
        processData(context, data);  
    }  
  
    int processData(Context context, Data data) {  
  
        switch (data.action) {  
            case IntentUtil.MOBILE_ACTION_QUERY_SIZE:  
                sendSizeBroadcast(context);  
                break;  
            case IntentUtil.MOBILE_APPLICATION_BACKUP_RECEIVER:  
                if (data.isInvalid()) {  
                    mServiceIntent = null;  
                    mServiceNum = 0;  
                    return ProcessResult.DATA_INVALID;  
                }  
  
                if (data.operation == Operation.CANCEL) {  
                    Log.d(TAG, "cancel");  
                    cancelBackupService(context, data);  
                    return ProcessResult.PROCESS_CANCEL;  
                }  
  
                if (isFileTooLarge(context, data) || isEmptyBackupFile(context, data)) {  
                    return ProcessResult.FILE_ERROR;  
                }  
  
                startBackupService(context, data);  
  
                break;  
            case IntentUtil.NO_SPACE_ACTION:  
                stopBackupService(context, data);  
                break;  
  
        }  
        return ProcessResult.FINISHED;  
    }  
  
    void startBackupService(Context context, Data data) {  
        if (data.dataType != null && data.dataType.equals("calendar")) {  
            if (!(data.operation == Operation.BACKUP  
  || data.operation == Operation.RESTORE)) {  
                //操作失败  
  notifyBackupRecover(context, data);  
            } else {  
                if (mServiceNum == 0) {  
                    mServiceIntent.putExtra(STR_NOTIFY_ACTION, data.notifyAction);  
                    mServiceIntent.putExtra(IDENTITY_STR, data.identity);  
                    mServiceIntent.putExtra(STR_OPER_TYPE, data.operation);  
                    mServiceIntent.putExtra(STR_FOLD_PATH, data.folderPath);  
                    mServiceIntent.putExtra(QIHOO_TYPE, data.qihooType);  
                    mServiceNum = 1;  
                    Log.v(CalendarConsts.QK_CALENDAR,  
                            "startService, mServiceNum = " + mServiceNum);  
                    mIsBackupRecover = true;  
                    startService(context, mServiceIntent);  
                }  
            }  
        } else {  
            //操作失败  
  notifyBackupRecover(context, data);  
        }  
    }  
  
    void startService(Context context, Intent intent) {  
        if (Build.VERSION.SDK_INT >= 26) {  
            context.startForegroundService(intent);  
        } else {  
            context.startService(intent);  
        }  
    }  
  
    void prepareBackupIntent() {  
        if (mServiceIntent == null && mServiceNum == 0) {  
            // 防止连续收到多个备份恢复的消息。  
  // 如果 mServiceIntent 不为空或者 mServiceNum 大于0,表示服务已经起来了,就不需要再new Intent。  
  mServiceIntent = new Intent();  
            mServiceIntent.setAction(IntentUtil.MOBILE_BACKUP_SERVICE);//你定义的service的action  
  mServiceIntent.setPackage("com.qiku.android.calendar");//这里你需要设置你应用的包名  
  } else {  
            Log.v(CalendarConsts.QK_CALENDAR,  
                    "mServiceIntent != null, mServiceNum= " + mServiceNum);  
        }  
    }  
  
    boolean isEmptyBackupFile(Context context, Data data) {  
        if (data.hasPath()) {  
            String fileName = "\\calendar.vcs";  
            File file = new File(data.folderPath + fileName);  
            if ((data.operation == Operation.RESTORE) && (file.exists() && file.length() == 0)) {  
                // 如果需要恢复的备份文件为空,直接返回恢复成功  
  Log.v(CalendarConsts.QK_CALENDAR, "Backup File is empty");  
                data.percent = BackupConsts.NUM_100;  
                data.returnOperation = ReturnOperation.COMPLETE;  
                notifyBackupRecover(context, data);  
                mServiceIntent = null;  
                mServiceNum = 0;  
                return true;  
            }  
        }  
        return false;  
    }  
  
    boolean isFileTooLarge(Context context, Data data) {  
        if (data.hasPath()) {  
            String fileName = "\\calendar.vcs";  
            File file = new File(data.folderPath + fileName);  
            // 600 * 1024是只恢复的ics文件的大小,该大小的ics文件估计存储了2000条左右的日历,  
  // 但是如果日程每个字段都填满的话,就只有600条日程。定义该大小的原因是:  
  // 如果ics文件超出该大小,在framework层解析ics文件时,会出现异常  
  if (file.length() > MAX_FILE_SIZE) {  
                notifyBackupRecover(context, data);  
                Toast.makeText(context, ResUtil.getString(R.string.file_lenth_beyond_limit),  
                        Toast.LENGTH_SHORT).show();  
                mServiceIntent = null;  
                mServiceNum = 0;  
                return true;  
            }  
        }  
        return false;  
    }  
  
    private void cancelBackupService(Context context, Data data) {  
        if (context != null && mServiceIntent != null) {  
            context.stopService(mServiceIntent);  
        }  
        mServiceIntent = null;  
        mServiceNum = 0;  
        data.returnOperation = ReturnOperation.CANCEL;  
        notifyBackupRecover(context, data);  
        mIsBackupRecover = false;  
    }  
  
    Data getPayload(Intent intent) {  
        Data data = new Data();  
  
        //标示身份(当前时间,用于验证是否是本次操作)  
  data.identity = intent.getLongExtra(IDENTITY_STR, 0);  
        data.notifyAction = intent.getStringExtra(STR_NOTIFY_ACTION);  
  
        //获取处理类型,判断是否为日程类型  
  data.dataType = intent.getStringExtra(APPLICATION_STR);  
        data.qihooType = intent.getStringExtra(QIHOO_TYPE);  
        data.operation = intent.getIntExtra(STR_OPER_TYPE, Operation.BACKUP);  
        data.folderPath = intent.getStringExtra(STR_FOLD_PATH);  
        data.action = intent.getAction();  
        return data;  
    }  
  
    void stopBackupService(Context context, Data data) {  
        // 空间不足取消操作  
  context.stopService(mServiceIntent);  
        mServiceIntent = null;  
        mServiceNum = 0;  
        data.returnOperation = ReturnOperation.CANCEL;  
        notifyBackupRecover(context, data);  
    }  
  
    void sendSizeBroadcast(Context context) {  
        ListBufferInfoBean bean = provideEditEvent(context);  
        if (bean != null) {  
            //每个设置3.5k  
  int size = 7 * bean.getCount() / 2;  
  
            Intent i = new Intent(IntentUtil.ACTION_ANSWER_SIZE);  
            i.putExtra(APPLICATION_STR, "calendar");  
            i.putExtra("size", size);  
            i.setClassName(IntentUtil.BACKUP_PACKAGE, IntentUtil.SPACE_RECEIVER);  
            context.sendBroadcast(i);  
        }  
    }  
  
    ListBufferInfoBean provideEditEvent(Context context) {  
        return EditEventLogicImpl.getInstance(context).getEventsTableInfoForBackUp(-1);  
    }  
  
    /**  
 * 发广播消息通知备份应用程序  
  */  
  void notifyBackupRecover(Context context, Data data) {  
        Intent intent = new Intent(data.notifyAction);  
        intent.putExtra(APPLICATION_STR, "calendar");  
        intent.putExtra("return", data.returnOperation);  
  
        if (data.percent > BackupConsts.NUM_100) {  
            intent.putExtra(STR_PERCENT, BackupConsts.NUM_100);  
        } else {  
            intent.putExtra(STR_PERCENT, data.percent);  
        }  
        intent.putExtra(IDENTITY_STR, data.identity);  
        context.sendBroadcast(intent);  
    }  
  
    static class Data {  
        @Operation  
  int operation;  
  
        @ReturnOperation  
  int returnOperation = ReturnOperation.INTERRUPT;  
  
        long identity;  
        String notifyAction;  
        String dataType;  
        String qihooType;  
        String folderPath;  
        String action;  
        int percent;  
  
        boolean isInvalid() {  
            return TextUtils.isEmpty(notifyAction) || identity == 0;  
        }  
  
        boolean hasPath() {  
            return !TextUtils.isEmpty(folderPath);  
        }  
    }
}