使用Mockk,Kotest和其他人进行单位测试的单位测试的最佳实践
#编程 #测试 #android #unittest

也许您编写单元测试,或者也许您没有。有了这篇文章,我不是要说服您这样做,而是想证明单位测试的世界已经发展了多远,现在编写单位测试的比较容易得多,而不是如何进行。 10年前,Android开发人员做到了。另请注意,我将谈论开发人员而不是QA工程师进行的测试,因此我们将不会进入Appium等高级框架。

Test Pyramid的角度来看,在本文中,我将仅描述下层,即单位测试。但是,我计划撰写另外两篇文章,这些文章专用于UI测试,其中包括Espresso等框架,以及代码覆盖范围和视觉报告以进行测试结果。目前,我不打算涉及CI/CD设置的主题,这是一个很大的主题,移动开发人员通常不对它负责(或者所有开发人员)。

Image description

因此,单元测试是涵盖应用程序各个部分逻辑的测试,无论是视图模型,存储库,用户酶,数据源还是其他组件。通常,单元测试不会测试活动和其他Android组件,因为在这种情况下,我们必须在Android设备或模拟器上运行测试,而可以在安装开发环境的本地计算机上进行存储库测试。当然,有 robolectric 框架,但它涉及测试UI组件。

十年前,使用JUNIT框架编写了Android的古典单元测试,即使现在,这是此类测试中最受欢迎的框架。让我们看看它的一些功能。

朱尼特

目前,我们有两个版本的junit 4和junit5。名称中的第一个字母意味着该框架最初是为了测试Java代码而开发的,但对于Kotlin代码也非常有效。下面,您可以看到使用Junit 4编写的几个测试示例。

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

class SimpleViewModel : ViewModel() {

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

此方法非常愚蠢,因此我们所能做的就是检查它是否返回我们希望它返回的内容。为此,我们可以编写以下测试。

class SimpleViewModelTest {

    private val viewModel = SimpleViewModel()

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

您可以看到,我们只需检查方法执行的结果是否等于某个期望值。 Assert.asserEquals方法对我们有所帮助。 Assert类包括许多其他有用方法用于此类检查。我建议通过所有可用的方法。

让我们在加载使用者方法中添加一些逻辑。我们添加了一个只能返回成人用户的参数(超过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
    }
}

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

@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
    )
}

所以,一切都很简单。单元测试使我们能够在方法中测试逻辑的各种方案。我们输入一些参数,并使用预期值检查结果。

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
    )
}

尽管Kotlin开发人员即使没有特殊注释,也可以使用backticks使用此类测试命名。

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

@DisplayName的优势是它也可以应用于班级名称。

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

与测试方法的这种命名一起,我们可以遵循GivenWhenThen approach。因此我们的测试看起来如下:

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

或这样的:

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

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

@DisableD注释

此外,如果您遵循TDD并编写很多测试,这些测试总是在旅途中运行,Junit 5具有方便的注释@Disabled,可让您关闭由于某种原因而无法工作的测试或编写以添加代码。

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

请记住,关闭失败或不稳定测试是一种不良习惯。您应该修复此类测试或使其一次失败的代码。

参数化测试@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
    }
}

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

@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)
        )
    )
}

在此示例中,我使用了附加的注释@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
        }
    }
}

目的是相同的:我们想测试SimpleViewModel方法的逻辑。但是,我们不知道(不想知道)用户的回报。为此,我们可以使用模拟框架,例如Mockito

嘲笑

Mockito和类似的框架使我们能够更改我们不直接测试的类和方法的逻辑,而是代码的一部分。在我们的示例中,当返回预期用户列表中时,我们可以模拟usersRepository.getUsers()方法的执行,以检查loadUsers方法的执行。我不会详细介绍 Mockito s 语法,因为它具有许多功能和细微差别。但是,这两个关键特征是:它可以替换方法的返回值;它还可以检查是否已使用预期参数调用替换的方法。在这种情况下,我们的测试看起来像这样:

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()
}

您可以看到,我使用mock(函数创建mockUsersRepository对象。然后,在setup()方法中,我以这样的方式设置了此存储库,以便它返回硬编码值列表。在此之后,在其他所有内容上,我检查是否在mockUsersRepository对象中,在测试方法中使用verify函数调用getUsers方法。重要的是要注意, Mockito 是一个非常古老的框架,最初是为Java编写的。当Kotlin出现时,事实证明,这个框架可以在某些限制中起作用,这就是为什么释放了extension Mockito-Kotlin 的原因。上面的示例是用它编写的。另一个嘲笑者的限制是,不可能嘲笑最终课程和静态方法,默认情况下,所有类都是Kotlin的最终方法。目前,这是通过mockito-inline解决的,但这是一个严重的限制。在Java开发方面,有并且是Android开发人员的替代方法, PowerMock。

PowerMock

这个框架旨在消除嘲笑最终,静态和私人方法的限制。它成功地解决了这个问题,但同时使用了一些困难。例如,我们可以按以下方式模拟静态方法:

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

现在,其中大部分都可以在 Mockito 中获得。更重要的是 - 自然而然地,Kotlin没有静态的方法,这是一个很好的做法,不要创造它们。理想代码是遵循坚实而干净的体系结构原则的代码,这意味着可以轻松嘲笑和测试。因此,我们的Android开发人员不需要 PowerMock 的特殊功能,但重要的是要记住,这种框架存在。另一个 MOCKITO的限制涉及与Coroutines和Flow一起使用的。在这种情况下,再次, Mockito-Kotlin 以及像turbine这样的第三方图书馆将为您提供帮助。话虽如此,我想告诉您Mockito的另一种替代方案,这是 Mockk

Mockk

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

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

常规的嘲笑可以嘲笑它。这就是为什么需要 Mockito-Kotlin 扩展。有了它,我们的模拟将看起来像这样:

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

mockk 中,同一功能的模拟将是这样:

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

您可以看到,这是一个品味的问题。

但是, Mockk Mockito 之间的重要区别在于,所有Mockk的模拟都没有默认值。在Mockito中,可以预期返回单元的方法,并且返回参考类型的方法将返回 null 。这可能会导致不利的后果:如果我们可以在生产代码中处理此空值,那么很明显,该测试会错误地隐藏NPE。在 Mockk 中,默认情况下,任何模拟类别的任何方法都会引发异常,尽管可以创建放松的模拟以实现类似于Mockito的行为。

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

实际上,这是一个反模式,因为它可能导致开发人员缺少放松模拟隐藏的错误。但是,有时不为返回单元的函数创建模拟可能是有用的。为此, mockk 有一个特殊的参数:

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

这可能是 Mockk Mockito 之间的主要区别。但这取决于您决定是否有优势以及是否应该因此而改变框架。

现在,我们了解了为单位测试创建模拟的框架,我想让任务更加复杂。在我们的示例中,SimpleViewModel类只有一个依赖项usersRepository,而该存储库反过来仅包含一种方法。在现实生活中,您的经过的班级可以具有十几个依赖关系,并具有一组彼此密切相关的方法。这就是为什么viewModel.loadUsers方法可能要复杂得多。在下面的示例中,我们结合了几个可观察结果以得出最终结果。

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(),
    )

要测试init()方法的一组方案,您需要进行一组测试,其中包含用于嘲笑observeCurrentUserobserveCurrentProfile等的重复方法。我们可以将它们添加到BeforeEachMethod中,但是,我们要小心而不要忘记更新不需要的默认模型。 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`() {
            ///
        }
    }

按照此示例,我们可以将这种方法扩展到分组测试并根据需要创建尽可能多的嵌套级别,只要它们对我们和团队的其他成员都可以阅读并且可以理解。尝试遵守KISS原则。

junit 5 和repovelist框架还有许多其他有趣的功能。我只描述了我自己使用的那些人,我认为对我们非常有用,Android开发人员。

TDD与BDD

实际上,在前面的示例中,我们已经从TDD标准方面有所移动,因为我们不仅要测试代码的可操作性,而且还要检查代码是否根据某些规格运行(给定/何时/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()
        }
    }
})

您可以看到,我们的测试逻辑被写为lambda表达式,每个测试都是从行,测试名称和运行测试的单元中的一种映射。

描述 - 样式主要用于编写 Spek 测试。 describe结构使我们能够创建一组描述特定方法的测试,在it中,编写了该方法的特定方案。但是也可以使用给定样式。不幸的是,该框架没有嵌入了Coroutine支持,因此我们必须始终将runBlockingTest { }用于悬挂功能。 Github上有这张feature request的票,但似乎从未完成。

spek 不提供自己的断言,模拟或匹配者,但它允许您与其他库一起工作,例如 mockk mockito < /strong>。

从袋子里

在其语法和行为方面,它类似于 spek 。由于其Kotlin优先的原则和灵活性,它最近也在Android开发人员中广受欢迎。也许,灵活性首先意味着您可以选择编写测试的样式。目前,您可以从10 styles中进行选择。就个人而言,我更喜欢 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()
        }
    }
})

上面的测试根据给定原理以freespec风格起作用。

kotest 已经嵌入了Coroutine支持,这就是为什么与Spek相比,您不必使用runBlockingTest { }来暂停功能。

与Junit 4/5不同的测试隔离水平在这里很重要。每次测试后未清除模拟和测试条件的值。如果您想要类似于Junit的行为,请使用isolationMode = IsolationMode.InstancePerLeaf标志。

class SimpleViewModelKoTest : FreeSpec({

        isolationMode = IsolationMode.InstancePerLeaf

另外, kotest 提供了一个额外的set of asserts和匹配器,比我们与 Mockito 一起使用的匹配器可能更方便。如果您的项目已经使用 Mockito Junit ,那可以,因为用Kotest编写的测试可以与 Junit 。 P>

结论

今天,Android应用程序有许多单元测试框架和库。我只描述了最受欢迎的,但还有更多。如果您从事企业Android项目,则很可能会用本文技术中提到的一些来编写单元测试。通常,大公司使用经过时间考验的工具,例如Junit和Mockito。但是,随着新的Kotlin功能继续发布,出现了许多新的测试库和框架。如果您以前从未使用过,我绝对可以建议您尝试Mockk或Kotest。它们可以使您的生活更轻松并提高考试的质量。

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