Android 单元测试项目集成(Junit + Mockito + Robolectric + JaCoCo)

一.JUnit

Java自带的单元测试工具,用于m跟p层的单元测试,需要了解一些注解@Before @After @Test

集成方式
testImplementation 'junit:junit:4.12'
  • 关于JUnit的断言。
assertTrue 判断是否为true。
assertFalse 判断是否为false。
assertSame 判断引用地址是否相等。
assertNotSame 判断引用地址是否不相等。
assertNull 判断是否为null
assertNotNull 判断是否不为null
assertEquals 判断是否相等
assertNotEquals 判断是否不相等
assertThat 条件判断断言
上边说的assertThat,下边详细介绍下
/**数值匹配**/
 //测试变量是否大于指定值
assertThat(test1.getShares(), greaterThan(50));
//测试变量是否小于指定值
assertThat(test1.getShares(), lessThan(100));
//测试变量是否大于等于指定值
assertThat(test1.getShares(), greaterThanOrEqualTo(50));
//测试变量是否小于等于指定值
assertThat(test1.getShares(), lessThanOrEqualTo(100));
                  
//测试所有条件必须成立
assertThat(test1.getShares(), allOf(greaterThan(50),lessThan(100)));
//测试只要有一个条件成立
assertThat(test1.getShares(), anyOf(greaterThanOrEqualTo(50), lessThanOrEqualTo(100)));
//测试无论什么条件成立(还没明白这个到底是什么意思)
assertThat(test1.getShares(), anything());
//测试变量值等于指定值
assertThat(test1.getShares(), is(100));
//测试变量不等于指定值
assertThat(test1.getShares(), not(50));
                  
/**字符串匹配**/
String url = "http://www.taobao.com";
//测试变量是否包含指定字符
assertThat(url, containsString("taobao"));
//测试变量是否已指定字符串开头
assertThat(url, startsWith("http://"));
//测试变量是否以指定字符串结尾
assertThat(url, endsWith(".com"));
//测试变量是否等于指定字符串
assertThat(url, equalTo("http://www.taobao.com"));
//测试变量再忽略大小写的情况下是否等于指定字符串
assertThat(url, equalToIgnoringCase("http://www.taobao.com"));
//测试变量再忽略头尾任意空格的情况下是否等于指定字符串
assertThat(url, equalToIgnoringWhiteSpace("http://www.taobao.com"));
                  
/**集合匹配**/
List<User> user = new ArrayList<User>();
user.add(test1);
user.add(test2);
                  
//测试集合中是否还有指定元素
assertThat(user, hasItem(test1));
assertThat(user, hasItem(test2));
  
/**Map匹配**/
Map<String,User> userMap = new HashMap<String,User>();
userMap.put(test1.getUsername(), test1);
userMap.put(test2.getUsername(), test2);
                  
//测试map中是否还有指定键值对
assertThat(userMap, hasEntry(test1.getUsername(), test1));
//测试map中是否还有指定键
assertThat(userMap, hasKey(test2.getUsername()));
//测试map中是否还有指定值
assertThat(userMap, hasValue(test2));

关于匹配的字符串详情点击

二.Mockito

所谓的mock就是创建一个类的虚假的对象,在测试环境中,用来替换掉真实的对象,以达到两大目的:
1.验证这个对象的某些方法的调用情况,调用了多少次,参数是什么等等
2.指定这个对象的某些方法的行为,返回特定的值,或者是执行特定的动作

集成方式
testImplementation 'org.mockito:mockito-core:2.23.0'
使用
  • 验证方法调用次数
User user = Mockito.mock(User.class);
UserManager manager = new UserManager(user);
manager.login("xmq","123456");
Mockito.verify(user,Mockito.times(1)).login(Mockito.anyString(),Mockito.anyString()); //验证User中的login调用了多少次
private class User {
    public void login(String user, String pass) {
        System.out.print(user+pass);
    }
}

private class UserManager {
    private User mUser;
    public UserManager(User user) {//这里注意下,对象是以一种注入的方式
        mUser = user;
    }

    public void login(String user, String pass) {
        mUser.login(user,pass);
    }
}
  • 指定mock对象的某些方法的行,或者是执行特定的动作
User user = Mockito.mock(User.class);
Mockito.when(user.isMaster("xmq")).thenReturn(true);
Assert.assertTrue(user.isMaster("xmq"));

class User {

    public void login(String user, String pass) {
        System.out.print(user+pass);
    }
    public boolean isMaster(String user) {
        return "xmq".equals(user);
    }
}

注意:这里有个问题,若删除Mockito.when(user.isMaster("xmq")).thenReturn(true);这一行的话isMaster方法本身传入参数为xmq时正常逻辑返回true,可是实际上是false。这是因为mock如果不指定返回值的话,一个mock对象的所有非void方法都将返回默认值:int、long类型方法将返回0,boolean方法将返回false,对象方法将返回null等等;而void方法将什么都不做。

替代方案,使用Spy,spy对象的方法默认调用真实的逻辑,mock对象的方法默认什么都不做,或直接返回默认值.

User user = Mockito.spy(User.class);
User user = Mockito.spy(new User());


List list = new LinkedList();
List spy = spy(list);
//下边两种处理是不一样的
doReturn("foo").when(spy).get(0);  //返回的是 foo

when(spy.get(0)).thenReturn("foo"); //将会抛出 IndexOutOfBoundsException 的异常,因为 List 为空

三.Robolectric

用于View层的单元测试,可直接运行于JVM上,其实内部是使用了一个android.jar包,具体原理有时间再理

  • 集成方式
build.gradle 中
android{ 
    testOptions {
        unitTests {
            includeAndroidResources = true
        }
    }
}
testImplementation "org.robolectric:robolectric:3.8" //这里4.0以上的需要AndroidStudio 3.2以上才可以
testImplementation "org.robolectric:robolectric-annotations:3.4-rc2"

注意:若出现AndroidManifest.xml找不到的时候在Edit configurations 中配置Working directory 配置为$MODULE_DIR$

edit configurations -> defaults -> android junit -> working directory选择$MODULE_DIRS
  • 使用方式:这里就不去详细介绍每一个控件的测试方法,就以一个实际例子介绍下
public class LoginActivity extends BaseActivity<LoginContract.LoginPresenter> implements LoginContract.LoginView {

    @BindView(R.id.tv_login_user_id)
    EditText etUserId;
    @BindView(R.id.tv_login_user_pass)
    EditText etUserPass;
    @BindView(R.id.tv_user_name)
    TextView tvUserName;

    @Override
    protected int getLayoutId() {
        return R.layout.login_activity;
    }

    @Override
    protected void init() {
    }

    @OnClick(R.id.btn_login)
    public void login() {
        //view 可以进行一些简单的逻辑处理,比如盼空校验等,就没必要交给presenter了
        if (TextUtils.isEmpty(etUserId.getText())) {
            showToast(getString(R.string.login_user_empt));
            return;
        }
        if (TextUtils.isEmpty(etUserPass.getText())) {
            showToast(getString(R.string.login_pass_empy));
            return;
        }
        presenter.login(etUserId.getText().toString(), etUserPass.getText().toString());
    }

    @Override
    protected LoginContract.LoginPresenter createPresenter() {
        return new LoginPresenter(new LoginSource());
    }

    @Override
    public void loginSuccess() {
        Intent intent = new Intent(this, MainActivity.class);
        startActivity(intent);
        finish();
    }

    @Override
    public void loginFail() {
        //登录失败后,可以清空账号 密码 之类的UI操作
        etUserPass.setText("登录失败!");
    }
}

@RunWith(RobolectricTestRunner.class)
@Config(constants = BuildConfig.class, sdk = 26)
public class LoginActivityTest {
    private EditText etUserId;
    private EditText etUserPass;
    private Button btnLogin;

    private LoginActivity mLoginActivity;
    @Rule
    public RxJavaTestSchedulerRule mRxJavaTestSchedulerRule = new RxJavaTestSchedulerRule(); //增加Rxjava规则
    @Before
    public void setUp() throws Exception {
       mLoginActivity =  Robolectric.buildActivity(LoginActivity.class).setup().get(); //创建Activity
       etUserId = mLoginActivity.findViewById(R.id.tv_login_user_id);   //获取其中的控件
       etUserPass = mLoginActivity.findViewById(R.id.tv_login_user_pass);
       btnLogin = mLoginActivity.findViewById(R.id.btn_login);
    }

    @After
    public void tearDown() throws Exception {

    }

    @Test
    public void login() {
        etUserId.setText("xmq");
        etUserPass.setText("123456");
        btnLogin.performClick(); //Button的点击事件
        assertEquals("登录失败!", ShadowToast.getTextOfLatestToast()); //断言是否弹出“ 登录失败!”toast

        etUserId.setText("xuser");
        etUserPass.setText("Zc123456");
        btnLogin.performClick();
        assertEquals("登录成功!", ShadowToast.getTextOfLatestToast());
    }

    @Test
    public void loginSuccess() {
        mLoginActivity.loginSuccess();
        Intent expectedIntent = new Intent(mLoginActivity, MainActivity.class);
        Intent actualIntent = ShadowApplication.getInstance().getNextStartedActivity();
        assertEquals(expectedIntent.getComponent(), actualIntent.getComponent()); //断言Activity跳转是否正确
    }

    @Test
    public void loginFail() {
        mLoginActivity.loginFail();
        assertEquals("登录失败!",etUserPass.getText().toString());
    }
}

关于RxJavaTestSchedulerRule 规则是将Rxjava异步转为同步

public class RxJavaTestSchedulerRule implements TestRule {

    @Override
    public Statement apply(final Statement base, Description description) {
        return new Statement() {
            @Override
            public void evaluate() throws Throwable {
//                ShadowLog.stream = System.out;
                LcHttpClientWrapper.getInstance().sync(true);
                RxJavaPlugins.reset();
                final Scheduler immediate = new Scheduler() {
                    @Override
                    public Disposable scheduleDirect(@NonNull Runnable run, long delay, @NonNull TimeUnit unit) {
                        return super.scheduleDirect(run, 0, unit);
                    }

                    @Override
                    public Worker createWorker() {
                        return new ExecutorScheduler.ExecutorWorker(new Executor() {
                            @Override
                            public void execute(@android.support.annotation.NonNull Runnable runnable) {
                                runnable.run();
                            }
                        });
                    }
                };

                RxJavaPlugins.setInitIoSchedulerHandler(new Function<Callable<Scheduler>, Scheduler>() {
                    @Override
                    public Scheduler apply(Callable<Scheduler> scheduler) throws Exception {
                        return immediate;
                    }
                });
                RxJavaPlugins.setInitComputationSchedulerHandler(new Function<Callable<Scheduler>, Scheduler>() {
                    @Override
                    public Scheduler apply(Callable<Scheduler> scheduler) throws Exception {
                        return immediate;
                    }
                });
                RxJavaPlugins.setInitNewThreadSchedulerHandler(new Function<Callable<Scheduler>, Scheduler>() {
                    @Override
                    public Scheduler apply(Callable<Scheduler> scheduler) throws Exception {
                        return immediate;
                    }
                });
                RxJavaPlugins.setInitSingleSchedulerHandler(new Function<Callable<Scheduler>, Scheduler>() {
                    @Override
                    public Scheduler apply(Callable<Scheduler> scheduler) throws Exception {
                        return immediate;
                    }
                });
                RxAndroidPlugins.reset();
                RxAndroidPlugins.setMainThreadSchedulerHandler(new Function<Scheduler, Scheduler>() {
                    @Override
                    public Scheduler apply(Scheduler scheduler) throws Exception {
                        return immediate;
                    }
                });
                RxAndroidPlugins.setInitMainThreadSchedulerHandler(new Function<Callable<Scheduler>, Scheduler>() {
                    @Override
                    public Scheduler apply(Callable<Scheduler> scheduler) throws Exception {
                        return immediate;
                    }
                });

                base.evaluate();
            }
        };
    }
}

自定义shadow:

public class User {
    private String name;
    public User(String name) {
        this.name = name;
    }
    public String getName() {
        return name;
    }
 @Override
    public String toString() {
        return name;
    }
}

@Implements(User.class) //增加关联注解
public class ShadowUser {

    @Implementation //重写的方法
    public String getName() {
        return "shadowXmq";
    }
}
@RunWith(RobolectricTestRunner.class) 
@Config(constants = BuildConfig.class , shadows = ShadowUser.class,sdk= 26) //这里需要使用shadow关联shadow对象
public class ShadowTest {
    @Test
    public void name() {
        User user  =new User("xmq");
        assertEquals("xmq",user.toString());
        assertNotEquals("xmq",user.getName());
    }
}

Roboletric详情点击

  • 生成报告
./gradlew clean testDebugUnitTest
测试报告

四.JaCoCo

使用JaCoCo生成测试报告,Android Instrument Test 中默认已经集成,但是在Android Unit Test并没有集成,需要我们手动配置gradle

  • 使用方式
apply plugin: 'jacoco'

jacoco {
    toolVersion = "0.8.0" //指定jacoco的版本
    reportsDir = file("$buildDir/JacocoReport") //指定jacoco生成报告的文件夹
}

android {
    buildTypes {
        debug {
            //打开覆盖率统计开关
            testCoverageEnabled = true
        }
    }
}

//依赖于testDebugUnitTest任务
task jacocoTestReport(type: JacocoReport, dependsOn: 'testDebugUnitTest') {
    group = "reporting" //指定task的分组
    reports {
        xml.enabled = true //开启xml报告
        html.enabled = true //开启html报告
    }

    def debugTree = fileTree(dir: "${buildDir}/intermediates/classes/debug/com/*/*/*", //指定类文件夹, 这里的路径需要指定你的包名
            includes: ["**/*.*"], //包含类的规则,这里我们生成所有Presenter类的测试报告
            excludes: ['**/R.class',
                       '**/R$*.class',
                       '**/BuildConfig.*',
                       '**/Manifest*.*']) //排除类的规则

    def mainSrc = "${project.projectDir}/src/main/java" //指定源码目录

    sourceDirectories = files([mainSrc])
    classDirectories = files([debugTree])
    executionData = files("${buildDir}/jacoco/testDebugUnitTest.exec") //指定报告数据的路径
}

执行代码生成报告:

./gradlew clean jacocoTestReport

其他:

  • 1.配置日志输出
 unitTests.all{
            testLogging {
                events 'passed', 'skipped', 'failed', 'standardOut', 'standardError'
                outputs.upToDateWhen { false }
                showStandardStreams = true
            }
        }

总结:

单元测试本身是对代码质量的一种把控,当我们case越多,覆盖的代码率越高,出现异常的情况就会越少。以上中P层的代码更加注重代码的逻辑,所以验证时以View层是否被调用为准;View层以View的变化为准,比如是否弹出正确toast、某一个控件的String是否发生变化、Activity是否跳转等等

参考连接:
Android单元测试研究与实践
Robolectric使用教程

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

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,520评论 25 707
  • 一.基本介绍 背景: 目前处于高速迭代开发中的Android项目往往需要除黑盒测试外更加可靠的质量保障,这正是单元...
    anmi7阅读 2,016评论 0 6
  • 用两张图告诉你,为什么你的 App 会卡顿? - Android - 掘金 Cover 有什么料? 从这篇文章中你...
    hw1212阅读 12,693评论 2 59
  • Android单元测试介绍 处于高速迭代开发中的Android项目往往需要除黑盒测试外更加可靠的质量保障,这正是单...
    东经315度阅读 3,093评论 6 37
  • 问:学校允许自己提前用知网查重吗? 答:是否允许知网论文查重学校会做特别通知强调的,如果没有收到相关信息,那就说明...
    霓裳仙子阅读 971评论 0 0