使用 Mockk、Kotest 和其他工具对 Android 应用进行单元测试的最佳实践 JUnit TDD 与 BDD Spek Kotest 结论

2025-06-10

使用 Mockk、Kotest 和其他工具对 Android 应用进行单元测试的最佳实践

JUnit

TDD 与 BDD

斯佩克

科特斯

结论

您可能编写过单元测试,也可能不编写。本文并非旨在说服您这样做,而是想展示单元测试领域已经取得了多大的进步,以及与 10 年前 Android 开发人员编写单元测试的方式相比,现在编写单元测试变得多么容易。另请注意,我将讨论由开发人员而不是 QA 工程师进行的测试,因此我们不会讨论像 Appium 这样的高级框架。

从测试金字塔的角度来看,本文我将仅描述其底层,即单元测试。不过,我计划撰写一两篇其他文章,专门讨论使用 Espresso 等框架进行 UI 测试,以及代码覆盖率和测试结果的可视化报告。目前,我不打算讨论 CI/CD 设置,因为它是一个相当重要的话题,而且移动开发者通常不负责(或者并非所有开发者都负责)。

图片描述

因此,单元测试涵盖了应用程序各个部分(例如 ViewModel、Repository、Usecase、DataSource 或其他组件)的逻辑。通常,单元测试不会测试 Activity 和其他 Android 组件,因为在这种情况下,我们必须在 Android 设备或模拟器上运行测试,而 Repository 测试则可以在安装了开发环境的本地计算机上进行。当然,还有Robolectric框架,但它主要负责测试 UI 组件。

十年前,经典的 Android 单元测试都是使用 JUnit 框架编写的,即使到了现在,它仍然是最流行的 Android 单元测试框架。让我们来看看它的一些特性。

JUnit

目前,该框架有两个版本:JUnit 4 和 JUnit 5。名称中的首字母表示该框架最初是为测试 Java 代码而开发的,但它也完美适用于 Kotlin 代码。下面,您可以看到几个使用 JUnit 4 编写的测试示例。

假设我们有一个简单的 ViewModel,其中包括 loadUsers 方法:



class SimpleViewModel : ViewModel() {

    fun loadUsers(): List<User> {
        return listOf(User(id = "1", userName = "jamie123"))
    }
}


Enter fullscreen mode Exit fullscreen mode

这个方法非常愚蠢,所以我们所能做的就是检查它是否返回了我们期望的结果。为此,我们可以编写以下测试。



class SimpleViewModelTest {

    private val viewModel = SimpleViewModel()

    @Test
    fun testReturnUsers() {
        val result = viewModel.loadUsers()
        assertEquals(listOf(User(id = "1", userName = "jamie123")), result)
    }
}


Enter fullscreen mode Exit fullscreen mode

如您所见,我们只是检查方法执行的结果是否等于某个预期值。Assert.asserEquals方法可以帮助我们实现这一点。Assert包含许多其他用于此类检查的实用方法。我建议您仔细研究所有可用的方法。

让我们在 loadUsers 方法中添加一些逻辑。我们添加一个参数,该参数仅返回成年用户(18 岁以上)。



fun loadUsers(onlyAdults: Boolean): List<User> {
    val allUsers = listOf(
        User(id = "1", userName = "jamie123", age = 10),
        User(id = "2", userName = "christy_a1", age = 34)
    )
    return if (onlyAdults) {
        allUsers.filter { it.age >= 18 }
    } else {
        allUsers
    }
}


Enter fullscreen mode Exit fullscreen mode

对于此方法,我们可以编写两个测试:第一个测试将检查 if (onlyAdults) 的上分支,第二个测试将检查下分支。



@Test
fun testReturnOnlyAdultsUsers() {
    val result = viewModel.loadUsers(onlyAdults = true)
    assertEquals(
        listOf(
            User(id = "2", userName = "christy_a1", age = 34)
        ), result
    )
}

@Test
fun testReturnAllUsers() {
    val result = viewModel.loadUsers(onlyAdults = false)
    assertEquals(
        listOf(
            User(id = "1", userName = "jamie123", age = 10),
            User(id = "2", userName = "christy_a1", age = 34)
        ), result
    )
}


Enter fullscreen mode Exit fullscreen mode

所以,一切都很简单。单元测试允许我们测试方法中逻辑的各种场景。我们输入一些参数,然后检查结果是否与预期值一致。

JUnit 4 包含基本的测试功能。使用它编写测试简单又方便。

JUnit 5

JUnit 5新增了许多令人欣喜的功能。此外,关键注解列表也略有变化:例如,@Before变成了@BeforeEach、和@After@AfterEach。此外,包层次结构也彻底改变了。现在所有主要类和注解都可以通过 path 访问org.junit.jupiter.api.*。让我们来了解一下JUnit 5的几个关键新功能。

@DisplayName 注释

此注释使测试的名称更具表现力。



@DisplayName("Test return only adults users")
@Test
fun testReturnOnlyAdultsUsers() {
    val result = viewModel.loadUsers(onlyAdults = true)
    assertEquals(
        listOf(
            User(id = "2", userName = "christy_a1", age = 34)
        ), result
    )
}


Enter fullscreen mode Exit fullscreen mode

尽管 Kotlin 开发人员即使没有特殊注释也可以使用这样的测试命名,使用反引号



@Test
fun `Test return only adults users`() {
        ...
}


Enter fullscreen mode Exit fullscreen mode

@DisplayName的优点是它也可以应用于类名。



@DisplayName("Tests for SimpleViewModel")
class SimpleViewModelTest {


Enter fullscreen mode Exit fullscreen mode

结合这样的测试方法命名,我们可以遵循GivenWhenThen 方法。因此,我们的测试将如下所示:



@DisplayName("WHEN pass onlyAdults = true THEN return expected items")
@Test
fun testReturnOnlyAdultsUsers() {
    val result = viewModel.loadUsers(onlyAdults = true)


Enter fullscreen mode Exit fullscreen mode

或者像这样:



@Test
fun `WHEN pass onlyAdults = true THEN return expected items`() {
        ...
}


Enter fullscreen mode Exit fullscreen mode

必须承认,现在测试名称更清楚地描述了其结构。

@Disabled 注解

此外,如果您遵循 TDD 并编写大量并不总是在运行的测试,JUnit 5 有一个方便的注释@Disabled,允许您关闭由于某种原因不起作用的测试或为尚未添加的代码而编写的测试。



@Disabled("Code not implemented yet")
@Test
fun `WHEN pass onlyAdults = true THEN return expected items`() {


Enter fullscreen mode Exit fullscreen mode

请记住,关闭失败或不稳定的测试是一种不好的做法。您应该立即修复此类测试或导致其失败的代码。

参数化测试@ParameterizedTest

现在让我们考虑一下我们的方法接收枚举作为输入的情况loadUsers



fun loadUsers(filter: FilterType): List<User> {
    val allUsers = listOf(
        User(id = "1", userName = "jamie123", age = 10),
        User(id = "2", userName = "christy_a1", age = 34)
    )
    return when (filter) {
        FilterType.ADULT_USERS -> allUsers.filter { it.age > 18 }
        FilterType.CHILD -> allUsers.filter { it.age < 18 }
        FilterType.ALL_USERS -> allUsers
    }
}


Enter fullscreen mode Exit fullscreen mode

为了检查所有三个条件,我们可以编写三个不同的测试。或者,我们可以编写一个参数化测试,接收参数数组和预期值作为输入,然后比较它们:



@ParameterizedTest
@MethodSource("testArgs")
fun `WHEN pass onlyAdults = true THEN return expected items`(argAndResult: Pair<FilterType, List<User>>) {
    val result = viewModel.loadUsers(argAndResult.first)
    assertEquals(argAndResult.second, result)
}

companion object {
    @JvmStatic
    fun testArgs(): List<Pair<FilterType, List<User>>> = listOf(
        FilterType.CHILD_USERS to listOf(User(id = "1", userName = "jamie123", age = 10)),
        FilterType.ADULT_USERS to listOf(User(id = "2", userName = "christy_a1", age = 34)),
        FilterType.ALL_USERS to listOf(
            User(id = "1", userName = "jamie123", age = 10),
            User(id = "2", userName = "christy_a1", age = 34)
        )
    )
}


Enter fullscreen mode Exit fullscreen mode

在此示例中,我使用了一个附加注解@MethodSource,它将包含提供参数的方法。此方法必须是静态的(位于伴随对象中并带有@JvmStatic注解),并返回参数列表或序列。在测试方法中,我们会接收此列表的一个元素。在本例中,它是Pair<FilterType, List<User>>。这样,测试方法就变得尽可能简单:我们只需使用第一个测试参数值调用被测试方法,然后将结果与第二个参数值进行比较。

除了 MethodSource 之外,还有许多其他用于参数化测试的参数来源。

您可能已经注意到,我们一直在测试简洁的方法,其结果既不依赖于类的条件,也不依赖于其他类。用户列表被硬编码到方法中。在实际生活中,情况总是更加复杂。因此,让我们看一下视图模型从存储库接收用户列表的情况。



class SimpleViewModel(private val usersRepository: UsersRepository) : ViewModel() {

    fun loadUsers(filter: FilterType): List<User> {

                val allUsers = usersRepository.getUsers()
                return when (filter) {
            FilterType.ADULT_USERS -> allUsers.filter { it.age > 18 }
            FilterType.CHILD_USERS -> allUsers.filter { it.age < 18 }
            FilterType.ALL_USERS -> allUsers
        }
    }
}


Enter fullscreen mode Exit fullscreen mode

目的是一样的:我们想测试 SimpleViewModel 方法的逻辑。但我们不知道(也不想知道)UsersRepository 返回了什么。为此,我们可以使用模拟框架,例如Mockito

Mockito

Mockito 和类似的框架允许我们更改那些我们不会直接测试但却是代码一部分的类和方法的逻辑。在我们的示例中,我们可以usersRepository.getUsers()在返回预期用户列表时模拟方法的执行,以检查loadUsers方法的执行情况。我不会详细介绍Mockito 的语法,因为它有很多特性和细微差别。但是,有两个关键特性:它可以替换方法的返回值;它还可以检查被替换的方法是否已使用预期参数调用。在本例中,我们的测试将如下所示:



private val mockUsersRepository: UsersRepository = mock()
private val viewModel = SimpleViewModel(mockUsersRepository)

@BeforeEach
fun setup() {

    mockUsersRepository.stub {
        on { getUsers() } doReturn listOf(
            User(id = "1", userName = "jamie123", age = 10),
            User(id = "2", userName = "christy_a1", age = 34)
        )
    }
}

@ParameterizedTest
@MethodSource("testArgs")
fun `WHEN pass onlyAdults = true THEN return expected items`(argAndResult: Pair<FilterType, List<User>>) {
    val result = viewModel.loadUsers(argAndResult.first)
    assertEquals(argAndResult.second, result)
    verify(mockUsersRepository).getUsers()
}


Enter fullscreen mode Exit fullscreen mode

如您所见,我mockUsersRepository使用mock(函数创建了对象。然后,在setup()方法中,我以某种方式设置了这个存储库,以便它返回一个硬编码值列表。此后,最重要的是,我检查在测试方法中是否使用函数调用了mockUsersRepository对象的方法。值得注意的是,Mockito是一个非常古老的框架,最初是为 Java 编写的。当 Kotlin 出现时,事实证明这个框架开箱即用,但存在一些限制,这就是为什么发布了扩展mockito -kotlin 的原因。上面的例子就是用它编写的。Mockito 的另一个限制是无法模拟 final 类和静态方法,而在 Kotlin 中所有类默认都是 final 的。目前,这个问题已经通过解决,但以前,这是一个严重的限制。就 Java 开发而言,Android 开发人员过去和现在都有一个替代方案,那就是PowerMock。getUsersverifymockito-inline

PowerMock

该框架旨在消除Mockito在模拟 final、static 和 private 方法方面的局限性。它成功地解决了这个问题,但同时也增加了一些使用难度。例如,我们可以如下模拟静态方法:



mockStatic(MyClass::class.java)
when(MyClass.firstMethod(anyString())).thenReturn("Some string");


Enter fullscreen mode Exit fullscreen mode

现在, Mockito中已经提供了其中的大部分功能。更重要的是,Kotlin 自然没有静态方法,最好不要创建它们。理想的代码是遵循 SOLID 和 Clean Architecture 原则的代码,这意味着它可以轻松地进行模拟和测试。因此,我们 Android 开发者不需要PowerMock的特殊功能,但重要的是要记住这样的框架是存在的。Mockito的另一个限制与协程和流程的使用有关。在这种情况下, mockito-kotlin以及turbine等第三方库将再次为您提供帮助。话虽如此,我想向您介绍 Mockito 的另一种替代方案,即Mockk

莫克

Mockk是一个相当新的框架;它最初是为 Kotlin 设计的,尽管它也支持 Java。在mockito-kotlin发布之前,使用Mockk对 Kotlin 开发者来说要容易得多,也更有益,但现在这两个框架的功能和语法几乎相同。例如,假设我们的函数loadUsers不再是同步的,而是暂停的:



interface UsersRepository {
    suspend fun getUsers(): List<User>


Enter fullscreen mode Exit fullscreen mode

常规的 Mockito 无法模拟它。这就是为什么需要mockito-kotlin扩展。有了它,我们的模拟将如下所示:



@BeforeEach
fun setup() {
    mockUsersRepository.stub { onBlocking { getUsers() } doReturn listOf(User("user_id")) }
}


Enter fullscreen mode Exit fullscreen mode

Mockk中,相同函数的模拟将是这样的:



@BeforeEach
fun setup() {
    coEvery { getUsers() } returns listOf(User("user_id"))
}


Enter fullscreen mode Exit fullscreen mode

正如您所见,这是一个品味问题。

然而, MockkMockito之间的一个重要区别是,所有 Mockk 的模拟都没有默认值。在 Mockito 中,返回 Unit 的方法将被预期调用,而返回引用类型的方法将返回null。这可能会导致不良后果:如果我们在生产代码中能够处理这个 null 值,那么测试显然会错误地运行,从而隐藏 NPE。在Mockk中,模拟类的任何方法默认都会抛出异常,尽管可以创建宽松的模拟来实现与 Mockito 类似的行为。



private val mockUsersRepository = mockk<UsersRepository>(relaxed = true)


Enter fullscreen mode Exit fullscreen mode

事实上,这是一种反模式,因为它可能导致开发人员错过宽松模拟所隐藏的错误。然而,有时不为返回 Unit 的函数创建模拟可能会有所帮助。为此,Mockk有一个特殊的参数:



private val mockUsersRepository = mockk<UsersRepository>(relaxUnitFun = true)


Enter fullscreen mode Exit fullscreen mode

这可能是MockkMockito之间的主要区别。但这取决于你判断这是否是一个优势,以及是否应该因此而改变你的框架。

既然我们已经了解了用于创建单元测试模拟的框架,我想让任务更复杂一些。在我们的示例中,SimpleViewModel类只有一个依赖项usersRepository,而这个存储库也只包含一个方法。在实际应用中,被测试的类可能有十几个依赖项,并且这些方法彼此紧密相关。因此,viewModel.loadUsers方法可能会更加复杂。在下面的示例中,我们组合了多个 Observable 来得出最终结果。



fun init() {
        observeUserAvailability()
                .subscribeNext { result ->
                        // check result
                }
}

private fun observeUserAvailability(draftPotUuid: String): Observable<UserAvailability> = Observable.combineLatest(
        usersRepository.observeCurrentUser(),
        profileRepository.observeCurrentProfile(),
        kycRepository.observeKycStatus(),
        addressRepository.loadCurrentUserAddress(),
        countryRepository.observeCountries(),
    )


Enter fullscreen mode Exit fullscreen mode

要测试一组方法场景init(),您需要准备一组包含重复方法的测试,用于模拟observeCurrentUserobserveCurrentProfile等等。我们可以将它们添加到BeforeEach方法中,但需要注意的是,不要忘记在不需要默认模拟的地方更新它们。JUnit 5 的嵌套类可以帮助您解决这个问题。

JUnit 5 中的嵌套类

使用嵌套类(对于 Kotlin 来说,是内部类),我们可以根据一些常见条件对测试进行分组。例如,我们可以根据usersRepository.observeCurrentUser()方法的返回值创建两组测试。在第一种情况下,它将返回正确的用户User("user_id"),而在第二种情况下,它将返回具有错误 ID 的用户User(null)



@DisplayName("Tests for SimpleViewModel")
class SimpleViewModelTest {

    private val mockUsersRepository: UsersRepository = mock()
    private val viewModel = SimpleViewModel(mockUsersRepository)

    @DisplayName("GIVEN userRepository returns correct user")
    @Nested
    inner class MockGroup1 {

        @BeforeEach
        fun setup() {
            mockUsersRepository.stub { on { getCurrentUser() } doReturn User("user_id") }
        }

        @Test
        fun `GIVEN kycRepository returns kycPassed WHEN init viewModel THEN get expected result`() {
            ///
        }
    }

    @DisplayName("GIVEN userRepository returns incorrect user")
    @Nested
    inner class MockGroup2 {

        @BeforeEach
        fun setup() {
            mockUsersRepository.stub { on { getCurrentUser() } doReturn User(null) }
        }

        @Test
        fun `GIVEN kycRepository returns kycPassed WHEN init viewModel THEN get expected result`() {
            ///
        }
    }


Enter fullscreen mode Exit fullscreen mode

按照这个例子,我们可以将这种方法扩展到分组测试,并根据需要创建任意数量的嵌套级别,只要它们对我们和团队的其他成员来说仍然是可读且易于理解的。尽量遵循KISS原则。

JUnit 5和上面列出的框架还有许多其他有趣的功能。我仅描述了我自己使用过并且认为对我们 Android 开发者非常有用的功能。

TDD 与 BDD

事实上,在前面的例子中,我们稍微偏离了 TDD 标准,这意味着我们不仅要测试代码的可操作性,还要检查代码是否按照某些规范(Given/When/Then)运行。这些规范就是我们的测试,而语法糖——使用 DisplayName 为测试赋予清晰的名称,以及通过一组相似的属性对测试进行分组——有助于我们清晰地制定这些规范。不同语言中有很多框架可以帮助我们创建这样的规范:对于 Java 来说,有 Spock;对于 Ruby 来说,有 RSpec;对于 Kotlin 来说,有 SpekKotest框架。下面,我将详细介绍它们。

斯佩克

Spek 是一个 BDD 框架,我们详细描述了测试对象必须工作的条件及其对这些条件的反应。我们默认由多个单元组成测试主体,以区分不同的职责范围(测试内容、测试条件和预期结果)。如果我们回到 SimpleViewModel,测试将如下所示:



class SimpleViewModelSpec : Spek({

    val mockUsersRepository: UsersRepository = mock()
    val viewModel by memoized { SimpleViewModel(mockUsersRepository) }

    describe("loadUsers") {

        beforeEachTest {
            // do mocking
        }

        it("should return only adult users if pass filterType: adult users") {
                val result = viewModel.loadUsers(FilterType.ADULT_USERS)
            assertEquals(listOf(User(id = "2", userName = "christy_a1", age = 34)), result)
            verify(mockUsersRepository).getUsers()
        }
    }
})


Enter fullscreen mode Exit fullscreen mode

如您所见,我们的测试逻辑被写成一个 lambda 表达式,并且每个测试都是来自行、测试名称和运行测试的单元的一种映射。

describe-it 风格主要用于编写Spek测试。describe该结构允许我们创建一组描述特定方法的测试,并在 中it为该方法编写特定的场景。但也可以使用 GivenWhenThen 风格。遗憾的是,该框架不支持嵌入式协程,因此我们必须始终使用runBlockingTest { }挂起函数。Github 上有一个包含此功能请求的工单,但似乎从未完成。

Spek不提供自己的断言、模拟或匹配器,但它允许您与其他库一起使用,例如MockkMockito

科特斯

另一个在语法和行为上与Spek类似的框架。由于其 Kotlin 优先的原则和灵活性,它最近在 Android 开发者中也广受欢迎。或许,灵活性的首要含义在于,你可以自由选择编写测试的风格。目前,你可以从10 种风格中选择。我个人更喜欢FreeSpec 。如果用Kotest编写,上述测试将如下所示



class SimpleViewModelSpec : FreeSpec({

    val mockUsersRepository: UsersRepository = mock()
    val viewModel = SimpleViewModel(mockUsersRepository)

    beforeTest {
        // do mocking
    }

    "WHEN pass filterType: adult users" - {

        val result = viewModel.loadUsers(FilterType.ADULT_USERS)

        "THEN return only adult users" {
            assertEquals(listOf(User(id = "2", userName = "christy_a1", age = 34)), result)
            verify(mockUsersRepository).getUsers()
        }
    }
})


Enter fullscreen mode Exit fullscreen mode

上述测试按照 GivenWhenThen 原则以 FreeSpec 风格运行。

Kotest已嵌入协程支持,这就是为什么与 Spek 不同,您不必使用runBlockingTest { }暂停功能。

测试隔离级别与 JUnit 4/5 不同,在这里非常重要。模拟值和测试条件的值在每次测试后不会被清除。如果您想要类似 JUnit 的行为,请使用isolationMode = IsolationMode.InstancePerLeaf标志。



class SimpleViewModelKoTest : FreeSpec({

        isolationMode = IsolationMode.InstancePerLeaf


Enter fullscreen mode Exit fullscreen mode

此外,Kotest还提供了一组额外的断言和匹配器,它们可能比我们在Mockito中使用的更方便。如果您的项目已经使用了MockitoJUnit ,那没关系,因为用 Kotest 编写的测试可以与JUnit共存

结论

如今,Android 应用的单元测试框架和库种类繁多。我仅介绍了一些最流行的,但实际应用远不止这些。如果您从事企业级 Android 项目,您很可能已经习惯使用本文提到的一些技术来编写单元测试。通常,大型公司会使用久经考验的工具,例如 JUnit 和 Mockito。但是,随着 Kotlin 新功能的不断发布,大量新的测试库和框架也应运而生。如果您之前没有使用过 Mockk 或 Kotest,我强烈推荐您尝试一下。它们可以让您的工作更加轻松,并提高测试质量。

在下一篇文章中,我将向您介绍编写 UI 测试的方法和最佳实践。另外,请分享您在项目中使用单元测试的经验:您使用哪些框架?

鏂囩珷鏉ユ簮锛�https://dev.to/rchugunov/best-practices-for-unit-testing-android-apps-with-mockk-kotest-and-others-35j9
PREV
Android UI 测试最佳实践 Espresso UI Automator Compose UI 测试 WireMock / MockWebServer OkReplay Barista Kaspresso 结论
NEXT
我如何通过懒惰、不耐烦和过度自信来做得更多 懒惰 不耐烦 过度自信