Android 四大组件单元测试之 Activity

发布于:

编程

测试工具

Junit4 Mockito Robolectric

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

测试内容

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

  • onCreate(Bundle savedInstanceState) 中的逻辑过程,需要通过 getIntent() 方法传入不同的 Intent 覆盖。
  • 其他内部逻辑方法
  • 如果有线程且其中有逻辑代码,分拆成独立方法测试

验证条件

逻辑执行过程中,可以验证 Activity.startActivity(Intent) 这样的方法是否被执行,及断言他们传入的 intent 是我们期望的值;可以验证 UI 界面中的状态变化,如 TextView 的文字等。

通过分析验证条件,我们可以总结出需要 mockspy 的对象要为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() 方法执行时提供不同的数据场景来覆盖业务逻辑。 这些逻辑分支整理为以下几条:

  • IntentIntent 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());  
}

4. 拉起主界面 SplashActivity

这个测试和第三个测试基本相同,只是 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());  
}

5. 拉起主界面 MenuAnimationActivity

这个测试和第三、四个测试基本相同,只是 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());  
    }  
}