Android 四大组件单元测试之 Receiver
测试工具
Junit4
Mockito
Robolectric
本文假定读者已经配置好了库及环境。
测试内容
Receiver
测试相对比较简单,很容易得到 100% 的覆盖率。测试主要覆盖如下场景:
- 可以被 Manifest 中 Intent-filter 正确拉起
onReceiver(Context, Intent)
中intent
输入- 其他内部逻辑方法
- 如果有线程且其中有逻辑代码,分拆成独立方法测试
验证条件
- 普通方法验证输入输出
onReceive()
可以验证Context.startService(Intent)
Context.startActivity(Intent)
方法是否被执行,且捕获Intent
参数,验证参数是否与预期一致- 有其他操作如写入数据库等,可以验证这些操作被执行,且参数一致
通过分析验证条件,我们可以总结出需要 mock
或 spy
的对象要有 Context
和 Receiver
本身。
代码实例
下面代码以日历中找的一个最简单的 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)
方法,可以看到需要注入 Context
和 Intent
依赖,同时需要对 intent
的数据内容做分支测试。
测试代码
首先我们需要准备上下文环境,并准备 Context
和 LanguageChangeReceiver
对象。可以从 ShadowApplication
对象中拿到 receiver
对象,同时使用 spy()
对 receiver
和 context
对象监听,这样我们就可以校验 context
和 receiver
中的方法是否被执行。
@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);
}
}
intent-filter
1. 测试 我们首先要测试 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);
}
}
}