El ecosistema de testing en Kotlin ofrece dos caminos principales. JUnit5 es el estandar del mundo JVM y funciona con Kotlin sin friccion, con la ventaja de integrarse perfectamente con cualquier proyecto Spring o gradle tradicional. Kotest es un framework nacido en Kotlin que apuesta por un DSL muy expresivo, varios estilos de especificacion, matchers ricos y soporte nativo para property-based testing.
Ambos frameworks son validos en produccion y pueden convivir en un mismo proyecto. La eleccion suele depender del equipo y del tipo de pruebas predominantes. En lecciones anteriores se han introducido coroutines y flows, asi que esta guia cubre tambien las tecnicas especificas para probar codigo asincrono con runTest y Turbine.
Configuracion en Gradle
Un proyecto con Gradle y el plugin org.jetbrains.kotlin.jvm se prepara anadiendo las dependencias de test y el runner JUnit Platform.
dependencies {
testImplementation("org.jetbrains.kotlin:kotlin-test")
testImplementation("org.junit.jupiter:junit-jupiter")
testImplementation("io.kotest:kotest-runner-junit5")
testImplementation("io.kotest:kotest-assertions-core")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test")
testImplementation("app.cash.turbine:turbine")
}
tasks.test {
useJUnitPlatform()
}
IntelliJ IDEA detecta los tests de ambos frameworks y permite ejecutarlos con la misma UI. La convencion habitual es colocar los tests en src/test/kotlin, replicando la estructura de paquetes del codigo principal.
Estructura de un test con JUnit5
Los tests de JUnit5 se escriben con anotaciones como @Test, @BeforeEach, @AfterEach y @DisplayName. La clase de test agrupa casos relacionados y el runner ejecuta cada metodo por separado.
import org.junit.jupiter.api.*
import org.junit.jupiter.api.Assertions.assertEquals
class CalculadoraTest {
private lateinit var calc: Calculadora
@BeforeEach
fun preparar() {
calc = Calculadora()
}
@Test
@DisplayName("suma de dos numeros positivos")
fun sumaDosPositivos() {
assertEquals(7, calc.sumar(3, 4))
}
@Test
fun restaDaNegativoCuandoProcede() {
assertEquals(-1, calc.restar(3, 4))
}
}
La clase Calculadora es la unidad bajo prueba. @BeforeEach reinstancia el objeto antes de cada test, garantizando aislamiento. assertEquals compara el valor esperado con el obtenido y falla si difieren.
Assertions idiomaticas con kotlin-test
El modulo kotlin-test ofrece un API coherente en todas las plataformas soportadas por Kotlin. En la JVM, sus funciones assertEquals, assertTrue, assertFailsWith y similares son envoltorios neutros que delegan en JUnit.
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
class ValidadorTest {
@Test
fun rechazaCorreosSinArroba() {
val resultado = Validador.validar("sincorrreo.com")
assertFalse(resultado.esValido)
}
@Test
fun lanzaExcepcionSiEsVacio() {
assertFailsWith<IllegalArgumentException> {
Validador.validar("")
}
}
}
assertFailsWith<T> es un patron idiomatico para verificar que una operacion lanza una excepcion concreta. La alternativa en JUnit puro con assertThrows tambien funciona, pero esta version resulta mas cercana a la sintaxis Kotlin.
Tests parametrizados con JUnit5
Los casos parametrizados cubren muchas entradas con un solo metodo de test, lo que mejora la cobertura sin duplicar codigo.
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
import kotlin.test.assertEquals
class ConversorTest {
@ParameterizedTest
@CsvSource(
"0, 32.0",
"100, 212.0",
"37, 98.6",
"-40, -40.0"
)
fun celsiusAFahrenheit(celsius: Int, esperado: Double) {
assertEquals(esperado, Conversor.cToF(celsius), 0.01)
}
}
Esta anotacion genera un caso por cada fila del CSV. Para datos mas complejos, existen @MethodSource (genera datos desde un metodo estatico) y @ArgumentsSource (usa una clase propia).
Kotest: estilos de especificacion
Kotest permite escribir las pruebas con varios estilos. Los mas usados son StringSpec (tests enlazados a una descripcion textual) y BehaviorSpec (estructura Given/When/Then).
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldStartWith
class SaludadorSpec : StringSpec({
"devuelve un saludo en espanol" {
Saludador.saludar("Ana") shouldBe "Hola, Ana"
}
"ignora espacios al inicio" {
Saludador.saludar(" Luis").shouldStartWith("Hola")
}
})
Los matchers shouldBe, shouldStartWith, shouldContain y muchos otros se leen como prosa. El bloque entre llaves es el cuerpo del test y su clave es la descripcion que aparece en el informe.
BehaviorSpec para describir comportamientos
import io.kotest.core.spec.style.BehaviorSpec
import io.kotest.matchers.shouldBe
class CarritoSpec : BehaviorSpec({
Given("un carrito vacio") {
val carrito = Carrito()
When("se anaden dos productos") {
carrito.anadir(Producto("libro", 20.0))
carrito.anadir(Producto("taza", 5.0))
Then("el total es la suma de sus precios") {
carrito.total() shouldBe 25.0
}
Then("contiene dos elementos") {
carrito.cantidad() shouldBe 2
}
}
}
})
La estructura Given/When/Then es util para tests que documentan el comportamiento del dominio. Cada Then se ejecuta de forma aislada, con la preparacion de los Given y When que lo contienen.
Property-based testing
Una caracteristica diferenciadora de Kotest es el soporte para property-based testing con el modulo kotest-property. Permite declarar invariantes que se prueban con muchos valores generados automaticamente.
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import io.kotest.property.Arb
import io.kotest.property.arbitrary.int
import io.kotest.property.arbitrary.list
import io.kotest.property.forAll
class ReversaSpec : StringSpec({
"reversa doble devuelve la lista original" {
forAll(Arb.list(Arb.int(), 0..50)) { lista ->
lista.reversed().reversed() == lista
}
}
})
Con pocas lineas se prueban cientos de listas generadas, lo que descubre casos limite que se escapan a los ejemplos escritos a mano. Es especialmente valioso para algoritmos puros y estructuras de datos.
Testing de coroutines con runTest
El modulo kotlinx-coroutines-test ofrece runTest, un builder que ejecuta codigo suspend con un dispatcher virtual que controla el tiempo. Los delay no esperan reloj real, sino que se avanzan de forma deterministica.
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
class OperacionAsincronaTest {
@Test
fun calculaResultadoDespuesDeEspera() = runTest {
val resultado = operacionAsincrona()
assertEquals(42, resultado)
}
private suspend fun operacionAsincrona(): Int {
delay(1_000)
return 42
}
}
El test se ejecuta en milisegundos aunque el codigo contenga un delay de un segundo. runTest acumula el tiempo virtual y permite adelantarlo con advanceTimeBy o advanceUntilIdle cuando hay trabajos lanzados en segundo plano.
Inyectar un TestDispatcher
Para probar codigo que interactua con Dispatchers.Main o Dispatchers.IO, es habitual inyectar un dispatcher configurable. En Kotlin el patron mas claro es pasar un CoroutineDispatcher por constructor.
import kotlinx.coroutines.*
import kotlinx.coroutines.test.*
class ServicioCache(private val io: CoroutineDispatcher) {
suspend fun cargar(): String = withContext(io) {
delay(500)
"contenido"
}
}
class ServicioCacheTest {
@Test
fun cargaDelegaEnDispatcher() = runTest {
val dispatcher = StandardTestDispatcher(testScheduler)
val servicio = ServicioCache(dispatcher)
val resultado = servicio.cargar()
assertEquals("contenido", resultado)
}
}
El dispatcher de test comparte el scheduler con runTest, por lo que el tiempo virtual afecta tambien a las corrutinas lanzadas alli dentro. Esto evita esperas reales y convierte los tests en deterministicos.
Testing de flujos con Turbine
La libreria Turbine simplifica la verificacion de flujos, especialmente cuando hay varios elementos y se quieren comprobar en orden.
import app.cash.turbine.test
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
class ContadorFlowTest {
private fun contador() = flow {
emit(1)
emit(2)
emit(3)
}
@Test
fun emiteValoresEsperados() = runTest {
contador().test {
assertEquals(1, awaitItem())
assertEquals(2, awaitItem())
assertEquals(3, awaitItem())
awaitComplete()
}
}
}
test { } suscribe al flujo y expone un DSL con awaitItem, awaitComplete, awaitError y cancelAndConsumeRemainingEvents. Es mucho mas legible que acumular elementos en una lista y comprobarlos al final.
StateFlow en tests
import app.cash.turbine.test
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
class EstadoViewModelTest {
@Test
fun avanzaDeCargaAExito() = runTest {
val estado = MutableStateFlow("cargando")
estado.test {
assertEquals("cargando", awaitItem())
estado.value = "exito"
assertEquals("exito", awaitItem())
cancelAndConsumeRemainingEvents()
}
}
}
El patron es util en aplicaciones que exponen estado con StateFlow hacia una capa de UI. El test verifica que las transiciones ocurren en el orden esperado sin tener que recurrir a Thread.sleep ni a latches.
Diagrama de decision
flowchart LR
A[Nuevo test] --> B{Codigo asincrono?}
B -- No --> C{Equipo habitua<br/>a JUnit?}
B -- Si --> D[runTest +<br/>TestDispatcher]
D --> E{Flujo reactivo?}
E -- Si --> F[Turbine]
E -- No --> G[assertEquals<br/>sobre suspend]
C -- Si --> H[JUnit5 +<br/>kotlin-test]
C -- No --> I[Kotest con<br/>StringSpec/BehaviorSpec]
La combinacion mas empleada en proyectos modernos es JUnit5 + kotlin-test + kotlinx-coroutines-test + Turbine, a la que se puede anadir Kotest para suites especificas que se beneficien de su DSL o del property-based testing.
Buenas practicas
Algunas recomendaciones transversales ayudan a mantener una base de tests saludable.
- 1. Nombres largos y descriptivos: en Kotlin se puede usar backticks para dar titulos legibles sin limites de identificadores.
- 2. Un test, una aserto principal: multiples verificaciones son aceptables, pero tests con decenas de
assertEqualssuelen esconder problemas de diseno. - 3. Evita mocks cuando puedas usar fakes: una implementacion controlada de la interfaz real es mas robusta que una libreria de mocks con stubs.
- 4. Aprovecha
runTestpara toda coroutine: evitarunBlockingen tests porque bloquea el hilo real. - 5. Usa Turbine en vez de acumular en listas: el codigo es mas claro y detecta ordenes incorrectos.
Las aplicaciones profesionales suelen convivir con un mix de tests unitarios rapidos y pruebas de integracion mas amplias. Kotlin no prescribe un unico camino: lo habitual es elegir el framework que mejor encaje con el estilo del equipo y cuidar la cobertura de las partes criticas, con especial atencion a las que mezclan coroutines y flujos.
Alan Sastre
Ingeniero de Software y formador, CEO en CertiDevs
Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, Kotlin es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.
Más tutoriales de Kotlin
Explora más contenido relacionado con Kotlin y continúa aprendiendo con nuestros tutoriales gratuitos.
Aprendizajes de esta lección
Escribir tests en Kotlin con JUnit5 y assertions idiomaticas, usar Kotest con estilos BehaviorSpec y StringSpec, probar coroutines con runTest y flows con Turbine.