参考:
- 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 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 文件同级的名字为 res
和 assets
的文件夹。
@Config(resourceDir = "some/build/path/res")
public class SandwichTest {
@Config(resourceDir = "other/build/path/ham-sandwich/res")
public void getSandwich_shouldReturnHamSandwich() {
}
}
限定资源
values/strings.xml
Not Overridden
Unqualified value
Unqualified value
values-en/strings.xml
English qualified value
English qualified value
values-en-port/strings.xml
English portrait qualified value
@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();