Android 项目必备(三十三)-->Android 单元测试

在这里插入图片描述

一、前言

单元测试是应用程序测试策略中的基本测试,通过对代码进行单元测试,可以轻松地验证单个单元的逻辑是否正确,在每次构建之后运行单元测试,可以帮助您快速捕获和修复因代码更改(重构、优化等)带来的回归问题。

1. 为什么要进行单元测试?

  • 提高稳定性,能够明确地了解是否正确的完成开发;
  • 快速反馈bug,跑一遍单元测试用例,定位bug;
  • 在开发周期中尽早通过单元测试检查bug,最小化技术债,越往后可能修复bug的代价会越大,严重的情况下会影响项目进度;
  • 为代码重构提供安全保障,在优化代码时不用担心回归问题,在重构后跑一遍测试用例,没通过说明重构可能是有问题的,更加易于维护。

2. 单元测试要测什么?

  • 列出想要测试覆盖的正常、异常情况,进行测试验证;
  • 性能测试,例如某个算法的耗时等等。

3. 单元测试的分类

  • 单元测试(Junit4、Mockito、PowerMockito、Robolectric)

  • UI测试(Espresso、UI Automator)

  • 压力测试(Monkey)

二、使用 Junit4 进行单元测试

1.集成了 Junit4 测试框架

	dependencies {
        ...
        testImplementation 'junit:junit:4.13.2'
    }

2. 注解

@Test 指明这是一个测试方法 (@Test注解可以接受2个参数,一个是预期错误
expected,一个是超时时间timeout,
格式如 @Test(expected = IndexOutOfBoundsException.class),
@Test(timeout = 1000)
@Before 在所有测试方法之前执行
@After 在所有测试方法之后执行
@BeforeClass 在该类的所有测试方法和@Before方法之前执
行 (修饰的方法必须是静态的)@AfterClass 在该类的所有测试方法和@After
方法之后执行(修饰的方法必须是静态的)
@Ignore 忽略此单元测试

3. 测试方法—断言

assertEquals(expected, actual) 判断2个值是否相等,相等则测试通过。
assertEquals(expected, actual, tolerance) tolerance 偏差值

注意:上面的每一个方法,都有一个重载的方法,可以加一个String类型的参数,表示如果验证失败的话,将用这个字符串作为失败的结果报告。

4. 使用 Junit4 进行单元测试

Tep 1. 编写测试类。

Tep 2. 鼠标右键点击测试类,选择选择Go To->Test(或者使用快捷键Ctrl+Shift+T,此快捷键可
以在方法和测试方法之间来回切换)在Test/java/项目测试文件夹/下自动生成测试模板。

Tep3. 使用断言(assertEqual、assertEqualArrayEquals等等)进行单元测试。

Tep 4. 右键点击测试类,Run编写好的测试类。

三、本地测试

根据单元有没有外部依赖(如Android依赖、其他单元的依赖),将本地测试分为两类,首先看看没有依赖的情况:

  • 添加依赖,google官方推荐
dependencies {
    // Required -- JUnit 4 framework
    testImplementation 'junit:junit:4.12'
    // Optional -- Mockito framework(可选,用于模拟一些依赖对象,以达到隔离依赖的效果)
    testImplementation 'org.mockito:mockito-core:2.19.0'
}
  • 单元测试代码存储位置
    事实上,AS已经帮我们创建好了测试代码存储目录。
app/src
     ├── androidTestjava (仪器化单元测试、UI测试)
     ├── main/java (业务代码)
     └── test/java  (本地单元测试)
  • 创建测试类
    可以自己手动在相应目录创建测试类,AS也提供了一种快捷方式:选择对应的类->将光标停留在类名上->按下ALT + ENTER->在弹出的弹窗中选择Create Test
    在这里插入图片描述
    说明: 勾选 setUp/@Before会生成一个带@Before注解的setUp()空方法,tearDown/@After则会生成一个带@After的空方法。

  • 运行测试用例

    • 运行单个测试方法:选中@Test注解或者方法名,右键选择Run;
    • 运行一个测试类中的所有测试方法:打开类文件,在类的范围内右键选择Run,或者直接选择类文件直接右键Run;
    • 运行一个目录下的所有测试类:选择这个目录,右键Run。
  • 运行前面测试验证邮箱格式的例子,测试结果会在Run窗口展示,如下图:
    在这里插入图片描述
    从结果可以清晰的看出,测试的方法为 EmailValidatorTest 类中的 isValidEmail()方法,测试状态为passed,耗时12毫秒。

修改一下前面的例子,传入一个非法的邮箱地址:

@Test
public void isValidEmail() {
    assertThat(EmailValidator.isValidEmail("#name@email.com"), is(true));
}

在这里插入图片描述
测试状态为failed,耗时14毫秒,同时也给出了详细的错误信息:在15行出现了断言错误,错误原因是期望值(Expected)为true,但实际(Actual)结果为false。

也可以通过命令 gradlew test 来运行所有的测试用例,这种方式可以添加如下配置,输出单元测试过程中各类测试信息:

android {
    ...
    testOptions.unitTests.all {
        testLogging {
            events 'passed', 'skipped', 'failed', 'standardOut', 'standardError'
            outputs.upToDateWhen { false }
            showStandardStreams = true
        }
    }
}

还是验证邮箱地址格式的例子 gradlew test:
在这里插入图片描述

在单元测试中通过System.out或者System.err打印的也会输出。

  • 通过模拟框架模拟依赖,隔离依赖
    前面验证邮件格式的例子,本地JVM虚拟机就能提供足够的运行环境,但如果要测试的单元依赖了Android框架,比如用到了Android中的Context类的一些方法,本地JVM将无法提供这样的环境,这时候模拟框架Mockito就派上用场了。

  • 一个Context#getString(int)的测试用例

import static org.hamcrest.core.Is.is;
import static org.junit.Assert.assertThat;
import static org.mockito.Mockito.when;

@RunWith(MockitoJUnitRunner.class)
public class MockUnitTest {
    private static final String FAKE_STRING = "AndroidUnitTest";

    @Mock
    Context mMockContext;

    @Test
    public void readStringFromContext_LocalizedString() {
        //模拟方法调用的返回值,隔离对Android系统的依赖
        when(mMockContext.getString(R.string.app_name)).thenReturn(FAKE_STRING);
        assertThat(mMockContext.getString(R.string.app_name), is(FAKE_STRING));
        
        when(mMockContext.getPackageName()).thenReturn("com.jdqm.androidunittest");
        System.out.println(mMockContext.getPackageName());
    }
}

在这里插入图片描述
通过模拟框架Mockito,指定调用context.getString(int)方法的返回值,达到了隔离依赖的目的,其中Mockito使用的是cglib动态代理技术。

四、仪器化测试

在某些情况下,虽然可以通过模拟的手段来隔离Android依赖,但代价很大,这种情况下可以考虑仪器化的单元测试,有助于减少编写和维护模拟代码所需的工作量。

仪器化测试是在真机或模拟器上运行的测试,它们可以利用Android framework APIs 和 supporting APIs。如果测试用例需要访问仪器(instrumentation)信息(如应用程序的Context),或者需要Android框架组件的真正实现(如Parcelable或SharedPreferences对象),那么应该创建仪器化单元测试,由于要跑到真机或模拟器上,所以会慢一些。

  • 配置
dependencies {
    androidTestImplementation 'com.android.support:support-annotations:27.1.1'
    androidTestImplementation 'com.android.support.test:runner:1.0.2'
    androidTestImplementation 'com.android.support.test:rules:1.0.2'
}

------------------------------分割线-----------------------------------
android {
    ...
    defaultConfig {
        ...
        testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
    }
}
  • Example
    这里举一个操作SharedPreference的例子,这个例子需要访问Context类以及SharedPreference的具体实现,采用模拟隔离依赖的话代价会比较大,所以采用仪器化测试比较合适。

这是业务代码中操作SharedPreference的实现

public class SharedPreferenceDao {
    private SharedPreferences sp;
    
    public SharedPreferenceDao(SharedPreferences sp) {
        this.sp = sp;
    }

    public SharedPreferenceDao(Context context) {
        this(context.getSharedPreferences("config", Context.MODE_PRIVATE));
    }

    public void put(String key, String value) {
        SharedPreferences.Editor editor = sp.edit();
        editor.putString(key, value);
        editor.apply();
    }

    public String get(String key) {
        return sp.getString(key, null);
    }
}

创建仪器化测试类(app/src/androidTest/java

// @RunWith 只在混合使用 JUnit3 和 JUnit4 需要,若只使用JUnit4,可省略
@RunWith(AndroidJUnit4.class)
public class SharedPreferenceDaoTest {

    public static final String TEST_KEY = "instrumentedTest";
    public static final String TEST_STRING = "玉刚说";

    SharedPreferenceDao spDao;

    @Before
    public void setUp() {
        spDao = new SharedPreferenceDao(App.getContext());
    }

    @Test
    public void sharedPreferenceDaoWriteRead() {
        spDao.put(TEST_KEY, TEST_STRING);
        Assert.assertEquals(TEST_STRING, spDao.get(TEST_KEY));
    }
}

运行方式和本地单元测试一样,这个过程会向连接的设备安装apk,测试结果将在Run窗口展示,如下图:
在这里插入图片描述

通过测试结果可以清晰看到状态passed,仔细看打印的log,可以发现,这个过程向模拟器安装了两个apk文件,分别是app-debug.apk和app-debug-androidTest.apk,instrumented测试相关的逻辑在app-debug-androidTest.apk中。简单介绍一下安装apk命令pm install:

// 安装apk
//-t:允许安装测试 APK
//-r:重新安装现有应用,保留其数据,类似于替换安装
//更多请参考 https://developer.android.com/studio/command-line/adb?hl=zh-cn
adb shell pm install -t -r filePath

安装完这两个apk后,通过am instrument命令运行instrumented测试用例,该命令的一般格式:

am instrument [flags] <test_package>/<runner_class>

例如本例子中的实际执行命令:

adb shell am instrument -w -r -e debug false -e class 'com.jdqm.androidunittest.SharedPreferenceDaoTest#sharedPreferenceDaoWriteRead' com.jdqm.androidunittest.test/android.support.test.runner.AndroidJUnitRunner
-w: 强制 am instrument 命令等待仪器化测试结束才结束自己(wait),保证命令行窗口在测试期间不关闭,
方便查看测试过程的log
-r: 以原始格式输出结果(raw format)
-e: 以键值对的形式提供测试选项,例如 -e debug false
关于这个命令的更多信息请参考
https://developer.android.com/studio/test/command-line?hl=zh-cn

如果你实在没法忍受instrumented test的耗时问题,业界也提供了一个现成的方案Robolectric,下一小节讲开源框库的时候会将这个例子改成本地本地测试。

五、常用单元测试开源库

1. Mocktio

https://github.com/mockito/mockito

Mock 对象,模拟控制其方法返回值,监控其方法的调用等。

  • 添加依赖
testImplementation 'org.mockito:mockito-core:2.19.0'
  • Example
import static org.hamcrest.core.Is.is;
import static org.junit.Assert.*;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.*;
import static org.mockito.internal.verification.VerificationModeFactory.atLeast;

@RunWith(MockitoJUnitRunner.class)
public class MyClassTest {

    @Mock
    MyClass test;

    @Test
    public void mockitoTestExample() throws Exception {

        //可是使用注解@Mock替代
        //MyClass test = mock(MyClass.class);

        // 当调用test.getUniqueId()的时候返回43
        when(test.getUniqueId()).thenReturn(18);
        // 当调用test.compareTo()传入任意的Int值都返回43
        when(test.compareTo(anyInt())).thenReturn(18);

        // 当调用test.close()的时候,抛NullPointerException异常
        doThrow(new NullPointerException()).when(test).close();
        // 当调用test.execute()的时候,什么都不做
        doNothing().when(test).execute();

        assertThat(test.getUniqueId(), is(18));
        // 验证是否调用了1次test.getUniqueId()
        verify(test, times(1)).getUniqueId();
        // 验证是否没有调用过test.getUniqueId()
        verify(test, never()).getUniqueId();
        // 验证是否至少调用过2次test.getUniqueId()
        verify(test, atLeast(2)).getUniqueId();
        // 验证是否最多调用过3次test.getUniqueId()
        verify(test, atMost(3)).getUniqueId();
        // 验证是否这样调用过:test.query("test string")
        verify(test).query("test string");
        // 通过Mockito.spy() 封装List对象并返回将其mock的spy对象
        List list = new LinkedList();
        List spy = spy(list);
        //指定spy.get(0)返回"Jdqm"
        doReturn("Jdqm").when(spy).get(0);
        assertEquals("Jdqm", spy.get(0));
    }
}

2. powermock

https://github.com/powermock/powermock

对于静态方法的 mock

  • 添加依赖
    testImplementation 'org.powermock:powermock-api-mockito2:1.7.4'
    testImplementation 'org.powermock:powermock-module-junit4:1.7.4'

Note: 如果使用了Mockito,需要这两者使用兼容的版本,具体参考
https://github.com/powermock/powermock/wiki/Mockito#supported-versions

  • Example
@RunWith(PowerMockRunner.class)
@PrepareForTest({StaticClass1.class, StaticClass2.class})
public class StaticMockTest {

    @Test
    public void testSomething() throws Exception{
        // mock完静态类以后,默认所有的方法都不做任何事情
        mockStatic(StaticClass1.class);
        when(StaticClass1.getStaticMethod()).thenReturn("Jdqm");
         StaticClass1.getStaticMethod();
        //验证是否StaticClass1.getStaticMethod()这个方法被调用了一次
        verifyStatic(StaticClass1.class, times(1));
    }
}

或者是封装为非静态,然后用 Mockito :

class StaticClass1Wraper{
  void someMethod() {
    StaticClass1.someStaticMethod();
  }

3. Robolectric

http://robolectric.org

主要是解决仪器化测试中耗时的缺陷,仪器化测试需要安装以及跑在Android系统上,也就是需要在Android虚拟机或真机上面,所以十分的耗时,基本上每次来来回回都需要几分钟时间。针对这类问题,业界其实已经有了一个现成的解决方案: Pivotal实验室推出的Robolectric,通过使用Robolectrict模拟Android系统核心库的Shadow Classes的方式,我们可以像写本地测试一样写这类测试,并且直接运行在工作环境的JVM上,十分方便。

  • 添加配置
testImplementation "org.robolectric:robolectric:3.8"

android {
  ...
  testOptions {
    unitTests {
      includeAndroidResources = true
    }
  }
}
  • Example
    模拟打开MainActivity,点击界面上面的Button,读取TextView的文本信息。
public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        final TextView tvResult = findViewById(R.id.tvResult);
        Button button = findViewById(R.id.button);
        button.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                tvResult.setText("Robolectric Rocks!");
            }
        });
    }
}
  • 测试类(app/src/test/java/)
@RunWith(RobolectricTestRunner.class)
public class MyActivityTest {
    
    @Test
    public void clickingButton_shouldChangeResultsViewText() throws Exception {
        MainActivity activity = Robolectric.setupActivity(MainActivity.class);
        Button button =  activity.findViewById(R.id.button);
        TextView results = activity.findViewById(R.id.tvResult);
        //模拟点击按钮,调用OnClickListener#onClick
        button.performClick();
        Assert.assertEquals("Robolectric Rocks!", results.getText().toString());
    }
}
  • 测试结果
    在这里插入图片描述
    耗时917毫秒,是要比单纯的本地测试慢一些。这个例子非常类似于直接跑到真机或模拟器上,然而它只需要跑在本地 JVM 即可,这都是得益于 Robolectric 的 Shadow 。

六、实践

1. 代码中用到了 TextUtil.isEmpty() 的如何测试

public static boolean isValidEmail(CharSequence email) {
    if (TextUtils.isEmpty(email)) {
        return false;
    }
    return EMAIL_PATTERN.matcher(email).matches();
}

当你尝试本地测试这样的代码,就会收到一下的异常:
java.lang.RuntimeException: Method isEmpty in android.text.TextUtils not mocked.

这种情况,直接在本地测试目录(app/src/test/java)下添加TextUtils类的实现,但必须保证包名相同。

package android.text;

public class TextUtils {
    public static boolean isEmpty(CharSequence str) {
        return str == null || str.length() == 0;
    }
}

2. 隔离 native 方法

public class Model {
    public native boolean nativeMethod();
}
public class ModelTest {
    Model model;

    @Before
    public void setUp() throws Exception {
        model = mock(Model.class);
    }

    @Test
    public void testNativeMethod() throws Exception {
        when(model.nativeMethod()).thenReturn(true);
        Assert.assertTrue(model.nativeMethod());
    }
}

3. 在内部 new,不方便 Mock

public class Presenter {

    Model model;
    public Presenter() {
        model = new Model();
    }
    public boolean getBoolean() {
        return model.getBoolean());
    }
}

这种情况,需要改进一下代码的写法,不在内部 new,而是通过参数传递。

public class Presenter {
    Model model;
    public Presenter(Model model) {
        this.model = model;
    }
    public boolean getBoolean() {
        return model.getBoolean();
    }
}

这样做方便Mock Model对象。

public class PresenterTest {
    Model     model;
    Presenter presenter;
    
    @Before
    public void setUp() throws Exception {
        // mock Model对象
        model = mock(Model.class);
        presenter = new Presenter(model);
    }

    @Test
    public void testGetBoolean() throws Exception {
        when(model.getBoolean()).thenReturn(true);

        Assert.assertTrue(presenter.getBoolean());
    }
}

4. 本地单元测试-文件操作
在一些涉及到文件读写的App,通常都会在运行时调用Environment.getExternalStorageDirectory()得到机器的外存路径,通常的做法是跑到真机或者模拟器上进行调试,耗时比较长,可以通过模拟的方式,在本地JVM完成文件操作。

//注意包名保持一致
package android.os;
public class Environment {
    public static File getExternalStorageDirectory() {
        return new File("本地文件系统目录");
    }
}

直接在本地单元测试进行调试,不再需要跑到真机,再把文件pull出来查看。

public class FileDaoTest {

    public static final String TEST_STRING = "Hello Android Unit Test.";
    
    FileDao fileDao;

    @Before
    public void setUp() throws Exception {
        fileDao = new FileDao();
    }

    @Test
    public void testWrite() throws Exception {
        String name = "readme.md";
        fileDao.write(name, TEST_STRING);
        String content = fileDao.read(name);
        Assert.assertEquals(TEST_STRING, content);
    }
}
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Kevin-Dev

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值