通用技术 使用 Kotlin 进行 Android 测试

恒温 · 2017年12月20日 · 最后由 剪烛 回复于 2018年01月01日 · 2933 次阅读
本帖已被设为精华帖!

原文来自:https://fernandocejas.com/2017/02/03/android-testing-with-kotlin/

使用 Kotlin 进行 Android 测试

事实上,这也不是一个新话题,因为 Kotlin 在编程语言世界里攻城略地,尤其在 Android 中。我不会深入去讲 Kotlin 能给给我们什么,因为有很多人已经做的很出色(特别是我的朋友 Antonio Leiva)。

Kotlin 编程语言

开始之前,我会给这个后起之秀的主要好处来个快速总结:

  • Kotlin 很简洁。代码写得少,错误犯的少。
  • Kotlin 非常具有表现力。你想用简短的方式表达任何东西。
  • Kotlin 非常实用。不需要绕来绕去直击心底。
  • Kotlin 对 Android 非常友好。一会儿你就能看到了。
  • Kotlin 是类型安全的。还记得亿元故障么?
  • Kotlin 是函数式。 Functions and properties 是第一公民。
  • Kotlin 是友好的。Kotlin 和 Java 几乎可以完美共存。

为什么要在测试中使用 Kotlin?

我们有一个使用 Java 写的 Android 代码库,我们想逐步引入这门帅气的语言,所以为啥不从测试开始呢?这样子我们就可以在任何环境下都不影响主应用的情况下放心地参试 Kotlin,体验把这门现代接近成熟的语言带来的激情,而且也可以为我们和我们的团队做一些迎接大改变的准备。听上去是不是很牛逼?那么让我们先写些代码...

代码敲起来

大致上,我想展示我们怎么使用 Kotlin 测试 Android 应用的。所以先迈出第一步,准备好我们的环境配置,把 Kotlin 的依赖加入到我们的 build.gradle 文件中去:

buildscript {
  repositories {
    mavenCentral()
    jcenter()
  }
  dependencies {
    classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.0.5-2'
  }
}

apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'

...

dependencies {
  ...
  compile "org.jetbrains.kotlin:kotlin-stdlib:1.0.6"

  ...
  testCompile 'org.jetbrains.kotlin:kotlin-stdlib:1.0.6'
  testCompile 'org.jetbrains.kotlin:kotlin-test-junit:1.0.6'
  testCompile "com.nhaarman:mockito-kotlin:1.1.0"
  testCompile 'org.amshove.kluent:kluent:1.14'
}

接着我们需要设置专门放 Kotlin 写的用例的文件夹,通过修改 sourceSets 部分完成:

android {
  ...
  sourceSets {
    test.java.srcDirs += 'src/test/kotlin'
    androidTest.java.srcDirs += 'src/androidTest/kotlin'
  }
  ...
}

第三步,我们想确保没有一行 Kotlin 的代码会跑到生产环境上去,我们加一些东西来防御下:

afterEvaluate {
  android.sourceSets.all { sourceSet ->
    if (!sourceSet.name.startsWith('test') || !sourceSet.name.startsWith('androidTest')) {
      sourceSet.kotlin.setSrcDirs([])
    }
  }
}

完整的文件 可以在 sample project on Github 找到。现在我们可以像用 Java 那样写测试用例啦!

JUnit Tests

这里我只需要 JUnitMockito-kotlinKluent(一个非常 cool 的断言语义库)。让我们看看 GetUserDetails.java 的测试代码,我的应用里的一个用户用例 (from a Clean Architecture approach) 。

class GetUserDetailsTest {

  private val USER_ID = 123

  private lateinit var getUserDetails: GetUserDetails

  private val userRepository: UserRepository = mock()
  private val threadExecutor: ThreadExecutor = mock()
  private val postExecutionThread: PostExecutionThread = mock()

  @Before
  fun setUp() {
    getUserDetails = GetUserDetails(userRepository, threadExecutor, postExecutionThread)
  }

  @Test
  fun shouldGetUserDetails() {
    getUserDetails.buildUseCaseObservable(GetUserDetails.Params.forUser(USER_ID));

    verify(userRepository).user(USER_ID)
    verifyNoMoreInteractions(userRepository)
    verifyZeroInteractions(postExecutionThread)
    verifyZeroInteractions(threadExecutor)
  }
}

Something to pay attention is that when we need to construct our subject under test (in the setup method), 这里需要注意的是当我们需要构建测试对象的时候(在 setup 方法里),我们必须用 “lateinit“来声明,否则编译器会报错,因为属性必须初始化或者抽象。这里是另外一个测试 Serializer.java 类的用例,你可以看到上面说到的断言:

class SerializerTest {

  private val JSON_RESPONSE = "{\n \"id\": 1,\n " +
                              "\"cover_url\": \"http://www.android10.org/myapi/cover_1.jpg\",\n " +
                              "\"full_name\": \"Simon Hill\",\n " +
                              "\"description\": \"Curabitur gravida nisi at nibh. In hac habitasse " +
                              "platea dictumst. Aliquam augue quam, sollicitudin vitae, consectetuer " +
                              "eget, rutrum at, lorem.\\n\\nInteger tincidunt ante vel ipsum. " +
                              "Praesent blandit lacinia erat. Vestibulum sed magna at nunc commodo " +
                              "placerat.\\n\\nPraesent blandit. Nam nulla. Integer pede justo, " +
                              "lacinia eget, tincidunt eget, tempus vel, pede.\",\n " +
                              "\"followers\": 7484,\n " +
                              "\"email\": \"jcooper@babbleset.edu\"\n}"

  private var serializer = Serializer()

  @Test
  fun shouldSerialize() {
    val userEntityOne = serializer.deserialize(JSON_RESPONSE, UserEntity::class.java)
    val jsonString = serializer.serialize(userEntityOne, UserEntity::class.java)
    val userEntityTwo = serializer.deserialize(jsonString, UserEntity::class.java)

    userEntityOne.userId shouldEqual userEntityTwo.userId
    userEntityOne.fullname shouldEqual userEntityTwo.fullname
    userEntityOne.followers shouldEqual userEntityTwo.followers
  }

  @Test
  fun shouldDesearialize() {
    val userEntity = serializer.deserialize(JSON_RESPONSE, UserEntity::class.java)

    userEntity.userId shouldEqual 1
    userEntity.fullname shouldEqual "Simon Hill"
    userEntity.followers shouldEqual 7484
  }
}

Robolectric (集成?) 测试

我创建了一个测试父类(每个测试用例都会用到)为了封装所有 Robolectic 相关的东西。所以,我的测试不需要直接依赖 Robolectric 框架。把任何通用功能或者帮助方法封装在父类里是我学到的一个教训,以前我在每个测试类里都用了 Robolectric 类,当我想迁移到一个不向后兼容的版本的时候就非常痛苦。

/**
 * Base class for Robolectric data layer tests.
 * Inherit from this class to create a test.
 */
@RunWith(RobolectricTestRunner::class)
@Config(constants = BuildConfig::class,
        application = AndroidTest.ApplicationStub::class,
        sdk = intArrayOf(21))
abstract class AndroidTest {

  fun context(): Context {
    return RuntimeEnvironment.application
  }

  fun cacheDir(): File {
    return context().cacheDir
  }

  internal class ApplicationStub : Application()
}

下面是一个集成测试用例,我们通过 AndroidTest.kt 和 Android 框架沟通。

class FileManagerTest : AndroidTest() {

  private var fileManager = FileManager()

  @After
  fun tearDown() {
    fileManager.clearDirectory(cacheDir())
  }

  @Test
  fun shouldWriteToFile() {
    val fileToWrite = createDummyFile()
    val fileContent = "content"

    fileManager.writeToFile(fileToWrite, fileContent)

    fileToWrite.exists() shouldEqualTo true
  }

  @Test
  fun shouldHaveCorrectFileContent() {
    val fileToWrite = createDummyFile()
    val fileContent = "content\n"

    fileManager.writeToFile(fileToWrite, fileContent)
    val expectedContent = fileManager.readFileContent(fileToWrite)

    expectedContent shouldEqualTo fileContent
  }

  private fun createDummyFile(): File {
    val dummyFilePath = cacheDir().path + File.separator + "dummyFile"
    return File(dummyFilePath)
  }
}

Espresso 验收 (UI?) 测试

我认为当下最稳定的集成测试框架非谷歌出品的 Espresso 莫属,所以我选择了它。和前面的 Robolectric 一样,我决定在顶上创建个小框架,让我们看看它是怎么工作的。我所有的测试用例都基于 AcceptanceTest.kt 类:

@LargeTest
@RunWith(AndroidJUnit4::class)
abstract class AcceptanceTest<T : Activity>(clazz: Class<T>) {

  @Rule @JvmField
  val testRule: ActivityTestRule<T> = IntentsTestRule(clazz)

  val checkThat: Matchers = Matchers()
  val events: Events = Events()
}

这里需要关注的点是:

  • 在 Espresso 中一个测试 Rule 是必须的(from the documentation): 这个 Rule 提供了单个 activity 的功能测试。被测 activity 在每个用 @Test 注释的测试和使用@Before注释的 before 方法执行前都被会被加载。每个测试用例或者@After注释的方法运行之后,被测 Activity 都会终止。在测试过程中,你可以直接操控你的被测 Activity。
  • 我们必须对 testRule 字段使用 @JvmField 注释:为了把 Kotlin 的属性转成 JVM 字段,这样 Junit 可以理解。
  • Matchers 类: 封装了 Espresso 的检查方法
  • Events 类: 封装了 Espresso 的事件
class Matchers {
  fun <T : Activity> nextOpenActivityIs(clazz: Class<T>) {
    intended(IntentMatchers.hasComponent(clazz.name))
  }

  fun viewIsVisibleAndContainsText(@StringRes stringResource: Int) {
    onView(withText(stringResource)).check(matches(withEffectiveVisibility(Visibility.VISIBLE)))
  }

  fun viewContainsText(@IdRes viewId: Int, @StringRes stringResource: Int) {
    onView(withId(viewId)).check(matches(withText(stringResource)))
  }
}
class Events {
  fun clickOnView(@IdRes viewId: Int) {
    onView(withId(viewId)).perform(click())
  }
}

最后说下,该项目的主 activity 就展示了一个 view,上面有个按钮,点了按钮就可以打开另外一个 view。代码很简单,如果你想更好的理解的话,可以看这里: you can browse the sample code on Github

class MainActivityTest : AcceptanceTest<MainActivity>(MainActivity::class.java) {

  @Test
  fun shouldOpenHelloWorldScreen() {
    events.clickOnView(R.id.btn_hello_world)
    checkThat.nextOpenActivityIs(HelloWorldActivity::class.java)
  }

  @Test
  fun shouldDisplayAction() {
    events.clickOnView(R.id.fab)
    checkThat.viewIsVisibleAndContainsText(R.string.action)
  }
}

运行我们的测试套件

There are neither problems nor especial configuration to run our tests from 在 Android Studio/Intellij 里运行我们测试没有什么问题,也无需特别的配置。同时,我也在我的根 build.gradle 文件里添加了几个 Gradle 任务:

task runUnitTests(dependsOn: [':app:testDebugUnitTest']) {
  description 'Run all unit tests'
}

task runAcceptanceTests(dependsOn: [':app:connectedAndroidTest']) {
  description 'Run all acceptance tests.'
}

如果要运行的话,只要在终端目录下运行:

./gradlew runUnitTests
./gradlew runAcceptanceTests

结束语

如果某个时刻,你开始考虑 Kotlin,那就不要有任何借口不用到产品中去。测试是一个好的切入点,而且还能尝尝新语言的味道。当然,你还可以额外得到所有本文提到的好处。一个好的培训可以让你和你的团队为这个大改变做好准备。

代码库

参考

如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
共收到 6 条回复 时间 点赞

这语言挺好用,我春节后把 AppCrawler 改成 Kotlin 开发。

陈恒捷 将本帖设为了精华贴 12月21日 08:53

紧跟时代步伐

大工程。。

先顶,正考虑如何开始学白盒,一点思路没有。

这么说,appium 也是能支持 kotlin 编写脚本?

simple 专栏文章:[精华帖] 社区历年精华帖分类归总 中提及了此贴 12月13日 14:44
simple [精彩盘点] TesterHome 社区 2018 年 度精华帖 中提及了此贴 01月07日 12:08
陈子昂 回复

这俩语法其实 95% 都是一样的

需要 登录 后方可回复, 如果你还没有账号请点击这里 注册