Reescribir para Kotlin Multiplatform sin cambiar el comportamiento: el código antiguo como oráculo de pruebas
Por Nesim Razon, desarrollador Android independiente · 2026-11-16
Pasé dos pequeñas aplicaciones de lectura RSS y una aplicación del espacio a Kotlin Multiplatform para que también funcionaran en iOS. Eso significaba reescribir código que dependía de clases de Android y de Java. No quería que ningún usuario actual notara la diferencia, así que dejé la implementación antigua en las fuentes de pruebas y la hice discutir con la nueva. Así fue.
Qué hubo que reescribir
El código común de Kotlin no puede llamar a clases de Android ni de Java, y iOS no tiene ninguna de ellas. El analizador RSS usaba XmlPullParser, la limpieza de texto usaba HtmlCompat, las fechas pasaban por SimpleDateFormat y la aplicación del espacio barajaba sus tarjetas diarias con java.util.Random. Las cuatro cosas tenían que convertirse en Kotlin plano que compile en ambas plataformas.
Una reescritura así es el lugar clásico para introducir cambios silenciosos: un título que pierde un acento, una fecha que se desplaza una hora, una lista en otro orden. Nada de eso provoca un cierre inesperado, así que nadie lo nota hasta que un usuario escribe una reseña.
Conservar el código antiguo como oráculo
Mi regla fue sencilla: no borrar la implementación antigua, moverla. En TurkSail el analizador anterior pasó, sin cambios, a un archivo de las fuentes de pruebas de Android llamado LegacyRss. Ya no se publica, pero sigue compilando y sigue dando las respuestas en las que confío, porque usuarios reales llevan años viendo su resultado.
El analizador nuevo vive en el módulo compartido. La prueba entrega el mismo XML a ambos y pregunta si los resultados son iguales.
La prueba de paridad
La prueba analiza un feed con el analizador nuevo y con el antiguo, y primero comprueba que ambos devuelven el mismo número de artículos. Después recorre los artículos uno a uno y compara seis cosas campo por campo: título, enlace, fecha, imagen de portada, resumen y HTML limpio. El mensaje de error nombra el feed, el índice del artículo y el campo, así que la diferencia es fácil de localizar.
Como el lado antiguo necesita clases de Android, la prueba se ejecuta con Robolectric en el módulo de la aplicación. Por defecto usa feeds de ejemplo incluidos en el proyecto. Si la lanzo con -Drss.live=true, también descarga los feeds en vivo de los sitios reales y compara esos. Esa propiedad del sistema tiene que reenviarse a la JVM de pruebas en la configuración de pruebas del módulo; si no, el interruptor no hace nada y no avisa.
Trampas que encontró el oráculo
Los feeds contienen HTML que a su vez está escapado como XML, así que las entidades se decodifican dos veces: una en la pasada de XML y otra en la pasada de HTML. Un feed con un ampersand escapado seguido de uuml solo acaba como la letra u con diéresis después de las dos pasadas. Un atajo que decodifica una sola vez da un texto incorrecto.
HtmlCompat conocía las entidades HTML con nombre y el código común no. Escribí una tabla con las 96 entidades con nombre de Latin-1, más las pocas otras que usan los feeds, y trato las entidades numéricas, decimales y hexadecimales, incluidos los caracteres fuera del plano básico. Las entidades desconocidas se dejan tal cual, que es lo que hacía el código antiguo.
Las fechas son RFC 822 con desfase, por ejemplo día de la semana, día, nombre del mes, hora y una zona con signo más o menos y cuatro dígitos. SimpleDateFormat ya no está, así que las analizo con una expresión regular, construyo el desfase a mano y convierto con kotlinx-datetime. Las pruebas unitarias fijan una marca de tiempo concreta, con desfase y con GMT, y comprueban que una entrada basura devuelve null, como antes.
Un generador aleatorio que no debe cambiar
La aplicación del espacio muestra una tarjeta del día. El orden sale de barajar la lista de tarjetas con java.util.Random con semilla 2026, y el índice de la tarjeta se deriva de la fecha. Si el barajado cambiara, todos los usuarios actuales verían de pronto otra tarjeta el mismo día.
Así que escribí una clase pequeña que reproduce java.util.Random exactamente: el generador congruencial lineal de 48 bits, la forma en que nextInt elige un valor por debajo de un límite (con el bucle de reintento y el atajo para potencias de dos) y el mismo algoritmo que usa Collections.shuffle. Aquí el oráculo es el original: una prueba baraja la lista con java.util.Random(2026) en la JVM y comprueba que los identificadores salen en el mismo orden que con mi clase.
Detalles pequeños que importaron
Android guarda el feed descargado en un archivo de caché con un nombre derivado de un resumen de la dirección. Mantuve los nombres de archivo idénticos, de modo que tras la actualización la aplicación sigue encontrando el archivo antiguo y el feed se abre sin conexión de inmediato. La prueba de paridad no habría detectado un cambio de nombre de archivo, por eso lo cuento aquí.
La comparación en vivo cubrió 7 categorías y 72 artículos en TurkSail y 20 artículos en MedyaFobi, con cero diferencias.
Lo que esto no demuestra
Una prueba de paridad solo cubre las entradas que le das. Los feeds de ejemplo y una instantánea en vivo no dicen nada de un artículo publicado la semana que viene con una entidad o un formato de fecha que nunca he visto. Los feeds en vivo también cambian, así que una ejecución en verde hoy es una afirmación sobre hoy.
Además demuestra igualdad, no corrección: si el código antiguo tenía un error, el nuevo ahora tiene el mismo error a propósito. Lo acepté porque el objetivo era conservar el comportamiento, y las correcciones son un cambio aparte y visible. Con el generador aleatorio rige el mismo límite: coincide para los tamaños de lista que probé y confío en que el algoritmo es determinista.
Preguntas frecuentes
¿Por qué conservar el código antiguo en las pruebas en vez de borrarlo?
Porque es la descripción más fiable del comportamiento. Comparar el código nuevo con él campo por campo es más rápido y seguro que escribir los valores esperados a mano.
¿La prueba de paridad necesita acceso a internet?
No por defecto. Usa feeds de ejemplo incluidos en el proyecto. La comparación en vivo solo se ejecuta si la prueba se lanza con -Drss.live=true.
¿Por qué reimplementar java.util.Random en lugar de usar otro barajado?
Otro barajado cambiaría la tarjeta del día de todos los usuarios actuales. Reproducir el generador exacto mantiene la secuencia sin cambios.
¿Una prueba de paridad en verde garantiza que no hay regresiones?
No. Solo cubre las entradas que ve y los datos en vivo cambian; reduce el riesgo pero no lo elimina.
Más del blog
- Por qué mi enlace de la App Store dio 404 y la página mostraba capturas de 2018
- Guía 2.1 de App Store «Information Needed»: lo que envié
- Capturas de App Store sin teléfono: Robolectric, un cargador de imágenes falso y un navegador sin interfaz
- Un código QR para dos tiendas de apps: una pequeña página de redirección
- Una lista remota sin servidor para «Más juegos»: un archivo JSON en GitHub Pages
- Depurar un iPhone desde Windows por USB: capturas y registros con Python