Android 单元测试 Robolectric

参考:

  • 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 文件同级的名字为 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

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();

你可能感兴趣的:(Android 单元测试 Robolectric)