Android 单元测试 Robolectric

参考:


通过实现一套 JVM 能够运行的 Android 代码,从而实现脱离 Android 环境进行测试。

src/test 目录也是它的工作目录。

testImplementation "org.robolectric:robolectric:3.8"

android {
  testOptions {
    unitTests {
      includeAndroidResources = true
    }
  }
}
@RunWith(RobolectricTestRunner.class)
@Config(constants = BuildConfig.class)
public class SandwichTest {
}

它可以方便地对 Activity,Fragment,Service,BroadcastReceiver 进行单元测试。


MainActivity 里有个 TextView

public class MainActivity extends AppCompatActivity {
    private static final String TAG = "MainActivity";
    private TextView tv;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        tv = findViewById(R.id.tv);
        tv.setText("onCreate");
    }

    @Override
    protected void onResume() {
        super.onResume();
        tv.setText("onResume");
    }
}

Robolectric 3.3 会自动寻找 /com/android/tools/test_config.properties,无需再指定 @Config(constants = BuildConfig.class)

@RunWith(RobolectricTestRunner.class)
public class MainActivityTest {

    @Test
    public void setup() {
        // setupActivity 会依次执行 Activity 的 onCreate, onStart, onResume
        Activity activity = Robolectric.setupActivity(MainActivity.class);
        assertNotNull(activity);
        TextView textview = activity.findViewById(R.id.tv);
        // 执行完 onResume 后文字变了
        assertEquals("onResume", textview.getText().toString());
    }
}

Activity 中有一个 Button,点击就去一个 LoginActivity。执行点击,检查 Intent

final View button = findViewById(R.id.login);
button.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View view) {
        startActivity(new Intent(MainActivity.this, LoginActivity.class));
    }
});

单元测试时框架 Robolectric 并不能真的启动 LoginActivity,但是可以检查 Intent 是否正确。

@Test
public void clickingLogin_shouldStartLoginActivity() {
    // setupActivity 会依次执行 Activity 的 onCreate, onStart, onResume
    MainActivity activity = Robolectric.setupActivity(MainActivity.class);
    activity.findViewById(R.id.login).performClick(); // 模拟执行一个点击

    Intent expectedIntent = new Intent(activity, LoginActivity.class); // 期望的 Intent
    Intent actual = ShadowApplication.getInstance().getNextStartedActivity(); // 真实获取到的 Intent
    assertEquals(expectedIntent.getComponent(), actual.getComponent());
}

生命周期

@Test
public void testLifecycle() {
    ActivityController<MainActivity> activityController = Robolectric.buildActivity(MainActivity.class).create().start();
    Activity activity = activityController.get(); // get 前已执行 create,start,那么这个 Activity 还没有执行过 resume
    TextView textview = (TextView) activity.findViewById(R.id.tv);
    assertEquals("onCreate", textview.getText().toString());
    activityController.resume(); // 之后执行 resume
    assertEquals("onResume", textview.getText().toString());
}

完整的生命周期

Activity activity = Robolectric.buildActivity(MyAwesomeActivity.class).create().start().resume().visible().get();

如果需要和页面控件交互,需要调用 visible() 来保证在单元测试中可以交互。

模拟 Intent 启动

Intent intent = new Intent(Intent.ACTION_VIEW);
Activity activity = Robolectric.buildActivity(MyAwesomeActivity.class, intent).create().get();

启动后恢复数据

Bundle savedInstanceState = new Bundle();
Activity activity = Robolectric.buildActivity(MyAwesomeActivity.class)
    .create()
    .restoreInstanceState(savedInstanceState)
    .get();

Config 配置

有两种方式,一种是包级别的 robolectric.properties 文件,另一种是在类或方法上加 @Config

@Config

方法上的配置会覆盖类上的配置。基类上的配置子类都会继承,所以如果有很多类都需要同样的配置,可以创建父类使用。

@Config(sdk=JELLYBEAN_MR1,
   manifest="some/build/path/AndroidManifest.xml",
   shadows={ShadowFoo.class, ShadowBar.class})
public class SandwichTest {
}

robolectric.properties

# src/test/resources/com/mycompany/app/robolectric.properties
sdk=18
manifest=some/build/path/AndroidManifest.xml
shadows=my.package.ShadowFoo,my.package.ShadowBar

可配置项

配置 SDK 版本

@Config(sdk = { JELLY_BEAN, JELLY_BEAN_MR1 })
public class SandwichTest {

    public void getSandwich_shouldReturnHamSandwich() {
      // will run on JELLY_BEAN and JELLY_BEAN_MR1
    }

    @Config(sdk = KITKAT)
    public void onKitKat_getSandwich_shouldReturnChocolateWaferSandwich() {
      // will run on KITKAT
    }
}

配置 Application

默认情况下 Robolectric 创建的 Application 会根据 manifest 的配置来,但是可以指定其它的 Application。

@Config(application = CustomApplication.class)
public class SandwichTest {
    @Config(application = CustomApplicationOverride.class)
    public void getSandwich_shouldReturnHamSandwich() {
    }
}

配置 Resource/Asset 路径

默认会寻找和 manifest 文件同级的名字为 resassets 的文件夹。

@Config(resourceDir = "some/build/path/res")
public class SandwichTest {

    @Config(resourceDir = "other/build/path/ham-sandwich/res")
    public void getSandwich_shouldReturnHamSandwich() {
    }
}

限定资源

values/strings.xml

<string name="not_overridden">Not Overridden</string>
<string name="overridden">Unqualified value</string>
<string name="overridden_twice">Unqualified value</string>

values-en/strings.xml

<string name="overridden">English qualified value</string>
<string name="overridden_twice">English qualified value</string>

values-en-port/strings.xml

<string name="overridden_twice">English portrait qualified value</string>
@Test
@Config(qualifiers="en-port") // 限定用哪个资源
public void shouldUseEnglishAndPortraitResources() {
  final Context context = RuntimeEnvironment.application;
  // en-port 没有,en 也没有,用默认的
  assertThat(context.getString(R.id.not_overridden)).isEqualTo("Not Overridden");
  // en-port 没有,用 en 的
  assertThat(context.getString(R.id.overridden)).isEqualTo("English qualified value");
  assertThat(context.getString(R.id.overridden_twice)).isEqualTo("English portrait qualified value");
}

系统配置

  • robolectric.enabledSdks — 如 19、21 或 KITKAT、LOLLIPOP,只有列出的 SDK 版本会运行,若不设置,所有版本 SDK 都可行。
  • robolectric.offline — 禁止运行时网络抓取依赖 jar 包。
  • robolectric.dependency.dir — robolectric.offline 时,配置运行时依赖文件所在的文件夹路径。
  • robolectric.dependency.repo.id — 运行时依赖从 Maven 仓库(default sonatype)拉,配置这个仓库的 ID。
  • robolectric.dependency.repo.url — 运行时从 Maven 仓库拉依赖,配置仓库的路径(default https://oss.sonatype.org/content/groups/public/).
  • robolectric.logging.enabled — 允许调试日志
android {
  testOptions {
    unitTests.all {
      systemProperty 'robolectric.dependency.repo.url', 'https://local-mirror/repo'
      systemProperty 'robolectric.dependency.repo.id', 'local'
    }
  }
}

设备配置

@Test @Config(qualifiers = "fr-rFR-w360dp-h640dp-xhdpi")
public void testItOnFrenchNexus5() { ... }

未指定的属性有些会根据已指定的属性来变化,有些使用默认值。

属性

根据其它属性值计算(如果未指定)

默认值

其它规则

MCC and MNC

None.

None

Language, region, and script (locale)

None.

en-rUS

Layout direction

当前布局方向

ldltr

Smallest width

width 和 height 两个属性中更小的

sw320dp

Width

如果指定了 screen size, 宽度对应

w320dp

如果指定了屏幕方向,宽高会根据屏幕方向变换

Height

如果指定了 screen size, 高度对应。如果纵横比过大,即 Screen aspect 的值是 long,高度会再大 25%

h470dp

如果指定了屏幕方向,宽高会根据屏幕方向变换

Screen size

如果指定了 height 和 width,尺寸对应

normal

Screen aspect

如果指定了 height 和 width, 且纵横比超过 1.75,值是 long

notlong

Round screen

如果 UI mode 是 watch,值为 round

notround

Wide color gamut

None.

nowidecg

High dynamic range

None.

lowdr

Screen orientation

如果宽高指定,若宽更大就是横屏 land,否则就是 port

port

UI mode

None.

None

Night mode

None.

notnight

Screen pixel density

None.

mdpi

Touchscreen type

None.

finger

Keyboard availability

None.

keyssoft

Primary text input method

None.

nokeys

Navigation key availability

None.

navhidden

Primary non-touch navigation method

None.

nonav

Platform version

The SDK level currently active. Need not be specified.

@Config(qualifiers = "xlarge-port")
class MyTest {
  public void testItWithXlargePort() { ... } // config is "xlarge-port"

  @Config(qualifiers = "+land")
  public void testItWithXlargeLand() { ... } // config is "xlarge-land"

  @Config(qualifiers = "land")
  public void testItWithLand() { ... } // config is "normal-land"
}

// RuntimeEnvironment.setQualifiers() 能够覆盖原来的配置
@Test @Config(qualifiers = "+port")
public void testOrientationChange() {
  controller = Robolectric.buildActivity(MyActivity.class);
  controller.setup();
  // assert that activity is in portrait mode
  RuntimeEnvironment.setQualifiers("+land");
  controller.configurationChange();
  // assert that activity is in landscape mode
}

Shadows

当 Android 的一个类被创建,Robolectric 就会去找一个对应的 Shadow 类,找到的话就创建并将之与 Android 类关联。

Shadow 可以被修改和继承。通过 @Implements 和一个 Android 类关联,必须有一个 public 的无参构造方法。

Shadow 类的继承关系要和其关联的 Android 类保持一致,比如 ShadowViewGroup 关联了 ViewGroup,而 ViewGroup 继承了 View,所以 ShadowViewGroup 要继承和 View 关联的 ShadowView。

@Implements(ViewGroup.class)
public class ShadowViewGroup extends ShadowView {
}

方法

和 Android 类有相同的方法签名,需要注解 @Implementation

@Implements(ImageView.class)
public class ShadowImageView extends ShadowView {

  @Implementation
  protected void setImageResource(int resId) {
    // implementation here.
  }
}

方法必须在相对应的 Shadow 类里定义,比如 View 里有个方法 setEnabled(),那这个方法只能在 ShadowView 里重写,而不能到 ShadowView 的子类 ShadowViewGroup 中重写,如果这样的话,对应的 ViewGroup 中调用 setEnable,Shadow 的寻找机制会找不到这个方法。

构造方法

固定的名字 __constructor__ 和注解 @Implementation

@Implements(TextView.class)
public class ShadowTextView {
  @Implementation
  protected void __constructor__(Context context) {
    this.context = context;
  }
}

修改关联类的属性

@Implements(Point.class)
public class ShadowPoint {
  @RealObject private Point realPoint;

  public void __constructor__(int x, int y) {
    realPoint.x = x;
    realPoint.y = y;
  }
}

自定义

Robolectric 已经内置了很多的 ShadowXXX 类,如果要使用自定义的,需要配置 @Config(shadows={MyShadowBitmap.class, MyOtherCustomShadow.class})

原来的 Shadows.shadowOf() 获取一个 Shadow 的方法对自定义的 Shadow 不适用,需要用 Shadow.extract() 获取并做类型转换,转换成自定义的 Shadow 类。

public class Person {

    private String name;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

自定义对应的 Shadow 类

@Implements(Person.class)
public class ShadowPerson {
    // 重写其中一个方法
    @Implementation
    public String getName() {
        return "AndroidUT";
    }
}
// @Config 指定这个 Shadow 类
@RunWith(RobolectricTestRunner.class)
@Config(shadows = {ShadowPerson.class})
public class ShadowTest {

    @Test
    public void testShadowShadow(){
        Person person = new Person();
        //实际上调用的是ShadowPerson的方法
        System.out.println(person.getName());

        //获取 Person 对象对应的 Shadow 对象
        ShadowPerson shadowPerson = extract(person);
        assertEquals("AndroidUT", shadowPerson.getName());
    }
}

例子

前面测试 Intent 跳转的也可以通过 ShadowActivity 来验证。

@Test
public void clickingLogin_shouldStartLoginActivity() {
   MainActivity activity = Robolectric.setupActivity(MainActivity.class);
   activity.findViewById(R.id.login).performClick(); // 模拟执行一个点击

   Intent expectedIntent = new Intent(activity, LoginActivity.class); // 期望的 Intent

   ShadowActivity activity2 = Shadows.shadowOf(activity);
   Intent actual2 = activity2.getNextStartedActivity();
   assertEquals(expectedIntent.getComponent(), actual2.getComponent());
}

在 Activity 有个按钮,一点击就弹 Toast

button.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View view) {
        Toast.makeText(MainActivity.this, "Show Toast", Toast.LENGTH_LONG).show();
    }
});
@Test
public void clickBtn_shouldShowToast() {
    MainActivity activity = Robolectric.setupActivity(MainActivity.class);

    Toast toast = ShadowToast.getLatestToast();
    assertNull(toast); // 判断 Toast 尚未弹出

    activity.findViewById(R.id.showToast).performClick();

    toast = ShadowToast.getLatestToast();
    assertNotNull(toast); // 判断Toast已经弹出

    ShadowToast shadowToast = Shadows.shadowOf(toast); // 获取 ShadowToast
    assertEquals(Toast.LENGTH_LONG, shadowToast.getDuration());
    assertEquals("Show Toast", ShadowToast.getTextOfLatestToast());
}

Dialog 和 Toast 类似

@Test
public void clickBtn_shouldShowDialog() {
    MainActivity activity = Robolectric.setupActivity(MainActivity.class);

    AlertDialog dialog = ShadowAlertDialog.getLatestAlertDialog();
    assertNull(dialog);

    activity.findViewById(R.id.showDialog).performClick();

    dialog = ShadowAlertDialog.getLatestAlertDialog();
    assertNotNull(dialog);

    ShadowAlertDialog shadowDialog = Shadows.shadowOf(dialog);
    assertEquals("Show Dialog", shadowDialog.getMessage());
}

RuntimeEnvironment.application 获取 Application 对象

Application application = RuntimeEnvironment.application;
String appName = application.getString(R.string.app_name);

BroadcastReceiver 的注册和接收测试

// 验证广播接收者是否注册
ShadowApplication shadowApplication = ShadowApplication.getInstance();
Intent intent = new Intent(action);
assertTrue(shadowApplication.hasReceiverForIntent(intent));

// 模拟已经收到了广播接收者
Intent intent = new Intent(action);
intent.putExtra(MyReceiver.NAME, "AndroidUT");
MyReceiver myReceiver = new MyReceiver();
myReceiver.onReceive(RuntimeEnvironment.application, intent);

Service 测试

controller = Robolectric.buildService(MyService.class);
// 得到 Service
mService = controller.get();

// 生命周期执行
controller.create();
controller.startCommand(0, 0);
controller.bind();
controller.unbind();
controller.destroy();

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏非著名程序员

关于 Android 实现滑动返回的几种方法总结

关于 Android 实现滑动返回的方法,网上有很多种,实现的方式也都各不一样。有用 SwipeBackLayout 开源库的,有用 SlidingPaneLa...

1.2K9
来自专栏何俊林

Android支付实践(二)之微信支付详解与Demo

前言:集成支持宝和微信支付,是公司获取收益的最主要的部分,这两大巨头几乎在支付业务上不可或缺,今天看下Simon_Crystin独家授权本公众号发步的Andro...

2416
来自专栏郭霖

Android ListView工作原理完全解析,带你从源码的角度彻底理解

在Android所有常用的原生控件当中,用法最复杂的应该就是ListView了,它专门用于处理那种内容元素很多,手机屏幕无法展示出所有内容的情况。ListVie...

38410
来自专栏码匠的流水账

聊聊spring cloud gateway的SetStatusGatewayFilter

本文主要研究下spring cloud gateway的SetStatusGatewayFilter

1331
来自专栏everhad

[异常特工]android常见bug跟踪

前言 对app的线上bug的收集(友盟、云捕等)有时会得到这样的异常堆栈信息:没有一行代码是有关自身程序代码的。这使得对bug的解决无从下手,根据经验,内存不足...

2075
来自专栏james大数据架构

在Android中调用WebService

某些情况下我们可能需要与Mysql或者Oracle数据库进行数据交互,有些朋友的第一反应就是直接在Android中加载驱动然后进行数据的增删改查。我个人不推荐这...

2705
来自专栏封碎

Android自由选择TextView的文字 博客分类: Android AndroidUP

      用过EditText的都知道,EditText有个特点,当在里面长按的时候,会出现一个ContextMenu,提供了选择文字,复制,剪切等功能。有时...

2551
来自专栏求索之路

MVVM架构之自动增删改的极简RecycleView的实现

介绍图 先上个源代码的链接:https://github.com/whenSunSet/MVVMRecycleView RecycleView是Google替...

4106
来自专栏顶级程序员

深入浅出 RecyclerView

原文:http://kymjs.com/code/2016/07/10/01 作者:kymjs张涛 今天推荐给各位的是张涛同学最近的一篇文章,说实话,Recy...

4405
来自专栏向治洪

两个activity或者activity和fragment传值

使用Fragment的时候可能需要在两个Fragment之间进行参数的传递,开始想着可以使用SharedPreferences进行处理,想想这些简单的参数没有必...

3345

扫码关注云+社区

领取腾讯云代金券