Android 四大组件单元测试之 Activity
测试工具
Junit4
Mockito
Robolectric
本文假定读者已经配置好了库及环境。
测试内容
Activity
测试相对比较简单,很容易得到 100% 的覆盖率。测试主要覆盖如下场景:
onCreate(Bundle savedInstanceState)
中的逻辑过程,需要通过getIntent()
方法传入不同的Intent
覆盖。- 其他内部逻辑方法
- 如果有线程且其中有逻辑代码,分拆成独立方法测试
验证条件
逻辑执行过程中,可以验证 Activity.startActivity(Intent)
这样的方法是否被执行,及断言他们传入的 intent
是我们期望的值;可以验证 UI 界面中的状态变化,如 TextView
的文字等。
通过分析验证条件,我们可以总结出需要 mock
或 spy
的对象要为Activity
本身。同时我们可以借助 ActivityController<T>
来模拟一些 Activity
的行为如生命周期。
代码实例
下面代码以日历中找的一个最简单的 DeepLinkActivity
为例一步一步完成测试覆盖:
Activity 代码
<activity
android:name=".ui.DeepLinkActivity"
android:noHistory="true"
android:exported="true"
android:screenOrientation="portrait"
android:theme="@android:style/Theme.NoDisplay">
<intent-filter>
<data android:scheme="com.qiku.android.calendar" />
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
</intent-filter>
</activity>
DeepLinkActivity.java
public class DeepLinkActivity extends Activity {
private static final String TAG = DeepLinkActivity.class.getSimpleName();
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_deep_link);
Intent intent = getIntent();
if (intent != null && intent.getData() != null) {
openDeepLink(intent.getData());
}
finish();
}
private void openDeepLink(Uri deepLink) {
Log.d(TAG, "openDeepLink: " + deepLink);
String scheme = deepLink.getScheme();
String host = deepLink.getHost();
String path = deepLink.getPath();
if (!Constants.DEEP_LINK_SCHEME.equals(scheme)) {
Log.e(TAG, "wrong scheme: " + scheme);
return;
}
switch (host) {
case Constants.DEEP_LINK_HOST_HOME:
if (Constants.DEEP_LINK_PATH_MONTH.equals(path)) {
startActivity(new Intent(this, MenuAnimationActivity.class));
} else {
startActivity(new Intent(this, SplashActivity.class));
}
break;
default:
startActivity(new Intent(this, SplashActivity.class));
break;
}
}
}
代码分析
从代码可以看出这个 Acitivyt
监听了一个 com.qiku.android.calendar://xxx
的事件,我们首先需要对能否接收到这个事件做校验;再看 onCreate()
方法,可以看到需要在 getIntent()
方法执行时提供不同的数据场景来覆盖业务逻辑。 这些逻辑分支整理为以下几条:
- 空
Intent
或Intent data
为空 - 错误的
Intent scheme
- 正确
scheme
时默认拉起界面 - 拉起主界面
SplashActivity
- 拉起主界面
MenuAnimationActivity
测试代码
首先我们需要准备上下文环境,我们可以通过 Robolectric.buildActivity(DeepLinkActivity.class)
方法构建一个 ActivityController
对象,同时通过 controller.get()
来拿到 DeepLinkActivity
对象:
private ActivityController<DeepLinkActivity> controller;
private DeepLinkActivity activity;
private DeepLinkActivity spy;
@Before
public void setup() {
controller = Robolectric.buildActivity(DeepLinkActivity.class);
activity = controller.get();
}
1. 测试空 Intent 拉起
先对空边界进行覆盖,可以在 onCreate
方法中看出,当 getIntent()
返回空时,没有执行任何业务逻辑就退出了。我们首先 spy()
Activity
对象,同时假定 getIntent()
被执行时返回一个 null
,然后调用 onCreate
方法。这时我们期望 finish()
方法被执行,同时 startActivity()
方法没有被执行。
@Test
public void deepLink_finishActivity() {
spy = Mockito.spy(activity);
when(spy.getIntent()).thenReturn(null);
spy.onCreate(null);
verify(spy).finish();
verify(spy, never()).startActivity(any(Intent.class));
}
2. 测试错误的 Intent 拉起
再对错误的 Intent
拉起做覆盖。和第一个测试相同,我们假定 getIntent()
返回了一个错误的 Intent
,然后再执行 onCreate()
方法,我们的期望和第一个测试相同。
@Test
public void deepLink_wrongSchemeActivity() {
spy = Mockito.spy(activity);
Intent intent = new Intent();
intent.setData(Uri.parse("com.android.calendar://home"));
when(spy.getIntent()).thenReturn(intent);
spy.onCreate(null);
verify(spy).finish();
verify(spy, never()).startActivity(any(Intent.class));
}
3. 拉起默认的界面
传入了正确的 scheme
后,如果 HOST
不匹配需要拉起默认的界面。假定执行 getIntent()
时,返回了一个含有 com.qiku.android.calendar://home1
数据的 Intent
,然后执行 onCreate()
方法,校验 finish()
方法被执行,再校验 startActivity(Intent)
方法被执行,同时捕获它的 Intent
参数,断言这个 Intent
和我们期望的值相同。
@Test
public void deepLink_defaultActivity() {
spy = Mockito.spy(activity);
Intent intent = new Intent();
intent.setData(Uri.parse("com.qiku.android.calendar://home1"));
when(spy.getIntent()).thenReturn(intent);
spy.onCreate(null);
verify(spy).finish();
// check startActivity() is called and check argument value
ArgumentCaptor<Intent> argument = ArgumentCaptor.forClass(Intent.class);
verify(spy).startActivity(argument.capture());
assertEquals("Intent { cmp=com.qiku.android.calendar/.ui.SplashActivity }",
argument.getValue().toString());
}
SplashActivity
4. 拉起主界面 这个测试和第三个测试基本相同,只是 Intent
Data
为我们期望的值 com.qiku.android.calendar://home
。
@Test
public void deepLink_startHomeActivity() {
spy = Mockito.spy(activity);
Intent intent = new Intent();
intent.setData(Uri.parse("com.qiku.android.calendar://home"));
when(spy.getIntent()).thenReturn(intent);
spy.onCreate(null);
// check startActivity() is called and check argument value
ArgumentCaptor<Intent> argument = ArgumentCaptor.forClass(Intent.class);
verify(spy).startActivity(argument.capture());
assertEquals("Intent { cmp=com.qiku.android.calendar/.ui.SplashActivity }",
argument.getValue().toString());
}
MenuAnimationActivity
5. 拉起主界面 这个测试和第三、四个测试基本相同,只是 Intent
Data
中增加了 path
字段,为我们期望的值 com.qiku.android.calendar://home/month
,这时我们期望拉起了 MenuAnimationActivity
。
@Test
public void deepLink_startHomeMonthActivity() {
spy = Mockito.spy(activity);
Intent intent = new Intent();
intent.setData(Uri.parse("com.qiku.android.calendar://home/month"));
when(spy.getIntent()).thenReturn(intent);
spy.onCreate(null);
// check startActivity() is called and check argument value
ArgumentCaptor<Intent> argument = ArgumentCaptor.forClass(Intent.class);
verify(spy).startActivity(argument.capture());
assertEquals("Intent { cmp=com.qiku.android.calendar/.ui.MenuAnimationActivity }",
argument.getValue().toString());
}
总结
通过上面的测试代码,我们已经完整覆盖了 DeepLinkActivity
的所有业务逻辑分支,可以看出 Activity
的测试还是很简单的。如果上下文中涉及到生命周期,view
等内容,仍需要 ActivityController<T>
的参与。
最后附一个含有 UI 内容的 FeedSettingsActivity
单元测试,这个测试中校验了 ListView
的每个 Item
,同时对标题栏、Item
点击,返回按钮等事件做了验证。
附
FeedSettingsActivity.java
public class FeedSettingsActivity extends QkCalendarCommonActivity {
private static final String TAG = AdViewerActivity.class.getSimpleName();
TopBar mTopBar;
ListView mListView;
ListAdapter mAdapter;
FeedSettings mSettings;
boolean hasParent = false;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setBodyLayout(R.layout.activity_feed_manage);
mSettings = FeedSettingsImpl.getInstance();
mSettings.init(getApplicationContext());
mListView = findViewById(R.id.list);
mAdapter = new ListAdapter(mSettings);
List<ListItem> items = loadListItems();
mAdapter.reload(items);
mListView.setAdapter(mAdapter);
mListView.setOnItemClickListener(new AdapterView.OnItemClickListener() {
@Override
public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
ListItem item = (ListItem) mAdapter.getItem(position);
if (item.hasChild()) {
mAdapter.reload(item.children);
mAdapter.notifyDataSetChanged();
hasParent = true;
setTitle(item.title);
}
}
});
}
@Override
public void onBackPressed() {
if (hasParent) {
List<ListItem> items = loadListItems();
mAdapter.reload(items);
mAdapter.notifyDataSetChanged();
hasParent = false;
setTitle(R.string.feed_manager);
} else {
super.onBackPressed();
}
}
@Override
protected void onResume() {
super.onResume();
setTitle(R.string.feed_manager);
}
@Override
protected void onCreateTopBar(TopBar topBar) {
super.onCreateTopBar(topBar);
topBar.setTopBarStyle(TopBar.TopBarStyle.TOP_BAR_NOTMAL_STYLE);
topBar.setDisplayUpView(true);
mTopBar = topBar;
getLayoutInflater();
}
@Override
public void setTitle(CharSequence title) {
if (!((String) title).isEmpty()) {
mTopBar.setTopBarTitle(title);
}
}
List<ListItem> loadListItems() {
List<ListItem> listItems = new ArrayList<>();
listItems.add(new ListItem("黄历八字", FeedTypeEx.FORTUNE.getString()));
listItems.add(new ListItem("天气", FeedTypeEx.WEATHER.getString()));
listItems.add(new ListItem("历史上的今天", FeedType.HISTORY.getString()));
listItems.add(new ListItem("星座", loadSignItems()));
List<StoryType> storyTypes = mSettings.getAllStoryTypes();
for (StoryType storyType : storyTypes) {
listItems.add(new ListItem(storyType.getName(), FeedType.STORY.getString() +
"_" + storyType.getId()));
}
listItems.add(new ListItem("今日要闻", FeedTypeEx.FLOW_NEWS.getString()));
return listItems;
}
List<ListItem> loadSignItems() {
List<ListItem> listItems = new ArrayList<>();
for (SignType type : SignType.values()) {
listItems.add(new ListItem(type.getName(), type.key()));
}
return listItems;
}
static class ListAdapter extends BaseAdapter {
private FeedSettings mSettings;
List<ListItem> items = new ArrayList<>();
ListAdapter(FeedSettings settings) {
mSettings = settings;
}
void reload(List<ListItem> types) {
items.clear();
items.addAll(types);
}
@Override
public int getCount() {
return items.size();
}
@Override
public Object getItem(int position) {
return items.get(position);
}
@Override
public long getItemId(int position) {
return position;
}
@Override
public View getView(final int position, View convertView, final ViewGroup parent) {
final ListItem item = items.get(position);
if (convertView == null) {
convertView = View.inflate(parent.getContext(), R.layout.calendar_setting_item,
null);
convertView.setTag(new ViewHolder(convertView));
}
final ViewHolder viewHolder = (ViewHolder) convertView.getTag();
viewHolder.mainTitle.setText(item.title);
viewHolder.subTitle.setVisibility(View.GONE);
convertView.setClickable(false);
if (item.hasChild()) {
viewHolder.switchButton.setVisibility(View.GONE);
viewHolder.arrow.setVisibility(View.VISIBLE);
} else {
viewHolder.arrow.setVisibility(View.GONE);
viewHolder.switchButton.setVisibility(View.VISIBLE);
viewHolder.switchButton.setOnCheckedChangeListener(null);
viewHolder.switchButton.setCheckedImmediately(mSettings.getValue(item.key));
viewHolder.switchButton.setOnCheckedChangeListener(
new CompoundButton.OnCheckedChangeListener() {
@Override
public void onCheckedChanged(CompoundButton buttonView,
boolean isChecked) {
mSettings.setValue(item.key, isChecked);
if (isChecked && item.key.equals(FeedTypeEx.WEATHER.getString())) {
WeatherController.getInstance().init(parent.getContext());
}
}
});
convertView.setClickable(true);
convertView.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
viewHolder.switchButton.toggle();
}
});
}
return convertView;
}
final static class ViewHolder {
TextView mainTitle;
TextView subTitle;
QkSwitch switchButton;
ImageView arrow;
private ViewHolder(View parent) {
mainTitle = parent.findViewById(R.id.main_title);
subTitle = parent.findViewById(R.id.sub_title);
switchButton = parent.findViewById(R.id.common_switch_button);
arrow = parent.findViewById(R.id.icon_arrow);
}
}
}
static class ListItem {
ListItem(String title, String key) {
this.title = title;
this.key = key;
}
ListItem(String title, List<ListItem> children) {
this.title = title;
this.children.addAll(children);
}
String title;
String key;
List<ListItem> children = new ArrayList<>();
boolean hasChild() {
return children.size() > 0;
}
@Override
public String toString() {
String s = "{" +
"title='" + title + '\'' +
", key='" + key + '\'';
if (children.size() > 0) {
s += ", children=" + children;
}
s += '}';
return s;
}
}
}
FeedSettingsActivityTest.java
@RunWith(RobolectricTestRunner.class)
public class FeedSettingsActivityTest {
private ActivityController<FeedSettingsActivity> controller;
private FeedSettingsActivity activity;
@Before
public void setup() {
controller = Robolectric.buildActivity(FeedSettingsActivity.class);
activity = controller.get();
}
@Test
public void feedSettingList() {
FeedSettingsActivity spy = spy(activity);
spy.onCreate(null);
verify(spy).loadListItems();
verify(spy).loadSignItems();
assertEquals(5, spy.mAdapter.items.size());
assertEquals(
"[{title='黄历八字', key='fortune'}, "
+ "{title='天气', key='weather'}, "
+ "{title='历史上的今天', key='history'}, "
+ "{title='星座', key='null', children=["
+ "{title='白羊座', key='sign_aries'},"
+ " {title='金牛座', key='sign_taurus'}, "
+ "{title='双子座', key='sign_gemini'}, "
+ "{title='巨蟹座', key='sign_cancer'}, "
+ "{title='狮子座', key='sign_leo'}, "
+ "{title='处女座', key='sign_virgo'}, "
+ "{title='天秤座', key='sign_libra'}, "
+ "{title='天蝎座', key='sign_scorpio'}, "
+ "{title='射手座', key='sign_sagittarius'}, "
+ "{title='摩羯座', key='sign_capricorn'}, "
+ "{title='水瓶座', key='sign_aquarius'}, "
+ "{title='双鱼座', key='sign_pisces'}]}, "
+ "{title='今日要闻', key='flow_news'}]",
spy.mAdapter.items.toString());
}
@Test
public void signParentItemClick() {
FeedSettingsActivity spy = spy(activity);
spy.onCreate(null);
spy.mListView.performItemClick(spy.mListView, 3, 0);
assertEquals(12, spy.mAdapter.items.size());
assertEquals(
"[{title='白羊座', key='sign_aries'}, "
+ "{title='金牛座', key='sign_taurus'}, "
+ "{title='双子座', key='sign_gemini'}, "
+ "{title='巨蟹座', key='sign_cancer'}, "
+ "{title='狮子座', key='sign_leo'}, "
+ "{title='处女座', key='sign_virgo'}, "
+ "{title='天秤座', key='sign_libra'}, "
+ "{title='天蝎座', key='sign_scorpio'}, "
+ "{title='射手座', key='sign_sagittarius'}, "
+ "{title='摩羯座', key='sign_capricorn'}, "
+ "{title='水瓶座', key='sign_aquarius'}, "
+ "{title='双鱼座', key='sign_pisces'}]",
spy.mAdapter.items.toString());
assertTrue(spy.hasParent);
spy.onBackPressed();
assertEquals(5, spy.mAdapter.items.size());
assertEquals(
"[{title='黄历八字', key='fortune'}, "
+ "{title='天气', key='weather'}, "
+ "{title='历史上的今天', key='history'}, "
+ "{title='星座', key='null', children=["
+ "{title='白羊座', key='sign_aries'},"
+ " {title='金牛座', key='sign_taurus'}, "
+ "{title='双子座', key='sign_gemini'}, "
+ "{title='巨蟹座', key='sign_cancer'}, "
+ "{title='狮子座', key='sign_leo'}, "
+ "{title='处女座', key='sign_virgo'}, "
+ "{title='天秤座', key='sign_libra'}, "
+ "{title='天蝎座', key='sign_scorpio'}, "
+ "{title='射手座', key='sign_sagittarius'}, "
+ "{title='摩羯座', key='sign_capricorn'}, "
+ "{title='水瓶座', key='sign_aquarius'}, "
+ "{title='双鱼座', key='sign_pisces'}]}, "
+ "{title='今日要闻', key='flow_news'}]",
spy.mAdapter.items.toString());
assertFalse(spy.hasParent);
}
@Test
public void backButtonPressed() {
FeedSettingsActivity spy = spy(activity);
spy.onCreate(null);
assertFalse(spy.hasParent);
spy.mListView.performItemClick(spy.mListView, 3, 0);
assertTrue(spy.hasParent);
spy.onBackPressed();
assertFalse(spy.hasParent);
assertFalse(activity.isFinishing());
spy.onBackPressed();
assertTrue(activity.isFinishing());
}
@Test
public void feedSettingTitle() {
Locale locale = new Locale("zh");
Locale.setDefault(locale);
RuntimeEnvironment.setQualifiers(locale.getLanguage());
FeedSettingsActivity spy = spy(activity);
spy.onCreate(null);
spy.onResume();
assertEquals("订阅管理", spy.mTopBar.getTopBarTitle());
spy.mListView.performItemClick(spy.mListView, 3, 0);
assertTrue(spy.hasParent);
assertEquals("星座", spy.mTopBar.getTopBarTitle());
spy.onBackPressed();
assertEquals("订阅管理", spy.mTopBar.getTopBarTitle());
}
@Test
public void listView_item() {
FeedSettingsActivity spy = spy(activity);
spy.onCreate(null);
spy.onResume();
controller.visible();
View view = spy.mListView.getChildAt(0);
FeedSettingsActivity.ListAdapter.ViewHolder viewHolder =
(FeedSettingsActivity.ListAdapter.ViewHolder) view.getTag();
assertEquals("黄历八字", viewHolder.mainTitle.getText());
assertTrue(viewHolder.switchButton.isEnabled());
}
}