Mockito 单元测试实例

发布于:

编程

Mockito 是一款优秀的 Java 单元测试库,本文介绍如何使用 Mockito 来编写 Android 本地单元测试。

mockito 库接入

添加 mockito 支持是很简单的,只需要在 build.gradle 中加入如下库依赖即可

testImplementation 'org.mockito:mockito-core:2.19.0'

代码实例

首先我们先来看一段日历主界面 EventAdapter 中的代码,这段代码是点击天气后的业务逻辑。

这段代码主要有三个逻辑:

  1. 根据配置拉起天气应用
  2. 没有天气应用时拉起市场安装应用
  3. 没有天气应用时根据配置拉起网页

这段代码包含了 EventAdpater 的成员 mWeatherController,通过它获取天气的配置;Context 是需要传入的上下文,另外两个参数是没有使用的预留参数,可以忽视。这不是一个静态的全局类,又依赖于 WeatherControllerContext 要怎么测试呢?

public int performClick(Context context, WeatherView weatherView, Weather weather) {  
    try {  
        List<WeatherConf> weatherConfList = mWeatherController.loadWeatherConf();  
  
        for (WeatherConf conf : weatherConfList) {  
            try {  
                // 拉起应用
                Intent intent = new Intent();  
                intent.setClassName(conf.getPackageName(), conf.getActivityName());
                context.startActivity(intent);  
                Action.WEATHER_CLICK.put(Attribute.PACKAGE.with(conf.getPackageName()))  
                        .anchor(context);  
                Log.d(TAG, "onWeatherViewClicked: " + conf.getPackageName());  
                return WeatherEvent.CLICK;  
            } catch (Exception | Error e) {  
                e.printStackTrace();  
            }  
        }  
  
        Log.d(TAG, "onWeatherViewClicked: no package installed -> " + weatherConfList);  
  
        if (weatherConfList.size() > 0) {  
            WeatherConf weatherConf = weatherConfList.get(0);  
            if (weatherConf.getWebUrl() != null) {
                // 拉起网页
                Intent intent = new Intent(Intent.ACTION_VIEW)  
                        .setData(Uri.parse(weatherConf.getWebUrl()));  
                context.startActivity(intent);  
                Action.WEATHER_INSTALL.put(Attribute.PACKAGE.with(weatherConf.getWebUrl()))  
                        .anchor(context);  
                Log.d(TAG, "onWeatherViewClicked: visit url " + weatherConf.getWebUrl());  
                return WeatherEvent.WEB_URL;  
            } else {  
                // 拉起应用市场安装应用
                Intent goToMarket = new Intent(Intent.ACTION_VIEW)  
                        .setData(Uri.parse("market://details?id=" +  
                                weatherConf.getPackageName()));  
                context.startActivity(goToMarket);  
                Action.WEATHER_INSTALL.put(Attribute.PACKAGE.with(weatherConf.getPackageName()))  
                        .anchor(context);  
                Log.d(TAG, "onWeatherViewClicked: install " + weatherConf.getPackageName());  
                return WeatherEvent.INSTALL;  
            }  
        }  
    } catch (Exception | Error e) {  
        e.printStackTrace();  
    }  
    return WeatherEvent.NONE;  
}

测试分析

通过普通的 Junit 单元测试是不能测试这个方法的,我们不能 new 一个 Context 出来,我们也不知道 new 一个 WeatherController 对象会有什么结果,也许这个类有严重的耦合和依赖关系,创建一个陌生的对象是不可预期的。 这时我们就需要使用 mockito 来模拟这些依赖关系并在这些依赖类的方法被调用时做预期处理和检查断言。

我们有怎样的输入?阅读代码我们可以看到在执行过程中,mWeatherController.loadWeatherConf() 被执行,WeatherConf 是一个简单的数据模块类,可以轻松的 new 出来,我们需要在此方法被调用时返回这个对象的列表;我们需要输入一个模拟的上下文。整理的期望如下:

  1. 模拟上下文 Context 对象,WeatherViewWeather 对象无关紧要
  2. 模拟 mWeatherController 对象,并准备期望的数据

要覆盖到所有的逻辑分支,我们需要多种情况的输入和预期:

  1. 空的配置列表!这是一个边缘场景 –> 不做任何事
  2. 有效的配置列表,含有应用包名和主界面名,同时应用存在 –> 拉起应用
  3. 有效的配置列表,含有应用包名和主界面名,同时应用不存在 –> 拉起应用商店安装应用
  4. 有效的配置列表,含有应用包名、主界面名和拉起网页地址,同时应用不存在 –> 拉起网页

测试代码

准备工作

首先我们需要模拟依赖类,我们通过 mock 方法,将 EventAdapter 的依赖类都模拟出来,然后再创建一个 EventAdapter 的对象。因为每个单元测试都是独立无关的,而又都需要这些预置条件,我们可以将这部分代码抽出来放在 @Before 中作为公共部分,每次测试时都会重新走 @Before 方法,所以不需要担心数据被共享或混乱。

private Context mContext;  
  
private AdController mAdController;  
  
private FeedController mFeedController;  
  
private WeatherController mWeatherController;  
  
private EventAdapter mEventAdapter;  
  
@Before  
public void setup() {  
    mContext = mock(Activity.class);  
    mAdController = mock(AdController.class);  
    mFeedController = mock(FeedController.class);  
    mWeatherController = mock(WeatherController.class);  
  
    mEventAdapter = new EventAdapter(mAdController, mFeedController,  
            mWeatherController);  
}

第一个场景

空的配置列表!这是一个边缘场景 –> 不做任何事

我们先模拟实际操作,添加一个 Weather 对象到 EventAdapter 中,然后断言只含有一个 Weather 对象。之后我们再检查执行 performClick() 方法执行后的返回值,我们期望 WeatherEvent.NONE 的结果。

@Test  
public void testWeatherViewClickWithNoneConfig() {  
    List<Weather> weathers = new ArrayList<>();  
    Weather weather = new Weather();  
    weathers.add(weather);  
    mEventAdapter.reloadWeather(weathers);  
  
    assertEquals(1, mEventAdapter.getWeather().size());  
  
    WeatherView weatherView = mock(WeatherView.class);  
  
    assertEquals(WeatherEvent.NONE, mEventAdapter.performClick(mContext, weatherView, null));  
}

第二个场景

有效的配置列表,含有应用包名和主界面名,同时应用存在 –> 拉起应用

我们需要创建一个 WeatherConf 对象,并设定预期的字段;然后我们需要设定,在 WeatherController.loadWeatherConf() 方法调用时会返回刚才创建的对象。

when(mWeatherController.loadWeatherConf()).thenReturn(weatherConfList);

之后,我们执行 performClick() 方法,并断言期望的返回值为 WeatherEvent.CLICK

assertEquals(WeatherEvent.CLICK, mEventAdapter.performClick(mContext, weatherView, null));

这个预期可能已经足够了,但是我们期望得到更细节的预期:真的拉起了应用吗?

最后,我们检查 Context.startActivity(Intent intent) 方法是否被执行,同时检查它的 Intent 参数是否是我们预设的字段。

// check startActivity() is called and check argument value 
ArgumentCaptor<Intent> argument = ArgumentCaptor.forClass(Intent.class);
verify(mContext).startActivity(argument.capture());  
assertEquals("Intent { cmp=com.example/.activity.MainActivity }", argument.getValue().toString());

这个测试场景的完整代码如下:

@Test  
public void testWeatherViewClickWithInstalledPackageConfig() {  
  
    // add weather item  
    List<Weather> weathers = new ArrayList<>();  
    Weather weather = new Weather();  
    weathers.add(weather);  
    mEventAdapter.reloadWeather(weathers);  
  
    assertEquals(1, mEventAdapter.getWeather().size());  
  
    // mock WeatherView  
    WeatherView weatherView = mock(WeatherView.class);  
  
    List<WeatherConf> weatherConfList = new ArrayList<>();  
    WeatherConf weatherConf = new WeatherConf();  
    weatherConf.setActivityName("com.example.activity.MainActivity");  
    weatherConf.setPackageName("com.example");  
    weatherConfList.add(weatherConf);  
  
    when(mWeatherController.loadWeatherConf()).thenReturn(weatherConfList);  
  
    // check performClick() return value  
    assertEquals(WeatherEvent.CLICK, mEventAdapter.performClick(mContext, weatherView, null));  
  
    // check startActivity() is called and check argument value  
    ArgumentCaptor<Intent> argument = ArgumentCaptor.forClass(Intent.class);  
    verify(mContext).startActivity(argument.capture());  
    assertEquals("Intent { cmp=com.example/.activity.MainActivity }",  
            argument.getValue().toString());  
  
}

第三个场景

有效的配置列表,含有应用包名和主界面名,同时应用不存在 –> 拉起应用商店安装应用

这个场景和第二个场景类似,相同点不在赘述,只是我们需要在拉起应用时抛出一个运行时异常,然后才会走到拉起应用商店的代码:

// throw an no package found exception when startActivity() is called  
doThrow(new RuntimeException("no package found")).when(mContext).startActivity(argThat(  
        new ArgumentMatcher<Intent>() {  
            @Override  
  public boolean matches(Intent argument) {  
                return argument.toString().equals(  
                        "Intent { cmp=com.example/.activity.MainActivity }");  
            }  
        }));

这个场景的完整测试代码如下:

@Test  
public void testWeatherViewClickWithNoInstalledPackageConfig() {  
  
    // add weather item  
  List<Weather> weathers = new ArrayList<>();  
    Weather weather = new Weather();  
    weathers.add(weather);  
    mEventAdapter.reloadWeather(weathers);  
  
    assertEquals(1, mEventAdapter.getWeather().size());  
  
    // mock WeatherView  
  WeatherView weatherView = mock(WeatherView.class);  
  
    List<WeatherConf> weatherConfList = new ArrayList<>();  
    WeatherConf weatherConf = new WeatherConf();  
    weatherConf.setActivityName("com.example.activity.MainActivity");  
    weatherConf.setPackageName("com.example");  
    weatherConfList.add(weatherConf);  
  
    when(mWeatherController.loadWeatherConf()).thenReturn(weatherConfList);  
  
    // throw an no package found exception when startActivity() is called  
  doThrow(new RuntimeException("no package found")).when(mContext).startActivity(argThat(  
            new ArgumentMatcher<Intent>() {  
                @Override  
  public boolean matches(Intent argument) {  
                    return argument.toString().equals(  
                            "Intent { cmp=com.example/.activity.MainActivity }");  
                }  
            }));  
  
    // check performClick() return value  
  assertEquals(WeatherEvent.INSTALL, mEventAdapter.performClick(mContext, weatherView, null));  
  
    // check startActivity() is called and check argument value is start app market  
  ArgumentCaptor<Intent> argument = ArgumentCaptor.forClass(Intent.class);  
    verify(mContext, times(2)).startActivity(argument.capture());  
    assertEquals(  
            "Intent { act=android.intent.action.VIEW dat=market://details?id=com.example }",  
            argument.getValue().toString());  
}

第四个场景

有效的配置列表,含有应用包名、主界面名和拉起网页地址,同时应用不存在 –> 拉起网页

这个场景和前两个场景类似,只是输入的配置字段多了一个拉起链接,我们期望拉起的网页和我们输入的字段相同:

// check startActivity() is called and check argument value is start web view intent  
ArgumentCaptor<Intent> argument = ArgumentCaptor.forClass(Intent.class);  
verify(mContext, times(2)).startActivity(argument.capture());  
Intent intent = argument.getValue();  
assertEquals("android.intent.action.VIEW", intent.getAction());  
assertEquals("https://www.example.com/weather.html", intent.getData().toString());

这个场景的完整测试代码如下:

@Test  
public void testWeatherViewClickWithNoInstalledPackageAndWithUrlConfig() {  
  
    // add weather item  
  List<Weather> weathers = new ArrayList<>();  
    Weather weather = new Weather();  
    weathers.add(weather);  
    mEventAdapter.reloadWeather(weathers);  
  
    assertEquals(1, mEventAdapter.getWeather().size());  
  
    // mock WeatherView  
  WeatherView weatherView = mock(WeatherView.class);  
  
    List<WeatherConf> weatherConfList = new ArrayList<>();  
    WeatherConf weatherConf = new WeatherConf();  
    weatherConf.setActivityName("com.example.activity.MainActivity");  
    weatherConf.setPackageName("com.example");  
    weatherConf.setWebUrl("https://www.example.com/weather.html");  
    weatherConfList.add(weatherConf);  
  
    when(mWeatherController.loadWeatherConf()).thenReturn(weatherConfList);  
  
    // throw an no package found exception when startActivity() is called  
  doThrow(new RuntimeException("no package found")).when(mContext).startActivity(argThat(  
            new ArgumentMatcher<Intent>() {  
                @Override  
  public boolean matches(Intent argument) {  
                    return argument.toString().equals(  
                            "Intent { cmp=com.example/.activity.MainActivity }");  
                }  
            }));  
  
    // check performClick() return value  
  assertEquals(WeatherEvent.WEB_URL, mEventAdapter.performClick(mContext, weatherView, null));  
  
    // check startActivity() is called and check argument value is start web view intent  
  ArgumentCaptor<Intent> argument = ArgumentCaptor.forClass(Intent.class);  
    verify(mContext, times(2)).startActivity(argument.capture());  
    Intent intent = argument.getValue();  
    assertEquals("android.intent.action.VIEW", intent.getAction());  
    assertEquals("https://www.example.com/weather.html", intent.getData().toString());  
}

总结

运行覆盖率检查,我们可以看到这个方法的业务逻辑已经被完全覆盖到了,可以说明这些测试代码是必要的。

从代码示例中也可以看出 mockito 的使用是很简单和强大的,可以帮助我们快速的开发测试代码,断言预期。同时通过这些测试,可以让我们明晰代码的依赖关系,避免编写出来耦合太多或依赖混乱的代码。