Introducción a la automatización de pruebas
⏱ Dedicación recomendada: 0 minutos
Esto considera el contenido visible y relevante, e ignora texto colapsado o marcado como opcional.
r8vnhill/testing-kt
Si tienes gh
instalado, puedes obtener el código haciendo:
gh repo clone r8vnhill/testing-kt
cd testing-kt || exit
git checkout base
Si quieres tener tu propia copia del código, puedes hacer un fork del repositorio y clonarlo desde tu cuenta de GitHub.
gh repo fork r8vnhill/testing-kt
cd testing-kt || exit
git checkout --track origin/base
group
en gradle.properties
.group
en el archivo gradle.properties
por tu nombre de dominio.Puedes ejecutar el siguiente comando para crear el módulo
./gradlew setupIntroModule
Mientras se crean los archivos necesarios, puedes leer el código para saber qué está pasando.
Primero definiremos una tarea reutilizable para esta unidad, no te dejes intimidar por la cantidad de código, es solo una plantilla para crear módulos en el proyecto que ya viene incluido en el repositorio. Sin embargo, comprender cómo funciona puede ser útil como práctica y repaso sobre tareas de Gradle.
- Parte 1
- Parte 2
- Parte 3
- Parte 4
- Parte 5
- Parte 6
- Parte 7
- Código completo
abstract class ModuleSetupTask @Inject constructor(
private val layout: ProjectLayout
) : DefaultTask() {
init {
group = "setup"
}
}
Esta clase define una tarea personalizada que hereda de DefaultTask
, como es habitual en Gradle.
Justo después del nombre de la clase, aparece una sección que empieza con @Inject constructor(...)
. Esto indica que la tarea necesita ciertos datos para funcionar, y que Gradle se encargará de proporcionarlos automáticamente. A esto se le llama inyección de dependencias.
En este caso, lo que Gradle entrega es una instancia de ProjectLayout, un objeto que ofrece una forma segura de acceder a carpetas y archivos dentro del proyecto.
Usar ProjectLayout
en lugar de acceder directamente con project.file(...)
es importante porque mejora la compatibilidad con el configuration cache, una característica de Gradle que permite acelerar las compilaciones.
Finalmente, la línea group = "setup"
simplemente indica que esta tarea forma parte del grupo de tareas de configuración, lo cual ayuda a organizarla mejor cuando se listan las tareas con gradle tasks
.
private val capturedProjectName: String = project.name
private val capturedGroup: String = project.rootProject.group.toString()
@get:Input
abstract val module: Property<String>
@get:Internal
val main: File
get() = baseDir.resolve("src/main/kotlin")
@get:Internal
val test: File
get() = baseDir.resolve("src/test/kotlin")
private val baseDir: File
get() = layout.projectDirectory.file(module.get().replace(":", "/")).asFile
Esta sección define las propiedades que la tarea necesita para generar la estructura de un submódulo:
capturedProjectName
ycapturedGroup
almacenan el nombre del proyecto actual y del grupo raíz, respectivamente. Estos valores se capturan en el momento en que se crea la instancia de la tarea, lo cual es importante porque acceder directamente aproject.*
en tiempo de ejecución es incompatible con el configuration cache de Gradle. Al capturarlos de forma anticipada, evitamos errores futuros y mejoramos la compatibilidad.module
es una propiedad de entrada (@get:Input
). Esto significa que su valor puede cambiar entre ejecuciones, y Gradle debe tenerlo en cuenta al decidir si necesita volver a ejecutar la tarea.main
ytest
son rutas internas calculadas que apuntan a los directoriossrc/main/kotlin
ysrc/test/kotlin
dentro del módulo. Se marcan con@get:Internal
porque su valor depende de otras propiedades (module
) y no necesitan ser consideradas directamente por Gradle como entradas externas al momento de evaluar si la tarea puede ser cacheada.baseDir
calcula el directorio raíz del módulo a partir demodule
y del layout del proyecto. Se accede usandolayout.projectDirectory
, una forma segura y declarativa de trabajar con rutas en Gradle moderna, evitando los métodos imperativos comoproject.file(...)
.
En conjunto, esta configuración busca asegurar compatibilidad con el configuration cache y mantener un diseño declarativo y limpio de la tarea.
private fun createSettingsFile() {
val settingsFile: File = layout.projectDirectory.file("settings.gradle.kts").asFile
if (!settingsFile.exists()) {
settingsFile.writeText(
"""
rootProject.name = "$capturedProjectName"
include("${module.get()}")
""".trimIndent()
)
} else if (module.get() !in settingsFile.readText()) {
settingsFile.appendText("\ninclude(\"${module.get()}\")")
}
println("The module ${module.get()} was added to the settings file")
}
Esta función agrega el nuevo módulo al archivo settings.gradle.kts
, que Gradle usa para registrar todos los subproyectos del build.
Primero se obtiene una referencia segura al archivo de configuración usando layout.projectDirectory.file(...)
, que es la forma recomendada en tareas compatibles con el configuration cache.
Luego, se evalúan dos casos:
- Si el archivo no existe, se crea con el nombre del proyecto (
rootProject.name
) y elinclude(...)
correspondiente al nuevo módulo. - Si el archivo ya existe pero aún no incluye el nuevo módulo, se agrega la línea
include(...)
al final.
El valor module.get()
se refiere al nombre del módulo proporcionado como entrada a la tarea. Al final, se imprime un mensaje indicando que el módulo fue agregado al archivo.
Esta lógica garantiza que el módulo será reconocido por Gradle la próxima vez que se ejecute el build.
private fun createModuleDirectory() = baseDir.run {
when {
exists() -> printError("Directory already exists: $absolutePath")
mkdirs() -> println("Directory created: $absolutePath")
else -> printError("Failed to create directory: $absolutePath")
}
}
Creamos una función privada createModuleDirectory
que se encarga de crear el directorio del módulo. Si el directorio ya existe, se imprime un mensaje de error. Si el directorio se crea correctamente, se imprime un mensaje de éxito. Si falla la creación del directorio, se imprime un mensaje de error.
private fun createBuildFile() = baseDir.resolve("build.gradle.kts").run {
if (exists()) printError("The build file already exists")
else {
writeText("// Intentionally left blank\n")
println("The build file was created successfully")
}
}
Creamos una función privada createBuildFile
que se encarga de crear el archivo de configuración build.gradle.kts
en el directorio del módulo. Si el archivo ya existe, se imprime un mensaje de error. Si el archivo se crea correctamente, se imprime un mensaje de éxito.
fun createFiles(packageName: String, vararg files: Pair<File, String>) {
files.forEach { (dir, name) ->
val packageDir = dir.resolve("$capturedGroup/$packageName".replace(".", "/"))
val file = packageDir.resolve(name)
if (file.exists()) {
printError("The file $name already exists")
} else {
packageDir.mkdirs()
file.writeText("package $capturedGroup.$packageName\n\n")
println("The file $name was created successfully")
}
}
}
Esta función crea uno o más archivos de Kotlin dentro de un paquete específico, organizándolos según la convención de directorios basada en el nombre del grupo (capturedGroup
) y el nombre del paquete recibido como argumento.
Para cada par (dir, name)
:
- Se construye el path final resolviendo el directorio base
dir
con la estructura del paquete (capturedGroup.packageName
), reemplazando los puntos por barras para reflejar la estructura de carpetas. - Si el archivo ya existe, se imprime un mensaje de error.
- Si no existe, se crean los directorios necesarios (
mkdirs()
) y se escribe un archivo con una declaraciónpackage
adecuada en la primera línea.
Esto es útil para generar archivos fuente organizados automáticamente dentro de la estructura estándar de Kotlin.
@TaskAction
fun setupSubmodule() {
createSettingsFile()
createModuleDirectory()
createBuildFile()
}
Creamos una función setupSubmodule
que se encarga de configurar el módulo. Esta función llama a las funciones createSettingsFile
, createModuleDirectory
y createBuildFile
para agregar el módulo al archivo de configuración, crear el directorio del módulo y crear el archivo de configuración del módulo, respectivamente.
package tasks
import org.gradle.api.DefaultTask
import org.gradle.api.file.ProjectLayout
import org.gradle.api.provider.Property
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.Internal
import org.gradle.api.tasks.TaskAction
import java.io.File
import javax.inject.Inject
abstract class ModuleSetupTask @Inject constructor(
private val layout: ProjectLayout
) : DefaultTask() {
// Aquí guardamos el nombre del proyecto de forma inmutable
// en tiempo de configuración, para NO llamarlo en tiempo de ejecución.
private val capturedProjectName: String = project.name
private val capturedGroup: String = project.rootProject.group.toString()
@get:Input
abstract val module: Property<String>
@get:Internal
val main: File
get() = baseDir.resolve("src/main/kotlin")
@get:Internal
val test: File
get() = baseDir.resolve("src/test/kotlin")
private val baseDir: File
get() = layout.projectDirectory.file(module.get().replace(":", "/")).asFile
init {
group = "setup"
}
@TaskAction
fun setupSubmodule() {
createSettingsFile()
createModuleDirectory()
createBuildFile()
}
private fun createSettingsFile() {
val settingsFile: File = layout.projectDirectory.file("settings.gradle.kts").asFile
if (!settingsFile.exists()) {
settingsFile.writeText(
"""
rootProject.name = "$capturedProjectName"
include("${module.get()}")
""".trimIndent()
)
} else if (module.get() !in settingsFile.readText()) {
settingsFile.appendText("\ninclude(\"${module.get()}\")")
}
println("The module ${module.get()} was added to the settings file")
}
private fun createModuleDirectory() = baseDir.run {
when {
exists() -> printError("Directory already exists: $absolutePath")
mkdirs() -> println("Directory created: $absolutePath")
else -> printError("Failed to create directory: $absolutePath")
}
}
private fun createBuildFile() = baseDir.resolve("build.gradle.kts").run {
if (exists()) printError("The build file already exists")
else {
writeText("// Intentionally left blank\n")
println("The build file was created successfully")
}
}
fun createFiles(packageName: String, vararg files: Pair<File, String>) {
files.forEach { (dir, name) ->
val packageDir = dir.resolve("$capturedGroup/$packageName".replace(".", "/"))
val file = packageDir.resolve(name)
if (file.exists()) {
printError("The file $name already exists")
} else {
packageDir.mkdirs()
file.writeText("package $capturedGroup.$packageName\n\n")
println("The file $name was created successfully")
}
}
}
private fun printError(message: String) {
logger.error(message)
}
}
Con la tarea definida, podemos registrarla de la siguiente manera:
import tasks.ModuleSetupTask
tasks.register<ModuleSetupTask>("setupIntroModule") {
description = "Creates the base module and files for the testing introductory lesson"
module.set("intro")
doLast {
createFiles(
"intro",
test to "MyStringSpecTest.kt",
test to "MyFunSpecTest.kt",
test to "MyFreeSpecTest.kt",
test to "MyWordSpecTest.kt",
test to "MyBehaviorSpecTest.kt",
test to "MyFeatureSpecTest.kt",
)
}
}
Preocúpate de que el plugin intro
esté aplicado en el archivo build.gradle.kts
de tu proyecto.
./gradlew setupIntroModule
Preocúpate de que el nuevo módulo esté incluido en el archivo settings.gradle.kts
.
Imagina que lanzas una actualización crítica y, sin saberlo, rompes una funcionalidad clave.
La automatización de pruebas previene estos errores al verificar continuamente que el software funcione correctamente en distintas condiciones.
En esta lección, aprenderás cómo Kotest y TDD pueden ayudarte a escribir pruebas eficientes y confiables para tu código en Kotlin.
El objetivo del testing es proporcionar una validación continua de que el software:
- Cumple con los requisitos: Asegura que todas las funcionalidades esperadas estén presentes y se comporten correctamente.
- Previene la regresión: Verifica que los cambios recientes no rompan las funcionalidades ya existentes.
- Identifica errores: Facilita la detección de errores o problemas de rendimiento que puedan afectar la experiencia de lx usuarix final.
Además de verificar la funcionalidad, el testing mejora la confianza en el código, permite una evolución más rápida del software y asegura que sea más mantenible a largo plazo.
🔍 Test-Driven Development (TDD)
TDD es una metodología de desarrollo de software que prioriza la creación de pruebas antes de implementar cualquier funcionalidad. El flujo típico de TDD consiste en los siguientes pasos:
- Escribir casos de prueba: Antes de escribir el código, los requisitos del software se traducen en casos de prueba que describen cómo debería comportarse la funcionalidad.
- Desarrollar el código: Se implementa el código necesario para pasar los casos de prueba.
- Refactorizar: Una vez que las pruebas son exitosas, el código puede ser mejorado o refactorizado sin miedo a romper la funcionalidad, ya que los tests ya están en su lugar.
Este enfoque asegura que el desarrollo esté alineado con los requisitos desde el principio y ayuda a capturar errores en etapas tempranas del desarrollo, lo que mejora la calidad del software y reduce el costo de corregir fallos más adelante.
✅ Kotest: Un framework flexible para pruebas en Kotlin
Kotest es un framework de testing para Kotlin que ofrece una API flexible y expresiva para la creación de pruebas unitarias, de integración y más. Entre sus principales características se encuentran:
- API Declarativa: Kotest permite escribir pruebas de forma clara y legible, facilitando el mantenimiento y la comprensión de los casos de prueba.
- Data-Driven Testing (DDT): Permite ejecutar un mismo caso de prueba con diferentes conjuntos de datos, mejorando la cobertura de pruebas y asegurando que las funciones se comporten correctamente en distintos escenarios.
- Property-Based Testing (PBT): Genera automáticamente casos de prueba basados en propiedades definidas, verificando que las funciones cumplan con condiciones específicas bajo un amplio rango de entradas.
- Matchers Flexibles: Kotest incluye una amplia gama de matchers para realizar aserciones en pruebas, desde comparaciones simples hasta validaciones más complejas como excepciones o estructuras anidadas.
- Soporte para Coroutines: Kotest está diseñado para trabajar de manera fluida con las coroutines de Kotlin, lo que permite escribir pruebas asíncronas de manera sencilla y eficiente.
- Integración con JUnit: Kotest se integra sin problemas con JUnit, lo que facilita la adopción en proyectos existentes y permite el uso de herramientas y configuraciones ya familiares.
Kotest se integra fácilmente con otros frameworks y bibliotecas populares en el ecosistema de Kotlin, como Ktor, Spring, y Arrow, haciendo de él una herramienta robusta y versátil para cualquier tipo de prueba.
📦 Incluyendo Kotest en un Proyecto
Para comenzar a utilizar Kotest en nuestro proyecto, lo primero que haremos es agregar las dependencias necesarias en el catálogo de versiones.
- Código esencial
- Código completo
[versions]
kotest-framework = "5.9.1"
[libraries]
kotest-runner-junit5 = { module = "io.kotest:kotest-runner-junit5", version.ref = "kotest-framework" }
[bundles]
kotest = ["kotest-runner-junit5"]
[versions]
kotlin = "2.0.21"
testing = "1.0.0"
detekt = "1.23.7"
kotest-framework = "5.9.1"
[libraries]
kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detekt" }
kotest-runner-junit5 = { module = "io.kotest:kotest-runner-junit5", version.ref = "kotest-framework" }
[plugins]
detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
[bundles]
kotest = ["kotest-runner-junit5"]
En este archivo, definimos la versión de Kotest que utilizaremos (kotest-framework
) y la dependencia de JUnit 5 para Kotest (kotest-runner-junit5
). Luego, agrupamos estas dependencias en un bundle llamado kotest
, que nos permitirá añadir más dependencias de Kotest de manera sencilla en el futuro.
Ahora podemos aplicar este bundle a nuestro proyecto de Gradle:
- Código esencial
- Código completo
val kotestBundle = libs.bundles.kotest
subprojects {
dependencies {
implementation(kotestBundle)
}
}
plugins {
id("jvm.conventions")
alias(libs.plugins.detekt)
}
val projectGroup = extra["kotest-intro.group"]!! // Throws an exception if the property is not found
val projectVersion: String = libs.versions.kotest.intro.get()
val detektId: String = libs.plugins.detekt.get().pluginId
val detektFormattingModule = libs.detekt.formatting.get().module.toString()
val detektFormattingVersion = libs.detekt.formatting.get().version
val kotestBundle = libs.bundles.kotest
allprojects {
group = projectGroup
version = projectVersion
}
subprojects {
apply(plugin = "jvm.conventions")
apply(plugin = detektId)
dependencies {
detektPlugins("$detektFormattingModule:$detektFormattingVersion")
implementation(kotestBundle)
}
}
En este bloque de código, aplicamos el bundle de Kotest a todos los subproyectos de nuestro proyecto de Gradle, lo que nos permite utilizar Kotest en cualquier módulo de nuestro proyecto.
📝 Escribiendo Pruebas con Kotest
Kotest permite escribir pruebas utilizando diferentes estilos; aquí mostramos varios ejemplos:
- StringSpec
- FunSpec
- FreeSpec
- WordSpec
- BehaviorSpec
- FeatureSpec
package com.github.username.basics
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
class MyStringSpecTest : StringSpec({
"String length should be equal to the number of characters" {
val str = "Hello, World!"
str.length shouldBe 13
}
})
StringSpec
permite escribir pruebas de manera declarativa utilizando cadenas como descripciones.
package com.github.username.basics
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
class MyFunSpecTest : FunSpec({
context("String length") {
test("Should be equal to the number of characters") {
val str = "Hello, World!"
str.length shouldBe 13
}
}
})
FunSpec
permite escribir pruebas en forma de funciones, similar a otros frameworks de pruebas.
package com.github.username.basics
import io.kotest.core.spec.style.FreeSpec
import io.kotest.matchers.shouldBe
class MyFreeSpecTest : FreeSpec({
"String length" - {
"should be equal to the number of characters" {
val str = "Hello, World!"
str.length shouldBe 13
}
}
})
FreeSpec
permite estructurar las pruebas en bloques anidados, lo que da mayor flexibilidad.
package com.github.username.basics
import io.kotest.core.spec.style.WordSpec
import io.kotest.matchers.shouldBe
class MyWordSpecTest : WordSpec({
"String length" should {
"be equal to the number of characters" {
val str = "Hello, World!"
str.length shouldBe 13
}
}
})
WordSpec
sigue un estilo BDD (Behavior-Driven Development), muy útil para describir comportamientos de forma natural.
package com.github.username.basics
import io.kotest.core.spec.style.BehaviorSpec
import io.kotest.matchers.shouldBe
class MyBehaviorSpecTest : BehaviorSpec({
given("a string") {
`when`("calculating its length") {
then("it should be equal to the number of characters") {
val str = "Hello, World!"
str.length shouldBe 13
}
}
}
})
BehaviorSpec
también es un estilo BDD que permite describir escenarios complejos usando "given", "when", y "then".
package com.github.username.basics
import io.kotest.core.spec.style.FeatureSpec
import io.kotest.matchers.shouldBe
class MyFeatureSpecTest : FeatureSpec({
feature("String length") {
scenario("Should be equal to the number of characters") {
val str = "Hello, World!"
str.length shouldBe 13
}
}
})
FeatureSpec
permite organizar las pruebas en torno a características o funcionalidades específicas.
- Creamos una clase de pruebas
MyTest
que extiende una clase base de Kotest. Cada estilo (StringSpec
,FunSpec
,FreeSpec
,WordSpec
,BehaviorSpec
, etc.) ofrece diferentes formas de estructurar las pruebas, permitiéndote elegir el que más te acomode. - Definimos un bloque de prueba que describe el comportamiento esperado de una cadena de texto.
- Escribimos una aserción (
shouldBe
) para verificar que la longitud de la cadena sea la esperada.
Finalmente, podemos ejecutar las pruebas con Gradle desde la terminal:
./gradlew test
Estilo | Uso recomendado |
---|---|
StringSpec | Pruebas rápidas y concisas. |
FunSpec | Similar a JUnit, más estructurado. |
FreeSpec | Permite anidamiento flexible de pruebas. |
WordSpec | Mejor para pruebas estilo BDD. |
BehaviorSpec | Definir pruebas en términos de Given-When-Then. |
FeatureSpec | Organizar pruebas por características del sistema. |
El IDE recomendado para trabajar con Kotest es IntelliJ IDEA, que ofrece un plugin oficial para ejecutar y depurar pruebas de Kotest de manera sencilla. Puedes instalarlo desde el Marketplace de IntelliJ IDEA.
Puedes encontrar más información en la documentación oficial de Kotest.
🏁 Conclusiones
A lo largo de esta lección, exploramos la importancia de la automatización de pruebas en el desarrollo de software y cómo herramientas como Kotest y TDD pueden mejorar la calidad y confiabilidad del código.
🔑 Puntos clave
✅ Automatización de pruebas: Permite detectar errores antes de que lleguen a producción, asegurando que el software funcione correctamente en distintas condiciones.
✅ TDD (Test-Driven Development): Fomenta la escritura de pruebas antes del código, asegurando que cada funcionalidad cumpla con los requisitos desde su concepción.
✅ Kotest: Un framework flexible y expresivo para Kotlin que ofrece múltiples estilos de pruebas, soporte para data-driven testing y property-based testing, así como integración con JUnit y otras herramientas populares.
✅ Estrategias de testing: Aprendimos a escribir pruebas con diferentes enfoques (BDD, unitarias, de integración, etc.), organizando el código de manera modular y reutilizable.
✅ Configuración en Gradle: Implementamos Kotest en un proyecto de Kotlin, gestionando sus dependencias mediante un bundle de Gradle para facilitar su mantenimiento.
Bibliografías Recomendadas
- 📚 "The Basics of Unit Testing". (2014). Osherove, Roy, en The Art of Unit Testing: With Examples in C#, (2. ed., pp. 3–18.) Manning.
- 🌐 "Testing Styles | Kotest." Accedido: 15 de septiembre de 2024. [En línea]. Disponible en: https://kotest.io/docs/framework/testing-styles.html
- 🌐 "Test-Driven Development (TDD) Explained." [En línea]. Disponible en: https://circleci.com/blog/test-driven-development-tdd/