前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Android 单元测试 Robolectric

Android 单元测试 Robolectric

作者头像
三流编程
发布2018-09-11 16:13:04
2.2K0
发布2018-09-11 16:13:04
举报

参考:


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

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

代码语言:javascript
复制
testImplementation "org.robolectric:robolectric:3.8"

android {
  testOptions {
    unitTests {
      includeAndroidResources = true
    }
  }
}
代码语言:javascript
复制
@RunWith(RobolectricTestRunner.class)
@Config(constants = BuildConfig.class)
public class SandwichTest {
}

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


MainActivity 里有个 TextView

代码语言:javascript
复制
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)

代码语言:javascript
复制
@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

代码语言:javascript
复制
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 是否正确。

代码语言:javascript
复制
@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());
}

生命周期

代码语言:javascript
复制
@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());
}

完整的生命周期

代码语言:javascript
复制
Activity activity = Robolectric.buildActivity(MyAwesomeActivity.class).create().start().resume().visible().get();

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

模拟 Intent 启动

代码语言:javascript
复制
Intent intent = new Intent(Intent.ACTION_VIEW);
Activity activity = Robolectric.buildActivity(MyAwesomeActivity.class, intent).create().get();

启动后恢复数据

代码语言:javascript
复制
Bundle savedInstanceState = new Bundle();
Activity activity = Robolectric.buildActivity(MyAwesomeActivity.class)
    .create()
    .restoreInstanceState(savedInstanceState)
    .get();

Config 配置

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

@Config

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

代码语言:javascript
复制
@Config(sdk=JELLYBEAN_MR1,
   manifest="some/build/path/AndroidManifest.xml",
   shadows={ShadowFoo.class, ShadowBar.class})
public class SandwichTest {
}

robolectric.properties

代码语言:javascript
复制
# src/test/resources/com/mycompany/app/robolectric.properties
sdk=18
manifest=some/build/path/AndroidManifest.xml
shadows=my.package.ShadowFoo,my.package.ShadowBar

可配置项

配置 SDK 版本

代码语言:javascript
复制
@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。

代码语言:javascript
复制
@Config(application = CustomApplication.class)
public class SandwichTest {
    @Config(application = CustomApplicationOverride.class)
    public void getSandwich_shouldReturnHamSandwich() {
    }
}

配置 Resource/Asset 路径

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

代码语言:javascript
复制
@Config(resourceDir = "some/build/path/res")
public class SandwichTest {

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

限定资源

values/strings.xml

代码语言:javascript
复制
<string name="not_overridden">Not Overridden</string>
<string name="overridden">Unqualified value</string>
<string name="overridden_twice">Unqualified value</string>

values-en/strings.xml

代码语言:javascript
复制
<string name="overridden">English qualified value</string>
<string name="overridden_twice">English qualified value</string>

values-en-port/strings.xml

代码语言:javascript
复制
<string name="overridden_twice">English portrait qualified value</string>
代码语言:javascript
复制
@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 — 允许调试日志
代码语言:javascript
复制
android {
  testOptions {
    unitTests.all {
      systemProperty 'robolectric.dependency.repo.url', 'https://local-mirror/repo'
      systemProperty 'robolectric.dependency.repo.id', 'local'
    }
  }
}

设备配置

代码语言:javascript
复制
@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.

代码语言:javascript
复制
@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。

代码语言:javascript
复制
@Implements(ViewGroup.class)
public class ShadowViewGroup extends ShadowView {
}

方法

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

代码语言:javascript
复制
@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

代码语言:javascript
复制
@Implements(TextView.class)
public class ShadowTextView {
  @Implementation
  protected void __constructor__(Context context) {
    this.context = context;
  }
}

修改关联类的属性

代码语言:javascript
复制
@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 类。

代码语言:javascript
复制
public class Person {

    private String name;

    public String getName() {
        return name;
    }

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

自定义对应的 Shadow 类

代码语言:javascript
复制
@Implements(Person.class)
public class ShadowPerson {
    // 重写其中一个方法
    @Implementation
    public String getName() {
        return "AndroidUT";
    }
}
代码语言:javascript
复制
// @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 来验证。

代码语言:javascript
复制
@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

代码语言:javascript
复制
button.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View view) {
        Toast.makeText(MainActivity.this, "Show Toast", Toast.LENGTH_LONG).show();
    }
});
代码语言:javascript
复制
@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 类似

代码语言:javascript
复制
@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 对象

代码语言:javascript
复制
Application application = RuntimeEnvironment.application;
String appName = application.getString(R.string.app_name);

BroadcastReceiver 的注册和接收测试

代码语言:javascript
复制
// 验证广播接收者是否注册
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 测试

代码语言:javascript
复制
controller = Robolectric.buildService(MyService.class);
// 得到 Service
mService = controller.get();

// 生命周期执行
controller.create();
controller.startCommand(0, 0);
controller.bind();
controller.unbind();
controller.destroy();
本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
原始发表:2018.04.27 ,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 生命周期
  • Config 配置
    • @Config
      • robolectric.properties
        • 可配置项
          • 配置 SDK 版本
          • 配置 Application
          • 配置 Resource/Asset 路径
          • 限定资源
        • 系统配置
          • 设备配置
          • Shadows
            • 方法
              • 构造方法
                • 自定义
                  • 例子
                  领券
                  问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档