Android中的顶级状态测试

本文探讨了在Android应用中使用有状态测试验证状态逻辑的方法,以文本编辑器为例,通过定义状态、动作和预期结果,演示了如何编写、执行和优化测试,以增强代码覆盖率和可靠性。

从广义上讲,在应用程序中,状态是可以随时间变化的任何值。所有 Android 应用都会向用户显示状态,例如启用/禁用按钮、视图中显示的文本等。我​​们希望用户通过 UI 执行操作来修改状态。

这些动作几乎可以以任何顺序发生,导致许多潜在状态,这就是使测试它们成倍复杂的原因。

但是,如果我们可以为每个测试自动生成许多这些动作序列呢?

这就是有状态测试的用途。
状态测试是基于属性的测试的更复杂版本,我们验证在每个预定义的操作之后,状态是否满足给定的要求或属性,这可能取决于其先前的状态。

有状态的测试会半随机地生成数千个这样的动作,试图找到以失败告终的那些动作的组合。

因此,让我们看看如何应用有状态测试来验证 Android 应用程序中的状态逻辑。为此,我们将以一个接受文本输入以及撤消和重做操作的文本编辑器为例。

定义测试对象:文本编辑器

定义它的状态

状态通常用可以进行单元测试的普通 Java/Kotlin 类来表示。因此,我们需要了解文本编辑器必须持有哪些状态变量才能实现撤消和重做功能。例如:

  • 文本状态:显示的文本和光标位置
  • 撤消和重做文本状态:用户撤消/重做操作后要呈现的下一个 TextState。为了防止内存问题,撤消和重做状态的最大数量受缓冲区大小的限制。

我们可以这样建模:

data class TextEditorModelState(
    val bufferSize: Int,
    val textState: TextState = TextState(),
    val undoTextStates: Stack<TextState> = 
          CircularBuffer(bufferSize),
    val redoTextStates: Stack<TextState> = 
          CircularBuffer(bufferSize),
) {

    data class TextState(
        val displayedText: String = "",
        val cursorPosition: Int = 0,
    )
    ...
)

CircularBuffer 是一个 LIFO(队列),在溢出的情况下,它会在推送新元素之前踢出第一个元素(即最旧的元素)。

定义动作

指定状态类后,还有待定义:

  1. 用户可以执行以修改它的操作类型。
  2. 何时可以执行每种动作类型(即前提条件),如果并非总是如此。
  3. 每个动作类型后的预期状态(即后置条件)。这些定义了实际要求。

我们开始做吧。

  • TextChange:通过键盘输入或粘贴/剪切选项添加/删除任何文本。
状态变量前提条件后置条件
当前的TextState反映新的文本和光标位置
UndoTextStates尺寸增加一,直到缓冲区大小
RedoTextStates尺寸之后清空(它被清除)

  • 撤消:单击撤消操作(如果启用)
状态变量前提条件后置条件
当前的TextState推入的最后一个值undoTextStates
UndoTextStates尺寸大于零 -> 启用撤消尺寸减一
RedoTextStates尺寸增加一,直到缓冲区大小(它可以重做这个“撤消操作”)

  • 重做:如果启用,请单击重做操作。我们将在这里省略它,因为它类似于撤消。


所以这些是我们文本编辑器的要求!它的骨架在代码中看起来像这样

复制

复制

复制

复制

复制

复制

复制

复制

复制

复制

复制

COPY
class TextEditor(val bufferSize: Int = DEFAULT_BUFFER_SIZE) {

private var modelState = 
   TextEditorModelState(bufferSize)

fun undo() {
    modelState = modelState.copy(...)
}

fun redo() {
    modelState = modelState.copy(...)
}

fun textStateChange(
   newText: String,
   cursorPosition: Int,
) {
    modelState = modelState.copy(...)
}

fun getModelState(): TextEditorModelState = modelState.copy()

}
你可以在这里找到所有的实现细节。

如何在我们的 Android 应用中使用这个 TextEditor?简单的。让 ViewModel 将textStateChange(),undo()和redo()动作委托给 TextEditor。这样一来,它的状态就可以感知生命周期。有关更多详细信息,请参阅此处的代码

对文本编辑器进行单元测试
在大多数情况下,我强烈建议您在任何有状态测试之前编写示例单元测试。有状态的测试不会取代它们,而是补充它们。

例如,我们可以对以下每个序列进行单元测试

动作顺序预期结果
文本更改 21 次(缓冲区大小为 20)只能执行 20 个撤消操作
文本更改两次,撤消两次,重做一次启用重做,可能有 1 个重做动作
文本更改三次,撤消一次,文本更改一次重做禁用

这样的单元测试在代码上将是可读且易于理解的。但是,这些测试有以下缺点:

  1. 只验证代码对于那些确切的序列是正确的。在大多数情况下,这可能就足够了。
  2. 很难确保它们涵盖了每一个相关的场景。用户可以以几乎任何随机顺序执行文本更改、撤消和重做。

这就是为什么我们可以通过在我们已经存在的单元测试中添加状态测试来缓解这些问题并获得对我们代码的信心。

不幸的是,Junit4 和 Junit5 都不支持开箱即用的状态测试。因此,我们将在接下来的示例中了解如何使用 Jqwik 测试库进行状态测试。

在 gradle 中为 Android 项目启用 Jqwik 需要配置kotlinOptions和testOptions. 查看此配置,了解能够同时运行所有 Jqwik、Junit4 和 Junit5 测试的配置。

那么让我们看看如何编写一些有状态的测试!

截至 2022 年 8 月 31 日,Jqwik 是 Java/Kotlin 最好的支持有状态测试的测试库,其语法与 Junit5
非常相似。Kotest 是一个流行的 kotlin 多平台测试库,未来也有计划支持有状态测试!

使用 Jqwik 对文本编辑器进行有状态测试

实施行动

至此,我们已经定义了动作类型和执行后的预期状态。编写有状态测试的第一步是将其转换为代码。为此,Jqwik 要求我们对每种操作类型执行以下操作:

  • 继承自 Jqwik 的Action类。
  • 覆盖precondition(state: StateHolder)。它定义了何时可以生成操作(如果有任何约束)。
  • 覆盖run(state: StateHolder)。在这里,我们:
  1. 保存动作之前的状态,即之前的状态
  2. 执行改变状态的动作
  3. 断言新状态,基于先前的状态(即断言后置条件)

这看起来像这样TextChangeAction

class TextChangeAction(
    private val newText: String,
    private val cursorPosition: Int,
) : Action<TextEditor> {
    override fun run(state: TextEditor): TextEditor {
        // 1. save previous state
        val previousUndoTexts = 
            state.getModelState().copy().undoTextFieldStates

        // 2. perform action
        state.textStateChange(newText, cursorPosition)

        // 3. assert new state
        expectThat(state.getModelState()) {
            displayedTextEquals(newText)
            undoActionsSizeIncreasedByOneUpToMax(
                previousActionsSize = previousUndoTexts.size,
                maxUndoActionsSize = state.bufferSize,
            )
            redoActionsSizeEquals(0)
        }
        return state
    }

    // override for more readable logs in the Jqwik report
    override fun toString(): String {
        return "TextChangedAction($newText)"
    }
}

注意断言发生在Action类中,而不是测试本身,我们稍后会写。

UndoAction&RedoAction非常相似,其特点是它们具有一个先决条件:如果没有要撤消/重做的内容,我们不想生成撤消/重做动作。为此,我们还必须重写该precondition(state: StateHolder)方法,例如UndoAction:

class UndoAction : Action<TextEditor> {
    override fun precondition(state: TextEditor): Boolean =
        state.getModelState().undoTextFieldStates.isNotEmpty()

    override fun run(state: TextEditor): TextEditor {
        // 1. save previous state
        val previousRedoTexts = 
            state.getModelState().copy().redoTextFieldStates
        val previousUndoTexts = 
            state.getModelState().copy().undoTextFieldStates

        // 2. perform action
        state.undo()

        // 3. assert new state
        expectThat(state.getModelState()) {
            displayedTextEquals(previousUndoTexts.peek().displayedText)
            undoActionsSizeEquals(previousUndoTexts.size - 1)
            redoActionsSizeIncreasedByOneUpToMax(
                previousActionsSize = previousRedoTexts.size,
                maxRedoActionsSize = state.bufferSize,
            )
        }
        return state
    }

    // override for more readable logs in the Jqwik report
    override fun toString(): String {
        return "UndoAction"
    }
}

RedoAction 是类比的,所以为了简洁起见,我们把它省略了。

实现随机动作生成器

现在我们已经定义了动作,我们将生成它们的半随机序列。这涉及:

  1. 为每个动作生成任意值。
  2. 生成这些任意动作的序列。

关于每个动作的任意生成,最简单的实现是Arbitrary. 此类TextChangeAction包含任意文本和光标位置,即TextState,因此我们需要生成Arbitrary第一个。请注意,光标位置必须在文本长度范围内。我们这样实现

private fun arbitraryTextState(): Arbitrary<TextState> {
    // low end of range to reduce the generation time
    val textLengthRange = IntRange(1, 20)
    val arbText = 
        Arbitraries.strings().ofLength(textLengthRange)
    val arbCursorPosition = 
        arbText.flatMap { 
            text -> Arbitraries.integers().between(0, text.length) 
        }

    return Combinators.combine(arbText, arbCursorPosition)
        .`as` { text, cursorPosition ->
            TextState(text, cursorPosition)
        }
}

使用flatMap&map创建依赖于其他 Arbitraries 的 Arbitraries。
用于combine创建由其他任意对象组成的任意对象

现在我们arbitraryTextState()用来生成Arbitrary

private fun arbitraryTextChangeAction(): Arbitrary<TextChangeAction>
   = arbitraryTextState().map { 
       TextChangeAction(it.displayedText, it.cursorPosition) 
     }

最后,生成我们之前定义的所有动作的半随机序列的方法

@Provide
fun arbitraryTextEditorActionSequence() =
    Arbitraries.sequences(
        Arbitraries.oneOf(
            arbitraryTextChangeAction(),
            Arbitraries.of(RedoAction()),
            Arbitraries.of(UndoAction()),
        )
    )

@Provide告诉Jqwik测试引擎,该方法生成任意值,在使用@Property注释的测试中用作参数

编写有状态测试本身

现在是最简单的一步:编写测试本身。这只需要几行代码。

@Property
fun executeRandomSequenceOfActions_textEditorModelStateIsCorrect(
     @ForAll("arbitraryTextEditorActionSequence") actionSequence:
     ActionSequence<TextEditor>
) {
     actionSequence.run(TextEditor())
)

@ForAll表示用于生成相应类型的套利的方法。此类方法必须使用@Provide进行注释

分析错误:收缩的重要性

想象一下我们的生产代码中有一个错误。例如,我们忘记在 TextEditor中重置RedoTextStateActionsafter each。TextStateChangeAction

如果我们运行测试,就会检测到错误。测试报告将向我们展示 Jqwik 生成的原始样本和缩小样本。像这样的东西


原始样本序列看起来过于复杂。这是因为动作的数量以及每个动作的输入参数(如 in TextChangeAction,其中包含非 ascii 字符串)是随机生成的!在原始示例中,生成了 9 个动作,但可能已经生成了 30 个或更多。

这就是为什么收缩在有状态测试中如此有用的特性:收缩样本是导致测试失败的最简单的样本,原因相同。在这种情况下,它是序列TextChangeAction、UndoAction和TextChangeAction。

这意味着,我们可以将缩小的样本添加为基于示例的测试(即标准单元测试)并且测试将失败!这种方法对于修复错误和避免回归非常有用。有状态的测试在每次运行时都会生成新的值,但我们为每个失败的样本添加的基于示例的测试确保我们修复的错误不会在不被发现的情况下重新出现。

所以我们可以修复错误,再次运行测试,我们就完成了!

但我们真的完成了吗?

覆盖未覆盖:生成值的统计信息

我们知道我们已经在一个半随机的动作序列下测试了我们的文本编辑器状态,但我们实际上并不知道它生成了多少动作和哪种类型。

这很重要因为我们要确保,例如,文本编辑器尊重其缓冲区大小,这意味着让文本编辑器进入“UndoTextStateActions”的大小可能大于其缓冲区大小的情况。

Jqwik 支持添加有关半随机生成的值的统计信息。我们可以使用它们来检查用例的测试频率。
然而,使它非常有价值的是,如果没有覆盖用例,我们可以使测试失败。

回到我们的文本编辑器,有一些场景我们希望确保我们涵盖有状态测试。对于undoTextStates,那将是

  1. 它达到了缓冲区大小,“最大撤消”
  2. 缓冲区大小下的任何值,“在两者之间撤消”。

所以我们需要收集相应的统计数据。这是使用 Jqwik 的方法

fun collectStatsUndo(textEditorModelState: TextEditorModelState) {
    val bufferSize = 
        textEditorModelState.bufferSize
    val undoStatesSize = 
        textEditorModelState.undoTextFieldStates.size
    val reachedBufferSize = 
        undoStatesSize == bufferSize
    val statistics = 
        if (reachedBufferSize) "undo at max" else "undo in between"
    Statistics.collect(statistics)
}

然后在我们的测试中收集这些统计数据,如果没有涵盖任何情况,它就会失败

@Property
fun executeRandomSequenceOfActions_textEditorModelStateIsCorrect(
    @ForAll("arbitraryTextEditorActionSequence") actionSequence:
    ActionSequence<TextEditor>
) {
    // "peek" accesses the internal state
    // after each successful execution of an action’s run(..)
    actionSequence.peek { textEditor ->
        collectStatsUndo(textEditor.getModelState())
    }.run(TextEditor())

    Statistics.coverage { checker ->
        // if predicate not met, fail!
        checker.check("undo in between")
               .count(Predicate { times -> times > 0 })
        checker.check("undo at max")
               .count(Predicate { times -> times > 0 })
    }
}

在运行此测试几次(或仅一次)之后,我们很可能会得到以下结果…


换句话说,前面的测试验证了我们的文本编辑器对于添加/删除文本、撤消和重做操作的随机序列的正确性,但不确定它是否也涵盖了撤消缓冲区达到最大值的用例,以及一个新的 TextChangeAction 被执行。

请记住,缓冲区大小越大,使用有状态测试覆盖该场景的可能性就越小。

那么,如何解决这个问题呢?我们有两个选择:

  1. 创建一个直接导致该案例的附加操作,它TextChangeSequenceOverBufferSizeAction至少bufferSize + 1在一行中执行文本更改,例如,如果缓冲区大小为 20,则更改 21 个文本。
  2. 添加一个涵盖该特殊情况的单独属性测试,甚至是基于示例的测试。属性测试的优点是如果不覆盖这种情况,我们可以使用统计信息使其失败,而缺点是运行速度较慢。

结论

有状态测试是应用于状态持有者类的基于属性的测试。它们涵盖了每次测试运行的数百个动作序列。其中一些序列可能包括我们忘记在单元测试中验证的相关案例。结果,它们使我们对状态逻辑的正确性更有信心。

另一方面,它们更通用。这使得有状态测试的可读性低于标准单元测试。而且由于它们每次运行都会生成数千个序列,因此测试执行需要更长的时间。

如果以下任何一项适用,我的建议是用状态测试来补充此类组件的单元测试

  1. 它的功能对业务至关重要。
  2. 它将在应用程序的几个部分中重复使用(例如,属于共享模块)。

最后但并非最不重要的一点是,在编写有状态测试时有一些额外的建议:

  • 每当有状态测试失败时,为缩小的样本编写单元测试。这有助于避免回归错误。
  • 使用统计信息来确保涵盖难以生成的状态转换。
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值