Pruebas y un proyecto con Gradle en Kotlin

Lección 8 de 8. Hasta ahora cada programa era un archivo suelto. Aquí armas un proyecto de verdad con Gradle, la herramienta que usan Android Studio y casi todos los proyectos de Kotlin: configuración en build.gradle.kts, código y pruebas separados, una biblioteca de Maven Central, pruebas con kotlin.test, y un programa instalable que lee un CSV y escribe JSON.

Del archivo suelto al proyecto

Hasta ahora cada programa del curso cabía en un archivo: kotlinc lo compilaba y java lo ejecutaba. Un programa real tiene varios archivos, usa bibliotecas de otros (como kotlinx.serialization o las corrutinas de la lección 7) y lleva pruebas que se ejecutan solas. Para eso está Gradle, la herramienta de build que usan Android Studio, IntelliJ IDEA y casi todos los proyectos de Kotlin: descarga las dependencias, compila, ejecuta las pruebas y empaqueta el resultado. Sus scripts se escriben, además, en Kotlin.

En esta lección armamos un proyecto pequeño pero completo: una herramienta que lee tus gastos de un CSV, los resume por categoría, guarda el resumen en JSON y tiene sus pruebas. Todo lo que ves aquí se compiló y ejecutó con Gradle 9.7.0 y Kotlin 2.4.21.

Instalar Gradle

Gradle se descarga de gradle.org/install (un zip que se descomprime y se añade al PATH), o con SDKMAN en Linux y macOS: sdk install gradle 9.7.0. Necesita un JDK, el mismo que usas para Kotlin. Comprueba la instalación:

gradle --version
salida------------------------------------------------------------
Gradle 9.7.0
------------------------------------------------------------
Build time:    2026-08-06 14:07:35 UTC
Revision:      3defbfc59d757b873d787b2261de5c7f8a00970a
Kotlin:        2.4.0
Groovy:        4.0.32
Ant:           Apache Ant(TM) version 1.10.17 compiled on April 6 2026
Launcher JVM:  25.0.5 (Eclipse Adoptium 25.0.5+8-LTS)

Ojo con la línea Kotlin: 2.4.0: es la versión que Gradle usa por dentro para leer sus scripts, no la de tu proyecto. La de tu código la fija el plugin de Kotlin en el build.gradle.kts, como verás enseguida. Usamos Gradle 9.7.0 porque es la versión más nueva que el plugin de Kotlin 2.4.21 soporta del todo, según la tabla de compatibilidad de kotlinlang.org; ya existen versiones posteriores de Gradle, pero conviene esperar a que el plugin las cubra.

En la práctica solo instalas Gradle una vez, para crear el wrapper del proyecto (lo verás al final). A partir de ahí se usa ./gradlew, que descarga la versión exacta de Gradle que pide el proyecto. Si creas el proyecto desde IntelliJ IDEA o Android Studio, el wrapper ya viene incluido. También existe gradle init, un asistente que genera un proyecto de ejemplo; aquí lo escribimos a mano para entender cada pieza.

La estructura del proyecto

Gradle sigue una convención: el código va en src/main/kotlin, las pruebas en src/test/kotlin y la configuración en la raíz. Estos son todos los archivos que escribimos:

archivos del proyectobuild.gradle.kts
gastos.csv
settings.gradle.kts
src/main/kotlin/Gasto.kt
src/main/kotlin/Main.kt
src/main/kotlin/Resumen.kt
src/test/kotlin/MesVacioTest.kt
src/test/kotlin/ResumenTest.kt

A la izquierda lo que escribes: build.gradle.kts con los plugins y las dependencias, src/main/kotlin con el programa y src/test/kotlin con las pruebas. Gradle lo descarga, compila, prueba y empaqueta, y deja el resultado en la carpeta build: clases, el jar, los informes de pruebas y la instalaciónEl proyecto gastos: lo que escribes y lo que genera Gradlebuild.gradle.ktsplugins, dependenciasy cómo se ejecutasrc/main/kotlinGasto.kt, Resumen.kty Main.ktsrc/test/kotlinResumenTest.kt yMesVacioTest.ktgradledescargacompilapruebaempaquetabuild/clasesel .jarinformesinstall/

Tú escribes la configuración, el código y las pruebas; Gradle genera todo lo de la carpeta build. Esquema: decodigo.com.

settings.gradle.kts y build.gradle.kts

settings.gradle.kts solo dice cómo se llama el proyecto (y, en proyectos grandes, de qué módulos se compone):

rootProject.name = "gastos"

build.gradle.kts es el corazón: qué plugins se usan, de dónde salen las dependencias y cómo se ejecuta todo.

plugins {
    kotlin("jvm") version "2.4.21"
    kotlin("plugin.serialization") version "2.4.21"
    application
}

group = "com.ejemplo"
version = "1.0"

repositories {
    mavenCentral()
}

dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.12.0")
    testImplementation(kotlin("test"))
}

kotlin {
    jvmToolchain(25)
}

application {
    mainClass = "gastos.MainKt"
}

tasks.test {
    useJUnitPlatform()
    testLogging {
        events("passed", "failed")
    }
}
  • BloqueQué hace
  • pluginskotlin("jvm") compila Kotlin para la JVM en la versión 2.4.21; plugin.serialization genera el código de @Serializable; application añade gradle run e installDist.
  • repositoriesDónde buscar las bibliotecas: Maven Central, el mismo repositorio que usan Java y Android.
  • dependenciesimplementation para lo que usa el programa; testImplementation para lo que solo usan las pruebas.
  • kotlin { jvmToolchain(25) }Compila para Java 25 y avisa si el JDK instalado no es ese.
  • applicationLa clase con el main: gastos.MainKt, porque el archivo es Main.kt del paquete gastos.
  • tasks.testEjecutar las pruebas con JUnit 5 (JUnit Platform) y mostrar cada una, pase o falle.

El código

El CSV de ejemplo, en la raíz del proyecto:

fecha,categoria,importe
2026-09-28,Súper,854.20
2026-10-01,Transporte,120.00
2026-10-02,Súper,1236.75
2026-10-05,Ocio,450.00
2026-10-09,Transporte,95.50
2026-10-12,Súper,612.10
2026-10-15,Hogar,1899.00
2026-10-20,Ocio,289.90

Un gasto es una data class con su fecha, su categoría y su importe. La función que convierte una línea del CSV en un Gasto vive en el companion object, y require lanza una IllegalArgumentException con un mensaje claro si la línea no tiene tres columnas:

package gastos

import java.math.BigDecimal
import java.time.LocalDate

data class Gasto(val fecha: LocalDate, val categoria: String, val importe: BigDecimal) {
    companion object {
        // Una línea del CSV: fecha,categoria,importe
        fun desdeLinea(linea: String): Gasto {
            val partes = linea.split(",")
            require(partes.size == 3) { "Se esperaban 3 columnas: $linea" }
            return Gasto(
                fecha = LocalDate.parse(partes[0]),
                categoria = partes[1].trim(),
                importe = BigDecimal(partes[2].trim()),
            )
        }
    }
}

fun leerGastos(lineas: List<String>): List<Gasto> =
    lineas
        .drop(1)                       // la cabecera
        .filter { it.isNotBlank() }
        .map(Gasto::desdeLinea)

El resumen agrupa por categoría con groupBy, suma con sumOf y ordena de mayor a menor. Son las operaciones de colecciones de la lección 6, y están separadas del main a propósito: así se pueden probar sin leer archivos ni imprimir nada.

package gastos

import java.math.BigDecimal

data class TotalCategoria(val categoria: String, val movimientos: Int, val total: BigDecimal)

fun porCategoria(gastos: List<Gasto>): List<TotalCategoria> =
    gastos
        .groupBy { it.categoria }
        .map { (categoria, lista) -> TotalCategoria(categoria, lista.size, lista.sumOf { it.importe }) }
        .sortedByDescending { it.total }

fun totalDelMes(gastos: List<Gasto>, anio: Int, mes: Int): BigDecimal =
    gastos
        .filter { it.fecha.year == anio && it.fecha.monthValue == mes }
        .sumOf { it.importe }

Y el programa: comprueba los argumentos, lee el archivo, imprime el resumen y lo guarda en JSON con kotlinx.serialization. exitProcess termina con un código de salida distinto de cero cuando algo falla, para que un script que lo llame se entere.

package gastos

import java.io.File
import kotlin.system.exitProcess
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json

@Serializable
data class FilaResumen(val categoria: String, val movimientos: Int, val total: String)

private val json = Json { prettyPrint = true }

fun main(args: Array<String>) {
    if (args.size != 1) {
        System.err.println("Uso: gastos <archivo.csv>")
        exitProcess(1)
    }
    val archivo = File(args[0])
    if (!archivo.isFile) {
        System.err.println("No encuentro el archivo ${archivo.path}")
        exitProcess(2)
    }

    val gastos = leerGastos(archivo.readLines())
    val resumen = porCategoria(gastos)
    for (t in resumen) {
        println("%-12s %3d %10.2f".format(t.categoria, t.movimientos, t.total))
    }
    println("%-12s %3d %10.2f".format("Total", gastos.size, gastos.sumOf { it.importe }))

    val filas = resumen.map { FilaResumen(it.categoria, it.movimientos, it.total.toPlainString()) }
    File("resumen.json").writeText(json.encodeToString(filas))
    println("Guardado en resumen.json")
}

Los importes son BigDecimal y no Double porque es dinero: Double no puede guardar exactamente 0.10, y los centavos acaban descuadrando. El JSON lleva el total como texto (toPlainString()), porque kotlinx.serialization no sabe convertir un BigDecimal sin un serializador propio, y pasarlo a Double perdería justo lo que queríamos conservar.

Cuidado al comparar BigDecimal: su equals, que es lo que usa == en Kotlin, también compara la escala, es decir, el número de decimales. Por eso las pruebas escriben siempre los importes con dos decimales:

import java.math.BigDecimal

fun main() {
    println(BigDecimal("65.0") == BigDecimal("65.00"))
    println(BigDecimal("65.0").compareTo(BigDecimal("65.00")) == 0)
}
salidafalse
true

Para comparar solo el valor, compareTo. Pasa lo mismo en Java; lo explicamos en BigDecimal en Java.

Las pruebas, con kotlin.test

kotlin.test es la biblioteca de pruebas de Kotlin: funciones como assertEquals o assertFailsWith y la anotación @Test. Por debajo usa JUnit 5, que es quien encuentra y ejecuta las pruebas. Cada prueba es una función de una clase en src/test/kotlin, en el mismo paquete que el código, así que ve sus funciones sin importar nada:

package gastos

import java.math.BigDecimal
import java.time.LocalDate
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith

class ResumenTest {
    private val ejemplo = listOf(
        Gasto(LocalDate.of(2026, 9, 30), "Ocio", BigDecimal("10.00")),
        Gasto(LocalDate.of(2026, 10, 1), "Hogar", BigDecimal("50.00")),
        Gasto(LocalDate.of(2026, 10, 3), "Ocio", BigDecimal("15.00")),
    )

    @Test
    fun `lee una línea del CSV`() {
        val gasto = Gasto.desdeLinea("2026-10-05,Ocio,450.00")
        assertEquals(Gasto(LocalDate.of(2026, 10, 5), "Ocio", BigDecimal("450.00")), gasto)
    }

    @Test
    fun `rechaza una línea incompleta`() {
        val error = assertFailsWith<IllegalArgumentException> {
            Gasto.desdeLinea("2026-10-05,Ocio")
        }
        assertEquals("Se esperaban 3 columnas: 2026-10-05,Ocio", error.message)
    }

    @Test
    fun `suma por categoría y ordena de mayor a menor`() {
        val resumen = porCategoria(ejemplo)
        assertEquals(
            listOf(
                TotalCategoria("Hogar", 1, BigDecimal("50.00")),
                TotalCategoria("Ocio", 2, BigDecimal("25.00")),
            ),
            resumen,
        )
    }

    @Test
    fun `el total del mes solo cuenta ese mes`() {
        assertEquals(BigDecimal("10.00"), totalDelMes(ejemplo, 2026, 9))
        assertEquals(BigDecimal("65.00"), totalDelMes(ejemplo, 2026, 10))
    }
}

Los nombres entre comillas invertidas (`lee una línea del CSV`) son una costumbre muy de Kotlin en las pruebas: el nombre de la función es la frase que describe lo que se comprueba, con espacios y acentos, y así sale en los informes. Fuera de las pruebas no se usan. Las comparaciones son con assertEquals(esperado, real), en ese orden, y las data class facilitan mucho la vida: se comparan listas enteras de resúmenes de una sola vez porque su equals compara los datos.

Para ejecutarlas:

gradle test
gradle test> Task :checkKotlinGradlePluginConfigurationErrors SKIPPED
> Task :processResources NO-SOURCE
> Task :processTestResources NO-SOURCE
> Task :compileKotlin
> Task :compileJava NO-SOURCE
> Task :classes UP-TO-DATE
> Task :jar
> Task :compileTestKotlin
> Task :compileTestJava NO-SOURCE
> Task :testClasses UP-TO-DATE

> Task :test

MesVacioTest > un mes sin gastos suma cero() PASSED

ResumenTest > suma por categoría y ordena de mayor a menor() PASSED

ResumenTest > lee una línea del CSV() PASSED

ResumenTest > rechaza una línea incompleta() PASSED

ResumenTest > el total del mes solo cuenta ese mes() PASSED

BUILD SUCCESSFUL
4 actionable tasks: 4 executed

Gradle compiló el programa (compileKotlin), luego las pruebas (compileTestKotlin) y las ejecutó (test). Gracias al bloque testLogging vemos cada prueba; sin él, cuando todo pasa, Gradle no dice nada más que BUILD SUCCESSFUL. El orden en que aparecen puede cambiar de una ejecución a otra: JUnit no garantiza ninguno, y una prueba nunca debe depender de que otra se haya ejecutado antes.

Un fallo a propósito

Las pruebas valen por lo que atrapan. Cambiemos en Resumen.kt el sortedByDescending por sortedBy, un descuido fácil, y volvamos a ejecutarlas:

gradle test> Task :checkKotlinGradlePluginConfigurationErrors SKIPPED
> Task :processResources NO-SOURCE
> Task :processTestResources NO-SOURCE
> Task :compileKotlin
> Task :compileJava NO-SOURCE
> Task :classes UP-TO-DATE
> Task :jar
> Task :compileTestKotlin
> Task :compileTestJava NO-SOURCE
> Task :testClasses UP-TO-DATE

> Task :test FAILED

MesVacioTest > un mes sin gastos suma cero() PASSED

ResumenTest > suma por categoría y ordena de mayor a menor() FAILED
    org.opentest4j.AssertionFailedError at ResumenTest.kt:33

ResumenTest > lee una línea del CSV() PASSED

ResumenTest > rechaza una línea incompleta() PASSED

ResumenTest > el total del mes solo cuenta ese mes() PASSED

5 tests completed, 1 failed

FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':test'.
> There were failing tests. See the report at: build/reports/tests/test/index.html

La prueba del orden falló y Gradle dice en qué línea (ResumenTest.kt:33, la del assertEquals); el detalle, con el valor esperado y el obtenido lado a lado, está en el informe HTML de build/reports/tests/test/index.html. Las demás pruebas siguen pasando, porque el orden no les afecta. Y el build entero se marca como fallido: en un servidor de integración continua, eso impide publicar la versión rota.

El error que te ahorra: pasar el tipo equivocado a una función

En Main.kt es fácil confundir las líneas del archivo con los gastos ya leídos y escribir porCategoria(archivo.readLines()). En un lenguaje sin tipos, el programa arrancaría y fallaría a la mitad. Aquí Gradle se detiene al compilar, con el mensaje de kotlinc y la línea exacta:

gradle build> Task :checkKotlinGradlePluginConfigurationErrors SKIPPED
> Task :processResources NO-SOURCE
> Task :processTestResources NO-SOURCE

> Task :compileKotlin FAILED
e: src/main/kotlin/Main.kt:25:32 Argument type mismatch: actual type is 'List<String>', but 'List<Gasto>' was expected.

[Incubating] Problems report is available at: build/reports/problems/problems-report.html

FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':compileKotlin' (registered by plugin 'org.jetbrains.kotlin.jvm').
> A failure occurred while executing org.jetbrains.kotlin.compilerRunner.btapi.BuildToolsApiCompilationWork
   > Kotlin compiler: ARGUMENT_TYPE_MISMATCH
       Kotlin compiler error
         Argument type mismatch: actual type is 'List<String>', but 'List<Gasto>' was expected.
         Location: src/main/kotlin/Main.kt line 25

Ejecutar el programa

El plugin application añade la tarea run. Los argumentos del programa van en --args, y -q (silencioso) quita las líneas de las tareas para que solo se vea lo que imprime el programa:

gradle run -q --args="gastos.csv"
salidaSúper          3    2703.05
Hogar          1    1899.00
Ocio           2     739.90
Transporte     2     215.50
Total          8    5557.45
Guardado en resumen.json
cat resumen.json
[
    {
        "categoria": "Súper",
        "movimientos": 3,
        "total": "2703.05"
    },
    {
        "categoria": "Hogar",
        "movimientos": 1,
        "total": "1899.00"
    },
    {
        "categoria": "Ocio",
        "movimientos": 2,
        "total": "739.90"
    },
    {
        "categoria": "Transporte",
        "movimientos": 2,
        "total": "215.50"
    }
]

Sin argumentos, el programa escribe cómo se usa y termina con el código 1. Gradle lo trata como un fallo de la tarea run:

gradle run -qUso: gastos <archivo.csv>

FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':run' (registered by plugin 'org.gradle.application').
> Process 'command '/opt/java/openjdk/bin/java'' finished with non-zero exit value 1

Todo junto: gradle build

gradle build compila, ejecuta las pruebas y empaqueta. Si una prueba falla, no hay paquete. Es lo que se ejecuta antes de publicar una versión o en cada pull request:

gradle build> Task :checkKotlinGradlePluginConfigurationErrors SKIPPED
> Task :processResources NO-SOURCE
> Task :processTestResources NO-SOURCE
> Task :compileKotlin
> Task :compileJava NO-SOURCE
> Task :classes UP-TO-DATE
> Task :jar
> Task :startScripts
> Task :distTar
> Task :distZip
> Task :assemble
> Task :compileTestKotlin
> Task :compileTestJava NO-SOURCE
> Task :testClasses UP-TO-DATE

> Task :test

ResumenTest > suma por categoría y ordena de mayor a menor() PASSED

ResumenTest > lee una línea del CSV() PASSED

ResumenTest > rechaza una línea incompleta() PASSED

ResumenTest > el total del mes solo cuenta ese mes() PASSED

MesVacioTest > un mes sin gastos suma cero() PASSED

> Task :check
> Task :build

BUILD SUCCESSFUL
7 actionable tasks: 7 executed

gradle build ejecuta una cadena de tareas: compileKotlin compila el programa, jar lo empaqueta y assemble termina el empaquetado; compileTestKotlin compila las pruebas, test las ejecuta y check agrupa las comprobaciones; build depende de assemble y de checkQué hace gradle build: una cadena de tareascompileKotlinjarassemblecompileTestKotlintestcheckbuildSi una tarea falla, las que dependen de ella ya no se ejecutan.

Las tareas que encadena gradle build. Cada línea «> Task» de la salida es una de ellas. Esquema: decodigo.com.

Las tareas marcadas NO-SOURCE no tenían nada que hacer (no hay código Java ni recursos), y UP-TO-DATE significa que Gradle vio que nada había cambiado desde la última vez y se la saltó: es lo que hace rápidos los builds siguientes.

Distribuir el programa: installDist

Nadie quiere ejecutar un programa con gradle run. La tarea installDist prepara una carpeta lista para copiar a otra computadora: los .jar (el tuyo y sus dependencias, incluida la biblioteca estándar de Kotlin) y un script de arranque para Linux y macOS y otro para Windows.

gradle installDist
gradle installDist> Task :checkKotlinGradlePluginConfigurationErrors SKIPPED
> Task :compileKotlin UP-TO-DATE
> Task :compileJava NO-SOURCE
> Task :processResources NO-SOURCE
> Task :classes UP-TO-DATE
> Task :jar UP-TO-DATE
> Task :startScripts UP-TO-DATE
> Task :installDist

BUILD SUCCESSFUL
4 actionable tasks: 1 executed, 3 up-to-date
find build/install -type fbuild/install/gastos/bin/gastos
build/install/gastos/bin/gastos.bat
build/install/gastos/lib/annotations-13.0.jar
build/install/gastos/lib/gastos-1.0.jar
build/install/gastos/lib/kotlin-stdlib-2.4.21.jar
build/install/gastos/lib/kotlinx-serialization-core-jvm-1.12.0.jar
build/install/gastos/lib/kotlinx-serialization-json-jvm-1.12.0.jar
build/install/gastos/bin/gastos gastos.csv
salidaSúper          3    2703.05
Hogar          1    1899.00
Ocio           2     739.90
Transporte     2     215.50
Total          8    5557.45
Guardado en resumen.json
código de salida: 0
build/install/gastos/bin/gastos no-existe.csv
salidaNo encuentro el archivo no-existe.csv
código de salida: 2

El programa instalado solo necesita una JVM en la computadora donde se ejecute; no hace falta ni Kotlin ni Gradle. Para entregar un solo archivo existen plugins que hacen un fat jar (todo en un .jar), y distZip, que ya se ejecutó dentro de gradle build, deja esta misma carpeta comprimida en build/distributions/.

El wrapper: que todos usen el mismo Gradle

Si cada quien compila con su versión de Gradle, tarde o temprano algo funciona en una computadora y en otra no. El wrapper lo evita: un script, gradlew, que descarga y usa la versión exacta que fija el proyecto.

gradle wrapper
gradle wrapper> Task :wrapper

BUILD SUCCESSFUL
1 actionable task: 1 executed
lsbuild
build.gradle.kts
gastos.csv
gradle
gradlew
gradlew.bat
settings.gradle.kts
src
gradle/wrapper/gradle-wrapper.propertiesdistributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.0-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

A partir de aquí, en lugar de gradle test se escribe ./gradlew test (o gradlew test en Windows), y quien clone el proyecto no necesita instalar Gradle. gradlew, gradlew.bat y la carpeta gradle/ se suben al repositorio; la carpeta build/, no.

Si vienes de Java: es la misma herramienta

Gradle y Maven sirven igual para Java que para Kotlin, y un proyecto puede mezclar los dos lenguajes: basta con poner los .java en src/main/java. Si ya hiciste un JAR ejecutable en Java o pruebas con JUnit, aquí todo te resultará familiar: kotlin.test se ejecuta sobre el mismo JUnit 5.

Resumen de la lección

  • ParaEscribeRecuerda
  • Ejecutar las pruebasgradle testuseJUnitPlatform() en tasks.test
  • Ejecutar el programagradle run --args="..."-q para ver solo su salida
  • Compilar, probar y empaquetargradle buildsi falla una prueba, no hay paquete
  • Una carpeta lista para copiargradle installDisten build/install/
  • Fijar la versión de Gradlegradle wrapperluego, siempre ./gradlew
  • Una bibliotecaimplementation("grupo:nombre:versión")testImplementation si es solo para pruebas
  • Comparar dineroBigDecimal== compara también la escala

Retos

Reto 1. Añade a Resumen.kt una función masCaro(gastos: List<Gasto>): Gasto? que devuelva el gasto más grande, o null si la lista está vacía.

Ver la solución
fun masCaro(gastos: List<Gasto>): Gasto? = gastos.maxByOrNull { it.importe }
salidaGasto(fecha=2026-10-15, categoria=Hogar, importe=1899.00)
null

maxByOrNull ya devuelve null con una lista vacía, y el tipo Gasto? obliga a quien la llame a tener en cuenta ese caso (lección 4). Para probarla aquí, metimos en un solo archivo la data class y una versión corta de leerGastos.

Reto 2. Escribe una prueba que compruebe que un mes sin gastos suma cero. Pista: ¿con qué BigDecimal hay que comparar, si sumOf de una lista vacía devuelve BigDecimal.ZERO?

Ver la solución
package gastos

import java.math.BigDecimal
import java.time.LocalDate
import kotlin.test.Test
import kotlin.test.assertEquals

class MesVacioTest {
    @Test
    fun `un mes sin gastos suma cero`() {
        val gastos = listOf(Gasto(LocalDate.of(2026, 10, 3), "Ocio", BigDecimal("15.00")))
        assertEquals(BigDecimal.ZERO, totalDelMes(gastos, 2026, 11))
    }
}

Está en el proyecto, como un segundo archivo de pruebas, y es la quinta prueba que pasó en gradle test: MesVacioTest > un mes sin gastos suma cero() PASSED. Comparar con BigDecimal("0.00") fallaría por la escala: BigDecimal.ZERO tiene cero decimales.

Reto 3. Calcula el gasto promedio de cada categoría, redondeado a dos decimales. BigDecimal no divide como un Double: busca qué hace falta.

Ver la solución
import java.math.RoundingMode

fun promedios(gastos: List<Gasto>): Map<String, BigDecimal> =
    gastos.groupBy { it.categoria }.mapValues { (_, lista) ->
        lista.sumOf { it.importe }.divide(BigDecimal(lista.size), 2, RoundingMode.HALF_UP)
    }
salida{Súper=901.02, Ocio=450.00}

Hay que decir cuántos decimales y cómo redondear: 2703.05 / 3 no tiene fin, y un divide sin esos dos datos lanza ArithmeticException. mapValues conserva las claves del mapa y transforma solo los valores.

Terminaste el curso

En ocho lecciones pasaste de un println a un proyecto con pruebas: variables y funciones, colecciones, null safety, clases y data classes, lambdas y funciones de extensión, corrutinas y Gradle. Para comprobar lo que aprendiste, haz el test de Kotlin; para lo que puede sorprenderte, las rarezas de Kotlin. Y para seguir practicando, en los ejemplos de Kotlin hay tareas resueltas, como un servidor web con Ktor, una base de datos SQLite o corrutinas en paralelo. Si tu meta es Android, el siguiente paso es Android Studio, que crea proyectos con esta misma estructura de Gradle.

El proyecto de esta lección se compiló, probó e instaló con Gradle 9.7.0 y Kotlin 2.4.21 sobre Temurin 25.0.5, en un contenedor sin red (Gradle en modo --offline, con las dependencias descargadas antes); los fragmentos sueltos, con kotlinc. Las salidas están copiadas de esa ejecución; de las de Gradle solo quitamos la duración del build y una sugerencia sobre la configuration cache.