Android单元测试学习

android中的单元测试基于JUnit,可分为本地测试和instrumented测试,在项目中对应

  • module-name/src/test/java/.
     该目录下的代码运行在本地JVM上,其优点是速度快,不需要设备或模拟器的支持,但是无法直接运行含有android系统API引用的测试代码。

  • module-name/src/androidTest/java/.

    该目录下的测试代码需要运行在android设备或模拟器下面,因此可以使用android系统的API,速度较慢。


    image-20200613151726348.png

以上分别执行在JUnit和AndroidJUnitRunner的测试运行环境,两者主要的区别在于是否需要android系统API的依赖。
 在实际开发过程中,我们应该尽量用JUnit实现本地JVM的单元测试,而项目中的代码大致可分为以下三类:

  • 1.强依赖关系,如在Activity,Service等组件中的方法,其特点是大部分为private方法,并且与其生命周期相关,无法直接进行单元测试,可以进行Ecspreso等UI测试。
  • 2.部分依赖,代码实现依赖注入,该类需要依赖Context等android对象的依赖,可以通过Mock或其它第三方框架实现JUnit单元测试或使用androidJunitRunner进行单元测试。
  • 3.纯java代码,不存在对android库的依赖,可以进行JUnit单元测试

常用的测试框架

在android测试框架中,常用的有以下几个框架和工具类:

  • JUnit4
  • AndroidJUnitRunner
  • Mockito
  • Espresso

关于单元测试框架的选择,可以参考下图:


1802592-bbf29997eabf7f13.webp.jpg

JUnit4

JUnit4是一套基于注解的单元测试框架。在android studio中,编写在test目录下的测试类都是基于该框架实现,该目录下的测试代码运行在本地的JVM上,不需要设备(真机或模拟器)的支持。
 JUnit4中常用的几个注解:

  • @BeforeClass 测试类里所有用例运行之前,运行一次这个方法。方法必须是public static void
  • @AfterClass 与BeforeClass对应
  • @Before 在每个用测试例运行之前都运行一次。
  • @After 与Before对应
  • @Test 指定该方法为测试方法,方法必须是public void
  • @RunWith 测试类名之前,用来确定这个类的测试运行器

对于其它的注解,可以通过查看junit4官网来进一步学习。
 在test下添加测试类,对于需要进行测试的方法添加@Test注解,在该方法中使用assert进行判断,为了使assert更加直观,方便,可以使用Hamcrest library,通过使用hamcrest的匹配工具,可以让你更灵活的进行测试。 以下是一个最简单的测试类CalculatorTest的实现:

public class CalculatorTest {

    /** 计算功能类 */
    private Calculator mCalculator;

    @Before
    public void setUp() {
        mCalculator = new Calculator();
    }

    /**
     * 测试两个数相加
     */
    @Test
    public void addTwoNumbers() {
        double resultAdd = mCalculator.add(1d, 1d);
        //使用hamcrest进行assert,直观,易读
        assertThat(resultAdd, is(equalTo(2d)));
    }
    ……
}
image-20200613154145815.png
image-20200613154204670.png

点击执行Run CalculatorTest,整个类的@Test注解的方法都执行,或者可以选择执行单个@Test注解方法

当需要传入多个参数进行条件,即条件覆盖时,可以使用@Parameters来进行单个方法的多次不同参数的测试,对应Demo中的CalculatorWithParameterizedTest测试类,使用该方法需要如下步骤:

  • 1.在测试类上添加@RunWith(Parameterized.class)注解。
  • 2.添加构造方法,并将测试的参数作为其构造参数。
  • 3.添加获取参数集合的static方法,并在该方法上添加@Parameters注解。
  • 4.在需要测试的方法中直接使用成员变量,该变量由JUnit通过构造方法生成。
@RunWith(Parameterized.class)
public class CalculatorWithParameterizedTest {

    /** 参数的变量 */
    private final double mOperandOne;
    private final double mOperandTwo;
    /** 期待值 */
    private final double mExpectedResult;
    /** 计算类 */
    private Calculator mCalculator;

    /**
     * 构造方法,框架可以自动填充参数
     */
    public CalculatorWithParameterizedTest(double operandOne, double operandTwo,
            double expectedResult){
        mOperandOne = operandOne;
        mOperandTwo = operandTwo;
        mExpectedResult = expectedResult;
    }

    /**
     * 需要测试的参数和对应结果
     */
    @Parameterized.Parameters
    public static Collection<Object[]> initData(){
        return Arrays.asList(new Object[][]{
                {0, 0, 0},
                {0, -1, -1},
                {2, 2, 4},
                {8, 8, 16},
                {16, 16, 32},
                {32, 0, 32},
                {64, 64, 128}});
    }


    @Before
    public void setUp() {
        mCalculator = new Calculator();
    }

    /**
     * 使用参数组测试加的相关操作
     */
    @Test
    public void testAdd_TwoNumbers() {
        double resultAdd = mCalculator.add(mOperandOne, mOperandTwo);
        assertThat(resultAdd, is(equalTo(mExpectedResult)));
    }

}
image-20200613154839136.png

现在目录下存在如下Test类


image-20200613154912384.png

如果我们需要同时运行两个或多个Test类怎么办?JUnit提供了Suite注解,在对应的测试目录下创建一个空Test类,如Demo里的UnitTestSuite,该类上添加如下注解:

  • @RunWith(Suite.class):配置Runner运行环境。
  • @Suite.SuiteClasses({A.class, B.class}):添加需要一起运行的测试类。
/**
 * 通过Suite来运行多个Test类
 */
@RunWith(Suite.class)
@Suite.SuiteClasses({CalculatorTest.class, SharedPreferencesHelperWithMockTest.class})
public class UnitTestSuite {
}
image-20200613155251297.png

image-20200613155312948.png

目前为止已经可以完成简单的单元测试了,但在android中,方法中使用到android系统api是一件司空见惯的事,比如Context,Parcelable,SharedPreferences等等。而在本地JVM中无法调用这些接口,因此,我们就需要使用AndroidJUnitRunner来完成这些方法的测试

AndroidJUnitRunner

当单元测试中涉及到大量的android系统库的调用时,你可以通过该方案类完成测试。使用方法是在androidTest目录下创建测试类,在该类上添加@RunWith(AndroidJUnit4.class)注解。
 在Demo中androidTest目录下的SharedPreferencesHelperTest测试类,该类对SharedPreferencesHelper进行了单元测试,其方法内部涉及到了SharedPreferences,该类属于android系统的api,因此无法直接在test中运行。部分实现代码如下:

@RunWith(AndroidJUnit4.class)
public class SharedPreferencesHelperTest {

    private static final String TEST_NAME = "Test name";

    private static final String TEST_EMAIL = "test@email.com";

    private static final Calendar TEST_DATE_OF_BIRTH = Calendar.getInstance();

    private Context context;
    private SharedPreferences mSharedPreferences;
    private SharedPreferenceEntry mSharedPreferenceEntry;
    private SharedPreferencesHelper mSharedPreferencesHelper;


    @Before
    public void setUp() throws Exception{
        //获取application的context
        context = InstrumentationRegistry.getInstrumentation().getTargetContext();
        //实例化SharedPreferences
        mSharedPreferences = PreferenceManager.getDefaultSharedPreferences(context);

        mSharedPreferenceEntry = new SharedPreferenceEntry(TEST_NAME, TEST_DATE_OF_BIRTH, TEST_EMAIL);
        //实例化SharedPreferencesHelper,依赖注入SharePreferences
        mSharedPreferencesHelper = new SharedPreferencesHelper(mSharedPreferences);
    }

    /**
     * 测试保存数据是否成功
     */
    @Test
    public void sharedPreferencesHelper_SavePersonalInformation() throws Exception {
        assertThat(mSharedPreferencesHelper.savePersonalInfo(mSharedPreferenceEntry), is(true));
    }

    /**
     * 测试保存数据,然后获取数据是否成功
     */
    @Test
    public void sharedPreferencesHelper_SaveAndReadPersonalInformation() throws Exception {
        mSharedPreferencesHelper.savePersonalInfo(mSharedPreferenceEntry);
        SharedPreferenceEntry sharedPreferenceEntry = mSharedPreferencesHelper.getPersonalInfo();
        assertThat(isEquals(mSharedPreferenceEntry, sharedPreferenceEntry), is(true));
    }

    ...
}

使用AndroidJUnitRunner最大的缺点在于无法在本地JVM运行,直接的结果就是测试速度慢,同时无法执行覆盖测试。因此出现了很多替代方案,比如在设计合理,依赖注入实现的代码,可以使用Mockito来进行本地测试,或者使用第三方测试框架Robolectric等。

Mockito

涉及到android依赖的方法的测试,除了在androidTest使用,还可以通过mock来执行本地测试。使用Mock的目的主要有以下两点:

  • 验证这个对象的某些方法的调用情况,调用了多少次,参数是什么等等
  • 指定这个对象的某些方法的行为,返回特定的值,或者是执行特定的动作

Mockito是优秀的mock框架之一,使用该框架可以使mock的操作更加简单,直观。
 要使用Mockito,需要添加如下依赖:

dependencies {
    implementation fileTree(dir: "libs", include: ["*.jar"])
    implementation 'androidx.appcompat:appcompat:1.1.0'
    implementation 'androidx.constraintlayout:constraintlayout:1.1.3'

    androidTestImplementation 'androidx.test.ext:junit:1.1.1'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'


    testImplementation 'junit:junit:4.12'
    //如果你要使用Mockito 用于 Android instrumentation tests,那么需要你添加以下三条依赖库,在版本//mockito-core:2.+以上时dexmaker:1.2和dexmaker-mockito:1.2不好使,需要换成//com.linkedin.dexmaker:dexmaker-mockito:2.25.0
    testImplementation 'org.mockito:mockito-core:2.+'
    androidTestImplementation 'org.mockito:mockito-core:2.+'
//    androidTestImplementation "com.google.dexmaker:dexmaker:1.2"
//    androidTestImplementation "com.google.dexmaker:dexmaker-mockito:1.2"
    androidTestImplementation 'com.linkedin.dexmaker:dexmaker-mockito:2.25.0'
}

AndroidJUnitRunner介绍中的对于SharedPreferencesHelper的测试,由于其依赖注入的设计,我们可以方便的去mock一个SharePreferences来执行本地的测试。在Demo中的test目录下的SharedPreferencesHelperWithMockTest类即通过mockito来完成测试的,主要代码如下:

@RunWith(MockitoJUnitRunner.class)
public class SharedPreferencesHelperWithMockTest {

    private static final String TEST_NAME = "Test name";
    private static final String TEST_EMAIL = "test@email.com";
    private static final Calendar TEST_DATE_OF_BIRTH = Calendar.getInstance();
    private SharedPreferencesHelper mSharedPreferencesHelper;
    private SharedPreferenceEntry mSharedPreferenceEntry;
    ……
    @Mock
    SharedPreferences mMockSharedPreferences;
    @Mock
    SharedPreferences.Editor mMockEditor;
    ……
    @Before
    public void setUp() throws Exception {
        mSharedPreferenceEntry = new SharedPreferenceEntry(TEST_NAME, TEST_DATE_OF_BIRTH, TEST_EMAIL);
        mSharedPreferencesHelper = new SharedPreferencesHelper(mockSharePreferences());
        ……
    }

    @Test
    public void sharedPreferencesHelper_SavePersonalInformation() throws Exception {
        assertThat(mSharedPreferencesHelper.savePersonalInfo(mSharedPreferenceEntry), is(true));
    }

    @Test
    public void sharedPreferencesHelper_SaveAndReadPersonalInformation() throws Exception {
        mSharedPreferencesHelper.savePersonalInfo(mSharedPreferenceEntry);
        SharedPreferenceEntry sharedPreferenceEntry = mSharedPreferencesHelper.getPersonalInfo();
        assertThat(isEquals(mSharedPreferenceEntry, sharedPreferenceEntry), is(true));
    }
    ……
    /**
     * 编写Mock相关代码,代码中mock了SharedPreferences类的getXxx的相关操作,
     * 均返回SharedPreferenceEntry对象的值,同时在代码中使用到了commit和edit,都需要在方法中进行mock实现
     * Creates a mocked SharedPreferences.
     */
    private SharedPreferences mockSharePreferences(){
        when(mMockSharedPreferences.getString(eq(SharedPreferencesHelper.KEY_NAME), anyString()))
                .thenReturn(mSharedPreferenceEntry.getName());
        when(mMockSharedPreferences.getString(eq(SharedPreferencesHelper.KEY_EMAIL), anyString()))
                .thenReturn(mSharedPreferenceEntry.getEmail());
        when(mMockSharedPreferences.getLong(eq(SharedPreferencesHelper.KEY_DOB), anyLong()))
                .thenReturn(mSharedPreferenceEntry.getDateOfBirth().getTimeInMillis());
        when(mMockEditor.commit()).thenReturn(true);
        when(mMockSharedPreferences.edit()).thenReturn(mMockEditor);
        return mMockSharedPreferences;
    }
    ……
}

Mocktio的局限

    1. 不能 mock 静态方法;
    2. 不能 mock 构造器;
    3. 不能 mock equals()hashCode() 方法

Espresso

在Demo中,除了单元测试的用例,还提供了一个CalculatorInstrumentationTest测试类,该类使用Espresso,一个官方提供了UI测试框架。注意,UI测试不属于单元测试的范畴。通过Espresso的使用,可以编写简洁、运行可靠的自动化UI测试。详细的使用可以参考测试支持库中关于Espresso的使用介绍。

@RunWith(AndroidJUnit4.class)
@LargeTest
public class CalculatorInstrumentationTest {

    /**
     * 在测试中运行Activity
     * A JUnit {@link Rule @Rule} to launch your activity under test. This is a replacement
     * for {@link ActivityInstrumentationTestCase2}.
     * <p>
     * Rules are interceptors which are executed for each test method and will run before
     * any of your setup code in the {@link Before @Before} method.
     * <p>
     * {@link ActivityTestRule} will create and launch of the activity for you and also expose
     * the activity under test. To get a reference to the activity you can use
     * the {@link ActivityTestRule#getActivity()} method.
     */
    @Rule
    public ActivityTestRule<CalculatorActivity> mActivityRule = new ActivityTestRule<>(
            CalculatorActivity.class);
    ……
    private void performOperation(int btnOperationResId, String operandOne,
            String operandTwo, String expectedResult) {
        // 指定输入框中输入文本,同时关闭键盘
        onView(withId(R.id.operand_one_edit_text)).perform(typeText(operandOne),
                closeSoftKeyboard());
        onView(withId(R.id.operand_two_edit_text)).perform(typeText(operandTwo),
                closeSoftKeyboard());

        // 获取特定按钮执行点击事件
        onView(withId(btnOperationResId)).perform(click());

        // 获取文本框中显示的结果
        onView(withId(R.id.operation_result_text_view)).check(matches(withText(expectedResult)));
    }

}

其它

关于异步操作的单元测试

在实际的android开发过程中,经常涉及到异步操作,比如网络请求,Rxjava的线程调度等。在单元测试中,往往测试方法执行往了,异步操作还没介绍,这就导致了无法顺利的执行单元测试操作。其解决方法可以提供CountDownLatch类来阻塞测试方法的线程,当异步操作完成后(通过回调)来唤醒继续执行测试,获取结果。其实对于网络请求这种操作应该使用Mock来替代,因为你的单元测试的结果不应受网络的影响,不需要关注网络是否正常,服务器是否崩溃,而应该把关注点放在单元本身的操作。

单元测试,集成测试,UI测试

  • UI测试是测试到交互和视觉,以及操作的结果是否符合预期。可以通过Espresso,UI Automator等框架,或者人工测试。
  • 集成测试是基于单元测试,将多个单元测试组装起来进行测试,实际测试往往会运行慢,依赖过多导致集成测试非常费时。
  • 单元测试仅针对最小单元,在面向对象中,单元指的是方法,包括基类(超类)、抽象类、或者派生类(子类)中的方法。

三者的在实际应用中可以通过Test Pyramid(Martin Fowler的总结)来衡量:


1802592-f6c8caf172edbdce.webp.jpg

所以对于测试,在开发过程中,我们(开发者)需要把更多的精力放在单元测试上。

本文转自作者“BooQin”的Android单元测试-常见的方案比较,如有侵权,请联系作者效效进行修改。

项目代码地址:

https://github.com/games2sven/JUit

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,088评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,715评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,361评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,099评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 60,987评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,063评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,486评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,175评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,440评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,518评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,305评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,190评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,550评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,880评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,152评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,451评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,637评论 2 335