Patrón arquitectónico MVI en Kotlin Multiplatform. Parte 3: prueba





Este artículo es el último de una serie sobre la aplicación del patrón arquitectónico MVI en Kotlin Multiplatform. En las dos partes anteriores ( parte 1 y parte 2 ), recordamos qué es MVI, creamos un módulo de Kittens genérico para cargar imágenes de gatos y lo integramos en aplicaciones iOS y Android.



En esta parte, cubriremos el módulo Kittens con pruebas unitarias y de integración. Aprenderemos sobre las limitaciones actuales de las pruebas en Kotlin Multiplatform, descubriremos cómo superarlas e incluso hacer que funcionen a nuestro favor.



Un proyecto de muestra actualizado está disponible en nuestro GitHub .



Prólogo



No hay duda de que las pruebas son un paso importante en el desarrollo de software. Por supuesto, ralentiza el proceso, pero al mismo tiempo:



  • le permite comprobar los casos extremos que son difíciles de detectar manualmente;

  • Reduce la posibilidad de regresión al agregar nuevas funciones, corregir errores y refactorizar;

  • te obliga a descomponer y estructurar tu código.



A primera vista, el último punto puede parecer una desventaja, porque lleva tiempo. Sin embargo, hace que el código sea más legible y beneficioso a largo plazo.



“De hecho, la proporción de tiempo dedicado a leer versus escribir es más de 10 a 1. Estamos constantemente leyendo código antiguo como parte del esfuerzo por escribir código nuevo. ... [Por lo tanto,] facilitar la lectura facilita la escritura ". - Robert C. Martin, "Código limpio: un manual de artesanía de software ágil"


Kotlin Multiplatform amplía las capacidades de prueba. Esta tecnología agrega una característica importante: cada prueba se realiza automáticamente en todas las plataformas compatibles. Si, por ejemplo, solo se admiten Android e iOS, la cantidad de pruebas se puede multiplicar por dos. Y si en algún momento se agrega soporte para otra plataforma, automáticamente se cubre en las pruebas. 



Las pruebas en todas las plataformas compatibles son importantes porque puede haber diferencias en el comportamiento del código. Por ejemplo, Kotlin / Native tiene un modelo de memoria especial , Kotlin / JS a veces también da resultados inesperados.



Antes de continuar, vale la pena mencionar algunas de las limitaciones de prueba en Kotlin Multiplatform. El más grande es la falta de una biblioteca de burla para Kotlin / Native y Kotlin / JS. Esto puede parecer una gran desventaja, pero personalmente lo considero una ventaja. Probar en Kotlin Multiplatform fue bastante difícil para mí: tuve que crear interfaces para cada dependencia y escribir sus implementaciones de prueba (falsificaciones). Me llevó mucho tiempo, pero en algún momento me di cuenta de que dedicar tiempo a las abstracciones es una inversión que conduce a un código más limpio. 



También noté que las modificaciones posteriores a este código toman menos tiempo. ¿Porqué es eso? Porque la interacción de una clase con sus dependencias no está clavada (burlada). En la mayoría de los casos, basta con actualizar sus implementaciones de prueba. No necesita profundizar en cada método de prueba para actualizar sus simulacros. Como resultado, dejé de usar bibliotecas burlonas incluso en el desarrollo estándar de Android. Recomiendo leer el siguiente artículo: " Burlarse no es práctico - Use falsificaciones " de Pravin Sonawane .



Plan



Recordemos lo que tenemos en el módulo Gatitos y lo que debemos probar.



  • KittenStore es el componente principal del módulo. Su implementación KittenStoreImpl contiene la mayor parte de la lógica empresarial. Esto es lo primero que vamos a probar.

  • KittenComponent es la fachada del módulo y el punto de integración para todos los componentes internos. Cubriremos este componente con pruebas de integración.

  • KittenView es una interfaz pública que representa la dependencia de la interfaz de usuario de KittenComponent.

  • KittenDataSource es una interfaz de acceso web interna que tiene implementaciones específicas de plataforma para iOS y Android.



Para una mejor comprensión de la estructura del módulo, daré su diagrama UML:







El plan es el siguiente:



  • Prueba KittenStore
    • Creando una implementación de prueba de KittenStore.Parser

    • Creando una implementación de prueba de KittenStore.Network

    • Pruebas unitarias de escritura para KittenStoreImpl



  • Prueba del componente Kitten
    • Creando una implementación de prueba de KittenDataSource

    • Cree una implementación de KittenView de prueba

    • Pruebas de integración de escritura para KittenComponent



  • Ejecutando pruebas

  • conclusiones





Prueba de la unidad KittenStore



La interfaz KittenStore tiene su propia clase de implementación: KittenStoreImpl. Esto es lo que vamos a probar. Tiene dos dependencias (interfaces internas), definidas directamente en la propia clase. Comencemos escribiendo implementaciones de prueba para ellos.



Prueba de implementación de KittenStore.Parser



Este componente es responsable de las solicitudes de red. Así es como se ve su interfaz:



interfaz de red {
fun load () : Quizás < String >
}
ver crudo KittenStoreImpl.kt alojado con ❤ por GitHub


Antes de escribir una implementación de prueba de una interfaz de red, debemos responder una pregunta importante: ¿qué datos devuelve el servidor? La respuesta es que el servidor devuelve un conjunto aleatorio de enlaces de imágenes, cada vez un conjunto diferente. En la vida real, se usa el formato JSON, pero como tenemos una abstracción del analizador, no nos importa el formato en las pruebas unitarias.



Una implementación real puede cambiar de transmisión, por lo que los suscriptores pueden congelarse en Kotlin / Native. Sería genial modelar este comportamiento para asegurarse de que el código maneja todo correctamente.



Por lo tanto, nuestra implementación de prueba de Network debería tener las siguientes características:



  • debe devolver un conjunto no vacío de filas diferentes para cada solicitud;

  • el formato de respuesta debe ser común para la red y el analizador;

  • debería poder simular errores de red (tal vez debería completarse sin una respuesta);

  • debería ser posible simular un formato de respuesta no válido (para comprobar si hay errores en el analizador);

  • debería ser posible simular retrasos en la respuesta (para comprobar la fase de arranque);

  • debería poder congelarse en Kotlin / Native (por si acaso).



La implementación de la prueba en sí misma podría verse así:



clase TestKittenStoreNetwork (
planificador de val privado : TestScheduler
) : KittenStoreImpl . Red {
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 tiene almacenamiento de cadenas (como un servidor real) y puede generar cadenas. Para cada solicitud, la lista actual de líneas se codifica en una línea. Si la propiedad "imágenes" es cero, entonces Maybe simplemente terminará, lo que debería considerarse un error.



También usamos TestScheduler . Este programador tiene una función importante: congela todas las tareas entrantes. Por lo tanto, el operador observeOn, utilizado junto con TestScheduler, congelará el flujo descendente, así como todos los datos que pasan a través de él, como en la vida real. Pero al mismo tiempo, el multihilo no estará involucrado, lo que simplifica las pruebas y las hace más confiables.



Además, TestScheduler tiene un modo especial de "procesamiento manual" que nos permitirá simular la latencia de la red.



Prueba de implementación de KittenStore.Parser



Este componente es responsable de analizar las respuestas del servidor. Aquí está su interfaz:



analizador de interfaz {
fun parse ( json : String ) : Quizás < Lista < Cadena >>
}
ver crudo KittenStoreImpl.kt alojado con ❤ por GitHub


Por lo tanto, todo lo que se descargue de la web debe convertirse en una lista de enlaces. Nuestra red simplemente concatena cadenas usando un separador de punto y coma (;), así que use el mismo formato aquí.



Aquí hay una implementación de prueba:



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 = ";"
}
}


Al igual que con Network, TestScheduler se usa para congelar a los suscriptores y verificar su compatibilidad con el modelo de memoria Kotlin / Native. Los errores de procesamiento de respuesta se simulan si la cadena de entrada está vacía.



Pruebas unitarias para KittenStoreImpl



Ahora tenemos implementaciones de prueba de todas las dependencias. Es hora de las pruebas unitarias. Todas las pruebas unitarias se pueden encontrar en el repositorio , aquí solo daré la inicialización y algunas pruebas.



El primer paso es crear instancias de nuestras implementaciones de prueba:



class KittenStoreTest {
analizador val privado = TestKittenStoreParser ()
private val networkScheduler = TestScheduler ()
red val privada = TestKittenStoreNetwork (networkScheduler)
tienda de diversión privada () : KittenStore = KittenStoreImpl (red, analizador)
// ...
}
ver crudo KittenStoreTest.kt alojado con ❤ por GitHub


KittenStoreImpl usa mainScheduler, por lo que el siguiente paso es anularlo:



class KittenStoreTest {
red val privada = 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


Ahora podemos ejecutar algunas pruebas. KittenStoreImpl debería cargar las imágenes inmediatamente después de la creación. Esto significa que se debe cumplir una solicitud de red, se debe procesar su respuesta y se debe actualizar el estado con el nuevo resultado.



@Prueba
fun load_images_WHEN_created () {
val images = network.generateImages ()
val store = store ()
aseverarEquals ( State.Data.Images (urls = images), store.state. data )
}
ver crudo KittenStoreTest.kt alojado con ❤ por GitHub


Lo que hicimos:



  • imágenes generadas en la Red;

  • creó una nueva instancia de KittenStoreImpl;

  • se aseguró de que el estado contenga la lista correcta de cadenas.



Otro escenario que debemos considerar es obtener KittenStore.Intent.Reload. En este caso, la lista debe recargarse desde la red.



@Prueba
fun reloads_images_WHEN_Intent_Reload () {
network.generateImages ()
val store = store ()
val newImages = network.generateImages ()
store.onNext ( Intent.Reload )
asertEquals ( State.Data.Images (urls = newImages), store.state. data )
}
ver crudo KittenStoreTest.kt alojado con ❤ por GitHub


Pasos de prueba:



  • generar imágenes fuente;

  • cree una instancia de KittenStoreImpl;

  • generar nuevas imágenes;

  • enviar Intent.Reload;

  • asegúrese de que la condición contenga nuevas imágenes.



Finalmente, probemos el siguiente escenario: cuando el indicador isLoading se establece mientras se cargan las imágenes.



@Prueba
fun isLoading_true_WHEN_loading () {
networkScheduler.isManualProcessing = verdadero
network.generateImages ()
val store = store ()
asertTrue (store.state.isLoading)
}
ver crudo KittenStoreTest.kt alojado con ❤ por GitHub


Hemos habilitado el procesamiento manual para TestScheduler; ahora las tareas no se procesarán automáticamente. Esto nos permite verificar el estado mientras esperamos una respuesta.



Pruebas de integración de componentes de Kitten



Como mencioné anteriormente, KittenComponent es el punto de integración de todo el módulo. Podemos cubrirlo con pruebas de integración. Echemos un vistazo a su API:



interna clase KittenComponent interna constructor ( dataSource : KittenDataSource ) {
constructor () : este ( KittenDataSource ())
fun onViewCreated ( vista : KittenView ) { / * ... * / }
fun onStart () { / * ... * / }
fun onStop () { / * ... * / }
fun onViewDestroyed () { / * ... * / }
fun onDestroy () { / * ... * / }
}
ver crudo KittenComponent.kt alojado con ❤ por GitHub


Hay dos dependencias, KittenDataSource y KittenView. Necesitaremos implementaciones de prueba para estos antes de que podamos comenzar a probar.



Para completar, este diagrama muestra el flujo de datos dentro del módulo:







Implementación de prueba de KittenDataSource



Este componente es responsable de las solicitudes de red. Tiene implementaciones separadas para cada plataforma y necesitamos otra implementación para las pruebas. Así es como se ve la interfaz KittenDataSource:



interfaz interna KittenDataSource {
carga divertida ( límite : Int , desplazamiento : Int ) : Quizás < String >
}


TheCatAPI admite la paginación, así que agregué los argumentos apropiados de inmediato. De lo contrario, es muy similar a KittenStore.Network, que implementamos anteriormente. La única diferencia es que tenemos que usar el formato JSON ya que estamos probando código real en integración. Así que solo estamos tomando prestada la idea de implementación:



clase interna TestKittenDataSource (
planificador de val privado : 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
}
}


Como antes, generamos diferentes listas de cadenas que se codifican en una matriz JSON en cada solicitud. Si no se generan imágenes o los argumentos de la solicitud son incorrectos, Maybe simplemente terminará sin una respuesta.



La biblioteca kotlinx.serialization se usa para formar una matriz JSON . Por cierto, el KittenStoreParser probado lo usa para decodificar.



Prueba de implementación de KittenView



Este es el último componente para el que necesitamos una implementación de prueba antes de que podamos comenzar a probar. Aquí está su interfaz:



interfaz KittenView : MviView < Modelo , Evento > {
Modelo de clase de datos (
val isLoading : booleano ,
val isError : booleano ,
val imageUrls : List < String >
)
Evento de clase sellada {
objeto RefreshTriggered : Event ()
}
}


Es una vista que solo toma modelos y activa eventos, por lo que su implementación de prueba es muy simple:



clase TestKittenView : AbstractMviView < Modelo , Evento > (), KittenView {
lateinit var modelo : Modelo
anular el renderizado divertido ( modelo : Modelo ) {
este .modelo = modelo
}
}
ver crudo TestKittenView.kt alojado con ❤ por GitHub


Solo necesitamos recordar el último modelo aceptado; esto nos permitirá verificar la exactitud del modelo mostrado. También podemos enviar eventos en nombre de KittenView utilizando el método dispatch (Event), que se declara en la clase AbstractMviView heredada.



Pruebas de integración para KittenComponent



El conjunto completo de pruebas se puede encontrar en el repositorio , aquí daré solo algunas de las más interesantes.



Como antes, comencemos creando instancias de dependencias e inicializando:



class KittenComponentTest {
Private val dataSourceScheduler = TestScheduler ()
Private val dataSource = TestKittenDataSource (dataSourceScheduler)
vista de valor privado = TestKittenView ()
privada divertido startComponent () : KittenComponent =
KittenComponent (fuente de datos). aplicar {
onViewCreated (ver)
onStart ()
}
// ...
}


Actualmente se utilizan dos programadores para el módulo: mainScheduler y computationScheduler. Necesitamos anularlos:



class KittenComponentTest {
Private val dataSourceScheduler = TestScheduler ()
Private val dataSource = TestKittenDataSource (dataSourceScheduler)
vista de valor privado = TestKittenView ()
privada divertido startComponent () : KittenComponent =
KittenComponent (fuente de datos). aplicar {
onViewCreated (ver)
onStart ()
}
// ...
@AntesTest
divertido antes () {
overrideSchedulers (main = { TestScheduler ()}, computación = { TestScheduler ()})
}
@Después de la prueba
divertido después () {
overrideSchedulers ()
}
}


Ahora podemos escribir algunas pruebas. Primero revisemos el script principal para asegurarnos de que las imágenes estén cargadas y mostradas al inicio:



@Prueba
fun load_and_shows_images_WHEN_created () {
val images = dataSource.generateImages ()
startComponent ()
asertEquals (imágenes, view.model.imageUrls)
}


Esta prueba es muy similar a la que escribimos cuando miramos las pruebas unitarias para KittenStore. Solo ahora está involucrado todo el módulo.



Pasos de prueba:



  • generar enlaces a imágenes en TestKittenDataSource;

  • crear y ejecutar KittenComponent;

  • asegúrese de que los enlaces lleguen a TestKittenView.



Otro escenario interesante: las imágenes deben recargarse cuando KittenView activa el evento RefreshTriggered.



@Prueba
fun reloads_images_WHEN_Event_RefreshTriggered () {
dataSource.generateImages ()
startComponent ()
val newImages = dataSource.generateImages ()
view.dispatch ( Event.RefreshTriggered )
asertEquals (newImages, view.model.imageUrls)
}


Etapas:



  • generar enlaces de origen a imágenes;

  • crear y ejecutar KittenComponent;

  • generar nuevos enlaces;

  • enviar Event.RefreshTriggered en nombre de KittenView;

  • asegúrese de que los nuevos enlaces lleguen a TestKittenView.





Ejecutando pruebas



Para ejecutar todas las pruebas, debemos realizar la siguiente tarea de Gradle:



./gradlew :shared:kittens:build


Esto compilará el módulo y ejecutará todas las pruebas en todas las plataformas compatibles: Android e iosx64.



Y aquí está el informe de cobertura de JaCoCo:







Conclusión



En este artículo, cubrimos el módulo Kittens con pruebas unitarias y de integración. El diseño propuesto del módulo nos permitió cubrir las siguientes partes:



  • KittenStoreImpl: contiene la mayor parte de la lógica empresarial;

  • KittenStoreNetwork: responsable de las solicitudes de red de alto nivel;

  • KittenStoreParser: responsable de analizar las respuestas de la red;

  • todas las transformaciones y conexiones.



El último punto es muy importante. Es posible cubrirlo gracias a la función MVI. La única responsabilidad de la vista es mostrar datos y enviar eventos. Todas las suscripciones, conversiones y enlaces se realizan dentro del módulo. Así, podemos cubrir todo con pruebas generales, excepto la propia pantalla.



Tales pruebas tienen las siguientes ventajas:



  • no utilice las API de la plataforma;

  • realizado muy rápidamente;

  • confiable (no parpadear);

  • se ejecuta en todas las plataformas compatibles.



También pudimos probar la compatibilidad del código con el complejo modelo de memoria Kotlin / Native. Esto también es muy importante debido a la falta de seguridad en el tiempo de compilación: el código simplemente falla en tiempo de ejecución con excepciones que son difíciles de depurar.



Espero que esto te ayude en tus proyectos. ¡Gracias por leer mis artículos! Y no olvides seguirme en Twitter .



...





Ejercicio extra



Si desea trabajar con implementaciones de prueba o jugar con MVI, aquí hay algunos ejercicios prácticos.



Refactorización de KittenDataSource



Hay dos implementaciones de la interfaz KittenDataSource en el módulo: una para Android y otra para iOS. Ya mencioné que son responsables del acceso a la red. Pero en realidad tienen otra función: generan la URL para la solicitud basándose en los argumentos de entrada "límite" y "página". Al mismo tiempo, tenemos una clase KittenStoreNetwork que no hace nada más que delegar la llamada a KittenDataSource.



Asignación: mueva la lógica de generación de solicitudes de URL de KittenDataSourceImpl (en Android e iOS) a KittenStoreNetwork. Necesita cambiar la interfaz KittenDataSource de la siguiente manera:







Una vez que lo haya hecho, deberá actualizar sus pruebas. La única clase que necesita tocar es TestKittenDataSource.



Añadiendo carga de página



TheCatAPI admite la paginación, por lo que podemos agregar esta funcionalidad para una mejor experiencia de usuario. Puede comenzar agregando un nuevo evento Event.EndReached para KittenView, después de lo cual el código dejará de compilarse. Luego, deberá agregar el Intent.LoadMore apropiado, convertir el nuevo Evento en Intent y procesar este último en KittenStoreImpl. También deberá modificar la interfaz KittenStoreImpl.Network de la siguiente manera:







Finalmente, deberá actualizar algunas implementaciones de prueba, corregir una o dos pruebas existentes y luego escribir algunas nuevas para cubrir la paginación.






All Articles