Kotlin Multiplatform中的MVI建筑模式。第3部分:测试





本文是在Kotlin Multiplatform中应用MVI架构模式的系列文章的最后一篇。在前两部分(第1部分第2部分)中,我们记得MVI是什么,创建了一个通用的Kittens模块来加载猫图像并将其集成到iOS和Android应用程序中。



在这一部分中,我们将通过单元测试和集成测试来介绍Kittens模块。我们将了解Kotlin Multiplatform中当前的测试局限性,弄清楚如何克服它们,甚至使它们对我们有利。



在我们的GitHub上有更新的示例项目



序幕



毫无疑问,测试是软件开发中的重要一步。当然,这会减慢该过程的速度,但同时:



  • 使您可以检查难以手动捕获的边缘情况;

  • 减少在添加新功能,修复错误和重构时进行回归的机会;

  • 强迫您分解和构造代码。



乍一看,最后一点似乎很不利,因为这需要时间。但是,从长远来看,它使代码更具可读性和益处。



“实际上,读与写所花费的时间比例远远超过10:1。作为编写新代码的一部分,我们不断读取旧代码。... [因此,]使其易于阅读使其易于书写。” -Robert C. Martin,“清洁代码:敏捷软件工艺手册”


Kotlin Multiplatform扩展了测试功能。该技术增加了一个重要功能:每个测试都在所有受支持的平台上自动执行。例如,如果仅支持Android和iOS,则测试次数可以乘以2。并且,如果在某个时候添加了对另一个平台的支持,那么它将自动成为测试的一部分。 



在所有支持的平台上进行测试很重要,因为代码的行为可能有所不同。例如,Kotlin / Native具有特殊的内存模型,Kotlin / JS有时也会产生意外的结果。



在继续进行之前,值得一提的是Kotlin Multiplatform中的一些测试限制。最大的问题是缺少Kotlin / Native和Kotlin / JS的任何模拟库。这似乎是一个很大的缺点,但我个人认为这是一个优点。对我来说,在Kotlin Multiplatform中进行测试非常困难:我必须为每个依赖项创建接口并编写其测试实现(伪造)。它花了很长时间,但是在某个时候,我意识到花时间在抽象上是一项投资,可以使代码更简洁。 



我还注意到,对该代码的后续修改花费的时间更少。这是为什么?因为没有钉牢(嘲笑)类及其依赖项的交互。在大多数情况下,仅更新其测试实现就足够了。无需深入研究每种测试方法即可更新模拟。结果,即使在标准的Android开发中,我也停止使用模拟库。我建议阅读以下文章:Pravin Sonawane的模拟不切实际-使用假货



计划



让我们记住小猫模块中的内容以及应该测试的内容。



  • KittenStore是模块的主要组件。它的KittenStoreImpl实现包含大多数业务逻辑。这是我们要测试的第一件事。

  • KittenComponent是所有内部组件的模块外观和集成点。我们将通过集成测试涵盖该组件。

  • KittenView是一个公共接口,代表KittenComponent的UI依赖项。

  • KittenDataSource是一个内部Web访问界面,具有针对iOS和Android的特定于平台的实现。



为了更好地理解该模块的结构,我将给出其UML图:







该计划如下:



  • 测试KittenStore
    • 创建KittenStore.Parser的测试实现

    • 创建KittenStore.Network的测试实现

    • KittenStoreImpl的编写单元测试



  • 测试KittenComponent
    • 创建KittenDataSource的测试实现

    • 构建测试KittenView实施

    • 为KittenComponent编写集成测试



  • 运行测试

  • 结论





KittenStore单元测试



KittenStore接口具有其自己的实现类-KittenStoreImpl。这就是我们要测试的。它具有两个依赖项(内部接口),直接在类本身中定义。让我们开始为它们编写测试实现。



测试KittenStore.Parser的实现



该组件负责网络请求。其界面如下所示:



接口 网络{
fun load() 也许< String >
}


在编写网络接口的测试实现之前,我们需要回答一个重要的问题:服务器返回什么数据?答案是服务器每次返回一组随机的图像链接。在现实生活中,使用的是JSON格式,但是由于我们具有Parser抽象,因此我们不在乎单元测试中的格式。



真正的实现可以切换流,因此可以将订阅者冻结在Kotlin / Native中。为这种行为建模可以确保代码正确处理所有事情。



因此,我们的网络测试实现应具有以下功能:



  • 必须为每个请求返回一组非空的不同行;

  • 网络和解析器的响应格式必须相同;

  • 应该能够模拟网络错误(也许应该在没有响应的情况下完成);

  • 必须有可能模拟无效的响应格式(以检查解析器中的错误);

  • 应该有可能模拟响应延迟(以检查启动阶段);

  • 应该在Kotlin / Native中可冻结(以防万一)。



测试实现本身可能如下所示:



TestKittenStoreNetwork
私有 val 调度程序 TestScheduler
KittenStoreImpl网络{
var images: List<String>? by AtomicReference<List<String>?>(null)
private var seed: Int by AtomicInt()
override fun load(): Maybe<String> =
singleFromFunction { images }
.notNull()
.map { it.joinToString(separator = SEPARATOR) }
.observeOn(scheduler)
fun generateImages(): List<String> {
val images = List(MAX_IMAGES) { "Img${seed + it}" }
this.images = images
seed += MAX_IMAGES
return images
}
private companion object {
private const val MAX_IMAGES = 50
private const val SEPARATOR = ";"
}
}


TestKittenStoreNetwork有一个字符串存储(就像真实的服务器一样)并且可以生成它们。对于每个请求,当前行列表被编码为一行。如果“ images”属性为零,则Maybe可能刚刚终止,应该认为是错误。



我们还使用了TestScheduler该调度程序具有一项重要功能:它冻结所有传入的任务。因此,与TestScheduler结合使用的observeOn运算符将冻结下游以及流经下游的所有数据,就像在现实生活中一样。但是同时,不会涉及多线程,这简化了测试并使其更加可靠。



此外,TestScheduler具有特殊的“手动处理”模式,该模式将允许我们模拟网络延迟。



测试KittenStore.Parser的实现



该组件负责解析服务器的响应。这是它的界面:



接口 解析器{
fun parsejson String 也许< List < String >>
}


因此,从网上下载的所有内容都应转换为链接列表。我们的网络只是使用分号(;)分隔符来连接字符串,因此在此处使用相同的格式。



这是一个测试实现:



class TestKittenStoreParser : KittenStoreImpl.Parser {
override fun parse(json: String): Maybe<List<String>> =
json
.toSingle()
.filter { it != "" }
.map { it.split(SEPARATOR) }
.observeOn(TestScheduler())
private companion object {
private const val SEPARATOR = ";"
}
}


与网络一样,TestScheduler用于冻结订户并检查其与Kotlin /本机内存模型的兼容性。如果输入字符串为空,则会模拟响应处理错误。



KittenStoreImpl的单元测试



现在,我们具有所有依赖项的测试实现。现在该进行单元测试了。所有单元测试都可以在存储库中找到,这里我仅给出初始化和一些测试本身。



第一步是创建我们的测试实现的实例:



KittenStoreTest{
私有 val解析器= TestKittenStoreParser()
私有 val networkScheduler = TestScheduler()
私有 val网络= TestKittenStoreNetwork(networkScheduler)
私人 娱乐 商店() KittenStore = KittenStoreImpl(网络,解析器)
// ...
}


KittenStoreImpl使用mainScheduler,因此下一步是覆盖它:



KittenStoreTest{
私有 val网络= TestKittenStoreNetwork()
private val parser = TestKittenStoreParser()
private fun store(): KittenStore = KittenStoreImpl(network, parser)
@BeforeTest
fun before() {
overrideSchedulers(main = { TestScheduler() })
}
@AfterTest
fun after() {
overrideSchedulers()
}
// ...
}
view raw KittenStoreTest.kt hosted with ❤ by GitHub


现在我们可以运行一些测试。KittenStoreImpl应该在创建后立即加载图像。这意味着必须满足网络请求,必须处理其响应,并且必须用新结果更新状态。



@测试
有趣的 loads_images_WHEN_created(){
val images = network.generateImages()
val store = store()
的assertEquals(State.Data.Images(网址=图像),store.state。数据
}


我们做了什么:



  • 在网络上生成图像;

  • 创建了一个KittenStoreImpl的新实例;

  • 确保状态包含正确的字符串列表



我们需要考虑的另一种情况是获取KittenStore.Intent.Reload。在这种情况下,必须从网络重新加载列表。



@测试
有趣的 reloads_images_WHEN_Intent_Reload(){
network.generateImages()
val store = store()
val newImages = network.generateImages()
store.onNext(Intent.Reload
的assertEquals(State.Data.Images(网址= newImages),store.state。数据
}


测试步骤:



  • 生成源图像;

  • 创建KittenStoreImpl的实例;

  • 生成新图像;

  • 发送Intent.Reload;

  • 确保条件包含新图像。



最后,让我们检查以下情况:在加载图像时设置isLoading标志。



@测试
有趣的 isLoading_true_WHEN_loading(){
networkScheduler.isManualProcessing = true
network.generateImages()
val store = store()
assertTrue(store.state.isLoading)
}


我们为TestScheduler启用了手动处理-现在将不会自动处理任务。这使我们可以在等待响应时检查状态。



KittenComponent集成测试



如前所述,KittenComponent是整个模块的集成点。我们可以进行集成测试。让我们看一下它的API:



内部 KittenComponent 内部 构造函数dataSource KittenDataSource){
构造函数() KittenDataSource())
有趣的 onViewCreated视图 KittenView){ / * ... * / }
有趣的 onStart(){ / * ... * / }
有趣的 onStop(){ / * ... * / }
有趣的 onViewDestroyed(){ / * ... * / }
有趣的 onDestroy(){ / * ... * / }
}


有两个依赖项,即KittenDataSource和KittenView。在开始测试之前,我们将需要测试实现。



为了完整起见,此图显示了模块内部的数据流:







测试KittenDataSource的实现



该组件负责网络请求。每个平台都有单独的实现,我们需要针对测试的另一个实现。这是KittenDataSource接口的样子:



内部 接口 KittenDataSource {
有趣的 负载极限 诠释偏移 诠释 也许<字符串>
}


TheCatAPI支持分页,因此我立即添加了适当的参数。否则,它与我们之前实现的KittenStore.Network非常相似。唯一的区别是,我们在集成中测试真实代码时必须使用JSON格式。因此,我们只是借鉴了实现思路:



内部 TestKittenDataSource
私有 val 调度程序 TestScheduler
) : KittenDataSource {
private var images by AtomicReference<List<String>?>(null)
private var seed by AtomicInt()
override fun load(limit: Int, page: Int): Maybe<String> =
singleFromFunction { images }
.notNull()
.map {
val offset = page * limit
it.subList(fromIndex = offset, toIndex = offset + limit)
}
.mapIterable { it.toJsonObject() }
.map { JsonArray(it).toString() }
.onErrorComplete()
.observeOn(scheduler)
private fun String.toJsonObject(): JsonObject =
JsonObject(mapOf("url" to JsonPrimitive(this)))
fun generateImages(): List<String> {
val images = List(MAX_IMAGES) { "Img${seed + it}" }
this.images = images
seed += MAX_IMAGES
return images
}
private companion object {
private const val MAX_IMAGES = 50
}
}


和以前一样,我们会在每个请求中生成不同的字符串列表,这些字符串被编码为JSON数组。如果没有图像生成,或者请求参数错误,也许会终止而没有响应。kotlinx.serialization



库用于形成JSON数组顺便说一下,经过测试的KittenStoreParser使用它进行解码。



测试KittenView的实现



这是我们开始测试之前需要测试实现的最后一个组件。这是它的界面:



KittenView 界面 MviView <模型事件> {
资料 类别 Model
val isLoading 布尔值
val isError 布尔值
val imageUrls 列表<字符串>
密封 事件{
RefreshTriggered对象 事件()
}
}


这个视图只接受模型并触发事件,因此其测试实现非常简单:



TestKittenView AbstractMviView <模型事件>(),KittenView {
lateinit var模型 模型
覆盖 有趣的 渲染模型 Model){
这个.model =模型
}
}


我们只需要记住最后接受的模型-这将使我们能够检查所显示模型的正确性。我们还可以使用在继承的AbstractMviView类中声明的dispatch(Event)方法代表KittenView调度事件。



KittenComponent的集成测试



完整的测试集可以在存储库中找到,在这里我仅给出一些最有趣的测试。



和以前一样,让我们​​首先实例化依赖关系并进行初始化:



KittenComponentTest{
私有 val dataSourceScheduler = TestScheduler()
私有 val dataSource = TestKittenDataSource(dataSourceScheduler)
私有 val视图= TestKittenView()
私人 乐趣 startComponent() KittenComponent =
KittenComponent(数据源)。申请{
onViewCreated(视图)
onStart()
}
// ...
}


当前,该模块有两个调度程序:mainScheduler和CalculationScheduler。我们需要覆盖它们:



KittenComponentTest{
私有 val dataSourceScheduler = TestScheduler()
私有 val dataSource = TestKittenDataSource(dataSourceScheduler)
私有 val视图= TestKittenView()
私人 乐趣 startComponent() KittenComponent =
KittenComponent(数据源)。申请{
onViewCreated(视图)
onStart()
}
// ...
@BeforeTest
有趣 (){
overlaySchedulers (主要= { TestScheduler()},计算= { TestScheduler()})
}
@AfterTest
()之后的乐趣{
overrideSchedulers()
}
}


我们现在可以编写一些测试。让我们首先检查主脚本,以确保在启动时加载并显示了图像:



@测试
有趣的 loads_and_shows_images_WHEN_created(){
val images = dataSource.generateImages()
startComponent()
assertEquals(images,view.model.imageUrls)
}


该测试与我们查看KittenStore的单元测试时编写的测试非常相似。直到现在才涉及整个模块。



测试步骤:



  • 在TestKittenDataSource中生成图像的链接;

  • 创建并运行KittenComponent;

  • 确保链接到达TestKittenView。



另一个有趣的情况:KittenView触发RefreshTriggered事件时需要重新加载图像。



@测试
有趣的 reloads_images_WHEN_Event_RefreshTriggered(){
dataSource.generateImages()
startComponent()
val newImages = dataSource.generateImages()
view.dispatch(Event.RefreshTriggered
assertEquals(newImages,view.model.imageUrls)
}


阶段:



  • 生成图像的源链接;

  • 创建并运行KittenComponent;

  • 产生新的链接;

  • 代表KittenView发送Event.RefreshTriggered;

  • 确保新链接到达TestKittenView。





运行测试



要运行所有测试,我们需要执行以下Gradle任务:



./gradlew :shared:kittens:build


这将编译模块并在所有支持的平台上运行所有测试:Android和iosx64。



这是JaCoCo的覆盖率报告:







结论



在本文中,我们用单元测试和集成测试介绍了Kittens模块。提议的模块设计使我们能够涵盖以下部分:



  • KittenStoreImpl-包含大多数业务逻辑;

  • KittenStoreNetwork-负责高层网络请求;

  • KittenStoreParser-负责解析网络响应;

  • 所有的转换和联系。



最后一点很重要。借助MVI功能,可以覆盖它。该视图的唯一责任是显示数据和调度事件。所有订阅,转换和链接都在模块内部完成。因此,我们可以用常规测试覆盖除显示屏本身以外的所有内容。



这样的测试具有以下优点:



  • 不使用平台API;

  • 表演很快

  • 可靠(不闪烁);

  • 在所有支持的平台上运行。



我们还能够测试该代码与复杂的Kotlin /本机内存模型的兼容性。这也是非常重要的,因为在构建时缺乏安全性:代码仅在运行时崩溃,并带有难以调试的异常。



希望这对您的项目有所帮助。感谢您阅读我的文章!并且不要忘记在Twitter上关注我



...





奖金运动



如果您想使用测试实现或使用MVI,这里有一些动手练习。



重构KittenDataSource



该模块中有KittenDataSource接口的两种实现:一种用于Android,一种用于iOS。我已经提到他们负责网络访问。但是它们实际上还有另一个功能:它们根据输入参数“ limit”和“ page”生成请求的URL。同时,我们有一个KittenStoreNetwork类,除了将调用委派给KittenDataSource外,它什么也不做。



分配:将URL请求生成逻辑从KittenDataSourceImpl(在Android和iOS上)移动到KittenStoreNetwork。您需要按以下方式更改KittenDataSource接口:







完成后,将需要更新测试。您需要触摸的唯一类是TestKittenDataSource。



添加页面加载



TheCatAPI支持分页,因此我们可以添加此功能以获得更好的用户体验。您可以通过为KittenView添加一个新的Event.EndReached事件开始,此后代码将停止编译。然后,您将需要添加适当的Intent.LoadMore,将新的Event转换为Intent,然后在KittenStoreImpl中对其进行处理。您还需要修改KittenStoreImpl.Network接口,如下所示:







最后,您将需要更新一些测试实现,修复一个或两个现有测试,然后编写一些新的测试来覆盖分页。






All Articles