根底概念

按照Google官方主张,Android测验体系应该参照测验金字塔架构(如下图所示),App应该包括三类测验(即小型、中型和大型测验):

Android单元测试实践

  • 小型测验是指单元测验,用于验证运用的行为,一次验证一个类。
  • 中型测验是指集成测验,用于验证模块内仓库等级之间的交互或相关模块间的交互。
  • 大型测验是指端到端测验,用于验证跨越了运用的多个模块的用户UI点击操作流程。

沿着金字塔逐级向上,从小型测验到大型测验,各类测验的保真度(关于用户的实在感受)逐级提高,但保护和调试工作所需的履行时刻和工作量也逐级增加。因而,咱们编写的单元测验应多于集成测验,集成测验应多于端到端测验。尽管各类测验的份额可能会因运用的用例不同而异,但咱们通常主张各类测验所占份额如下:小型测验占 70%,中型测验占 20%,大型测验占 10%。

今日咱们首要评论的是占比70%的小型测验,也叫单元测验。

测验结构介绍

根底结构:JUnit

junit.org/junit4/ JUnit是一个Java语言的单元测验结构。Junit测验是程序员测验,即所谓白盒测验,由于咱们知道被测验的软件怎么(How)完结功用和完结什么样(What)的功用。Junit是一套结构,承继TestCase类,就能够用Junit进行主动测验了。多数Java的开发环境都已经集成了JUnit作为单元测验的东西。

断语结构:Truth

github.com/google/trut… 断语结构首要是为了在单元测验代码中比较测验办法的实践输出与希望输出是否一致。 JUnit结构自身支持简略的断语,如下代码所示,assertEquals办法即为断语办法。

import org.junit.Test;
import static org.junit.Assert.*;
public class ExampleUnitTest {
    @Test
    public void addition_isCorrect() {
        assertEquals(4, 2 + 2);
    }
}

JUnit自身自带的断语办法,过于简略,并且测验语义也不是很丰富,这里引入Google的Truth断语结构,大致运用如下所示

import com.google.common.truth.Truth.assertThat
@Test
public void addition_isCorrect() {
    int result = 2+2;
    assertThat(result).isEqualTo(4);
}

当咱们能够运用内置在测验结构如JUnit中的办法(相似于assertEquals)时,为什么还要依靠新的断语库呢?咱们经过如下断语代码演示原因,下面代码是JUnit自带的assertEquals办法:

assertEquals(
    ImmutableMultiset.of("guava", "dagger", "truth", "auto", "caliper"),
    HashMultiset.create(projectsByTeam().get("corelibs")));

替换成Truth如下代码所示

assertThat(projectsByTeam())
    .valuesForKey("corelibs")
    .containsExactly("guava", "dagger", "truth", "auto", "caliper");

能够看到,运用Truth有如下几个优点:

  • 运用Truth编写代码会更快,由于它是链式调用,IDE能够智能提示和主动补全
  • 运用Truth的代码更简单了解和阅览:
    • 它的样板代码更少。 例如,在上面的示例代码中,projectByTeam() 回来一个 ListMultimap,因而 projectsByTeam().get(…) 将仅等于另一个元素次序相同的 List。 咱们不想在这里测验排序,所以咱们有必要将其转换为 Multiset后再进行测验,否则会直接失利,由于直接拿到的list次序不一定相等。
    • 将最终的成果放在首位会为接下来各种操作提供比较好了解的上下文信息:假如断语开端的部分便是“guava, dagger, …,”时,读者要读到后面才干确认要测验什么。
  • Truth有更加友爱和易于阅览的错误提示信息:
java.lang.AssertionError: expected:<[guava, dagger, truth, auto, caliper]> but was:<[dagger, auto, caliper, guava]>
  at org.junit.Assert.failNotEquals(Assert.java:835) <2 internal calls>
  at com.google.common.truth.example.DemoTest.testBuiltin(DemoTest.java:64) <19 internal calls>

上面的错误音讯关于一个简略的断语很好,可是考虑到如下状况:

  • 假如调集中有很多值,那么找出哪些值缺失(或剩余的值)可能会很困难。
  • 假如测验被参数化除了“corelibs”以外的键,JUnit 音讯将不会显示断语失利的键。
  • 假如成果项目列表是空的,JUnit 音讯将不会显示整个多映射是空的还是仅仅“corelibs”调集为空。 下面的信息是由 Truth 生成的错误音讯:
value of    : projectsByTeam().valuesForKey(corelibs)
missing (1) : truth
───
expected    : [guava, dagger, truth, auto, caliper]
but was     : [guava, auto, dagger, caliper]
multimap was: {corelibs=[guava, auto, dagger, caliper]}
  at com.google.common.truth.example.DemoTest.testTruth(DemoTest.java:71)

mock结构

Mock通常是指,在测验一个目标A时,咱们结构一些假的目标来模仿与A之间的交互,而这些Mock目标的行为是咱们事前设定且契合预期。经过这些Mock目标来测验A在正常逻辑,反常逻辑或压力状况下工作是否正常。

Mockito

site.mockito.org/ Mockito是最盛行的Java mock结构之一。根本运用如下代码所示

public class HelloWorldTest {
    @Test
    public void helloWorldTest() { 
        // mock DemoDao instance 
        DemoDao mockDemoDao = Mockito.mock(DemoDao.class); 
        // 运用 mockito 对 getDemoStatus 办法打桩 
        Mockito.when(mockDemoDao.getDemoStatus()).thenReturn(1); 
        // 调用 mock 目标的 getDemoStatus 办法,成果永远是 1 
        Assert.assertEquals(1, mockDemoDao.getDemoStatus()); 
        // mock DemoService 
        DemoService mockDemoService = new DemoService(mockDemoDao); 
        Assert.assertEquals(1, mockDemoService.getDemoStatus() ); 
    } 
}

mockK

github.com/mockk/mockk 当咱们运用Mockito去mock Java类或许办法是没有问题的,可是去mock kotlin的代码,可能会出现如下问题:

  • Mockito cannot mock/spy because : — final class
  • java.lang.IllegalStateException: anyObject() must not be null
  • when 要加上反引号才干运用,由于when在kotlin中是一个关键字
  • 测验静态办法(Static Method)

基于以上问题,开源社区专门为kotlin设计了一套mock结构:MockK。如下示例代码为mockK运用的样例代码:

class Kid(private val mother: Mother) {
    var money = 0
        private set
    fun wantMoney() {
        money += mother.giveMoney()
    }
}
class Mother {
    fun giveMoney(): Int {
        return 100
    }
}

上面代码首要用来测验Kid类中的wantMonkey办法调用后,kid的money成果是否准确

@Test
fun wantMoney() {
    // Given
    val mother = mockk<Mother>()
    val kid = Kid(mother)
    every { mother.giveMoney() } returns 30 // when().thenReturn() in Mockito
    // When
    kid.wantMoney()
    // Then
    assertEquals(30, kid.money)
}

Robolectric结构

Robolectric经过完成一套JVM能运转的Android代码,然后在Junit test运转的时分去截取android相关的代码调用,然后转到Robolectric内部完成的代码去履行这个调用的进程。不必依靠实在的 Android 环境中运转(模仿器或许真机) Robolectric首要适用于UI的测验,比方Activity,Fragment,一些页面操作的测验场景,选用Shadow的办法对Android中的组件进行模仿测验,从而完成Android单元测验Robolectric正好弥补了Mockito的缺乏,两者结合运用是最完美的,即假如想要测验依靠了Android FrameWork相关的代码,就需求运用Robolectric的才能。

编写测验代码

具体的代码示例可参考AndroidTestingSamples项目

Step1:装备测验环境

依据履行环境安排收拾测验目录,Android Studio 中的典型项目包括两个用于放置测验的目录。请按以下办法安排收拾您的测验:

  • androidTest 目录应包括在实在或虚拟设备上运转的测验。此类测验包括集成测验、端到端测验,以及仅靠 JVM 无法完结运用功用验证的其他测验。
  • test 目录应包括在本地计算机上运转的测验,如单元测验。

考虑在不同类型的设备上运转测验的利弊,在设备上运转测验时,您能够从以下类型中进行选择:

  • 实在设备
  • 虚拟设备(如 Android Studio 中的模仿器)
  • 模仿设备(如 Robolectric)

实在设备可提供最高的保真度,但运转测验所花费的时刻也最多。另一方面,模仿设备可提供较高的测验速度,但价值是保真度较低。不过,渠道在二进制资源和传神的循环程序上的改善使得模仿设备能够发生更传神的成果。 虚拟设备则平衡了保真度和速度。当咱们运用虚拟设备进行测验时,能够运用快照来最大限度地缩短测验之间的设置时刻。

增加依靠

dependencies {
      // Core library
      androidTestImplementation 'androidx.test:core:1.0.0'
      // AndroidJUnitRunner and JUnit Rules
      androidTestImplementation 'androidx.test:runner:1.1.0'
      androidTestImplementation 'androidx.test:rules:1.1.0'
      // Assertions
      androidTestImplementation 'androidx.test.ext:junit:1.0.0'
      androidTestImplementation 'androidx.test.ext:truth:1.0.0'
      androidTestImplementation 'com.google.truth:truth:0.42'
      // Espresso dependencies
      androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.0'
      androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.1.0'
      androidTestImplementation 'androidx.test.espresso:espresso-intents:3.1.0'
      androidTestImplementation 'androidx.test.espresso:espresso-accessibility:3.1.0'
      androidTestImplementation 'androidx.test.espresso:espresso-web:3.1.0'
      androidTestImplementation 'androidx.test.espresso.idling:idling-concurrent:3.1.0'
      // The following Espresso dependency can be either "implementation"
      // or "androidTestImplementation", depending on whether you want the
      // dependency to appear on your APK's compile classpath or the test APK
      // classpath.
      androidTestImplementation 'androidx.test.espresso:espresso-idling-resource:3.1.0'
    }

整合testing模块

为了整合上面一切的测验库依靠,革除每个模块都要独自增加测验的依靠库这类繁琐重复的操作,咱们在项目中能够新建一个testing模块,咱们只需求增加对testing模块的依靠即可

testImplementation project(":testing")

Gradle装备支持模仿AndroidFramework资源

本地测验依靠,需求在模块的build.gradle文件中增加如下装备

    android {
        // ...
        testOptions {
            unitTests.includeAndroidResources = true
        }
    }

Step2:创立测验

如咱们在项目 src/main/java 目录的 me.yamlee.testing.samples 包名下创立了一个 MathUtils 的东西类,那么需求在src/test/java目录的同样包名下创立一个名为 MathUtilsTest 的对应测验类(可经过快捷键ctrl+shfit+t(windows)/cmd+shift+t(mac) 快速创立)。

Android渠道无关测验

Android渠道无关测验即不依靠Android Framework相关Api的功用代码,需求承继testing模块中的BaseUnitTest抽象类,例如下面代码所示

object MathUtils {
    fun add(a: Int, b: Int): Int {
        return a + b
    }
}
//测验代码
class MathUtilsTest : BaseUnitTest() {
    @Test
    fun add() {
        val result = MathUtils.add(1, 1)
        Truth.assertThat(result).isEqualTo(2)
    }
}

Android渠道相关测验

关于依靠Android Framework相关Api的功用代码,假如不能运用Mockito进行手动mock,则能够运用Robolectric来进行模仿,如下代码所示,需求承继testing模块中的AndroidUnitTest抽象类

object StringUtils {
    fun getApplicationName(context: Context): String {
        return context.getString(R.string.app_name)
    }
}
//测验代码
class StringUtilsTest : AndroidUnitTest() {
    @Test
    fun getApplicationName() {
        val result = StringUtils.getApplicationName(applicationContext)
        Truth.assertThat(result).isEqualTo("AndroidTestingSamples")
    }
}

需求注意的是 AndroidUnitTest装备的单元测验runner为 @RunWith(AndroidJUnit4.class) ,表示JUnit的TestRunner运用AndroidJUnit的Runner,经过运用这个TestRunner,在运转测验用例时便会主动运用Android Framework Mock

/**
 * 需依靠Android Framework资源的测验,例如context等一些api,
 * 需求承继此类
 */
@RunWith(AndroidJUnit4::class)
abstract class AndroidUnitTest {
    protected lateinit var applicationContext: Context
    @Before
    open fun setup() {
        applicationContext = ApplicationProvider.getApplicationContext()
    }
}

Step3:运转测验

AndroidStudio运转

如上图所示最左面相似▶按钮点击,即可触发测验办法运转

命令行运转

经过在命令行运转如下所示命令,履行app模块下的一切单元测验用例

./gradlew :app:test

Step4:查看单元测验掩盖率

测验掩盖率计算选用的是Jacoco,Android Gradle Plugin默认支持Jacoco测验掩盖率东西,经过在build.gradle装备文件中设置即可敞开此功用。 项目根目录中有一个 jacoco.gradle 的装备文件,哪个模块需求生成测验掩盖率,能够经过如下代码引入

apply from: '../jacoco.gradle'

按如上代码装备Jacoco东西后即可,经过运转对应的gradle任务,即可生成相应的测验掩盖率陈述

./gradlew clean :app:jacocoTestReport

测验掩盖率生成完结后,能够再项目模块目录下的build目录查找生成的测验掩盖率陈述。如上面代码中咱们生成的是aivse模块的测验掩盖率,咱们能够在 app/build/reports/jacoco/jacocoTestReport/html/ 中经过浏览器打开 index.html 查看掩盖率状况

掩盖率陈述大致样式如下图所示

Android单元测试实践

Sonar测验掩盖率相关

有的公司会经过sonarqube进行代码的静态扫描监控,咱们能够把单元测验掩盖率同步到sonarqube渠道上,经过一下命令能够进行测验掩盖率相关(soanrqube的扫描装备比较复杂,不在本篇展开,后续独自介绍)

# 运转单元测验,并进行各个模块的单元测验掩盖率查看
./gradlew clean jacocoTestReport
# 将各个模块的测验掩盖率汇总
./gradlew allDebugCoverage
#上传到sonarqube上
./gradlew sonarqube

单元测验编写经验总结

TDD(Test Driven Development):测验驱动开发

TDD 是敏捷开发中的一项中心实践和技术,也是一种设计办法论。TDD的原理是在开发功用代码之前,先编写单元测验用例代码,测验代码确认需求编写什么产品代码。TDD 是 XP(Extreme Programming)的中心实践。它的首要推动者是 Kent Beck。

AndroidStudio测验代码分屏

如下图所示,咱们在开发是,事务代码和测验代码能够进行分屏,咱们能够快速的对事务代码和测验代码进行编辑和修正。

Android单元测试实践

多运用依靠注入,削减硬编码目标创立

首要咱们了解下依靠注入是什么,如下代码咱们有一个CoffeMaker的类,用来制造咖啡,它依靠了Heater和Pump类。

class CoffeeMaker {
  private Heater heater;
  private Pump pump;
  public CoffeMaker(){
      this.heater = new Heater();
      this.pump = new Pump();
  }
  public void makeCoffee(){
      heater.heat();
      pump.pump();
  }
}

在上面的代码中咱们new CoffeMaker不需求传任何参数,可是运用依靠注入需求改为如下完成办法

class CoffeeMaker {
  private Heater heater;
  private Pump pump;
  public CoffeMaker(Heater heater,Pump pump){
      this.heater = heater;
      this.pump = pump;
  }
  public void makeCoffee(){
      heater.heat();
      pump.pump();
  }
}

能够看到经过结构函数传递进来目标而不是在CoffeMaker结构函数中直接硬编码的办法便是依靠注入,那经过此种办法有何优点呢?最直接的优点便是便利咱们进行单元测验

@Test
public void testMakeCoffee1(){
    CoffeMaker coffeMaker = new CoffeMaker();
    coffeMaker.makeCoffe();
}
@Test
public void testMakeCoffee2(){
    Heater heater = mock(Heater.class)
    Pump pump = mock(Pump.class)
    CoffeMaker coffeMaker = new CoffeMaker(heater,pump);
    coffeMaker.makeCoffe();;
}

在testMakeCoffe1办法中,咱们是经过硬编码完成的目标创立,关于coffeMaker内部的heater和pump目标,咱们无法干预,单元测验也掩盖不到。而关于testMakecoffee2办法咱们能够mock不同的内部目标传给CoffeMaker这样就能够测验不同内部目标的状态,掩盖到更多场景。

总结

本篇内容整体介绍了当下Android单元测验的首要方案与流程,包括断语结构Truth、mock结构,以及Android编写单元测验代码的具体步骤和一些经验总结。

本文作者: 向青

本文链接: yamlee.me/2023/02/20/…

版权声明: 本博客一切文章除特别声明外,均选用**BY-NC-SA许可协议。转载请注明出处!