Data-Driven Testing
⏱ Dedicación recomendada: 0 minutos
Esto considera el contenido visible y relevante, e ignora texto colapsado o marcado como opcional.
r8vnhill/testing-kt
Puedes ejecutar el siguiente comando para crear el módulo
./gradlew setupDdtModule
Mientras se crean los archivos necesarios, puedes leer el código para saber qué está pasando.
import tasks.ModuleSetupTask
tasks.register<ModuleSetupTask>("setupDdtModule") {
description = "Creates the base module and files for the data driven testing lesson"
module.set("ddt")
doLast {
}
}
Preocúpate de que el plugin ddt
esté aplicado en el archivo build.gradle.kts
de tu proyecto.
./gradlew setupDdtModule
Preocúpate de que el nuevo módulo esté incluido en el archivo settings.gradle.kts
.
En el desarrollo de bibliotecas de software, es fundamental asegurar que nuestras funciones y métodos funcionen correctamente con una variedad de entradas. Esto garantiza que otrxs desarrolladorxs puedan confiar en nuestra biblioteca para manejar casos diversos sin errores.
Sin embargo, escribir pruebas unitarias para cada posible entrada puede volverse tedioso y propenso a errores. Aquí es donde las pruebas basadas en datos (Data-Driven Testing, DDT), pruebas parametrizadas o pruebas de tabla (table-driven testing) pueden ser útiles. Estas técnicas permiten ejecutar una prueba con múltiples conjuntos de datos, lo que facilita la validación de diferentes escenarios con un solo caso de prueba.
En esta lección, exploraremos cómo implementar Data-Driven Testing utilizando Kotest en Kotlin, para mejorar la eficiencia y la calidad de nuestras pruebas.
Data-Driven Testing
El Data-Driven Testing es una técnica que separa los datos de prueba (entradas y salidas esperadas) de la lógica de prueba. En lugar de escribir una prueba individual para cada caso, podemos escribir una sola prueba que se ejecuta múltiples veces con diferentes datos.
Caso de Estudio: Biblioteca de Validación de Contraseñas
Supongamos que estamos desarrollando una biblioteca de software para validar contraseñas según ciertas políticas de seguridad. Nuestra función isValidPassword
debe verificar si una contraseña cumple con los siguientes criterios:
- Longitud mínima: Al menos 8 caracteres.
- Contiene números: Debe incluir al menos un dígito.
- Contiene letras mayúsculas y minúsculas: Debe incluir ambas.
- Contiene caracteres especiales: Debe incluir al menos un carácter especial (e.g.,
!@#\$%^&*
).
Queremos asegurarnos de que nuestra función se comporte correctamente con una variedad de contraseñas válidas e inválidas.
Especificación BDD
Podemos expresar los criterios de validación de contraseñas utilizando una especificación BDD:
"Given a password" - {
"when validating it" - {
"then it should return true for a strong password" {}
"then it should return false for a weak password" - {
"if it has less than 8 characters" {}
"if it has no uppercase letter" {}
"if it has no lowercase letter" {}
"if it has no digits" {}
"if it has no special characters" {}
}
}
}
Implementación de las pruebas
- Código esencial
- Código completo
isValid("P@ssw0rd").shouldBeTrue()
isValid("P@ssw0r").shouldBeFalse()
isValid("p@ssw0rd").shouldBeFalse()
isValid("P@SSW0RD").shouldBeFalse()
isValid("P@ssword").shouldBeFalse()
isValid("Password1").shouldBeFalse()
package com.github.username.password
import com.github.username.password.PasswordValidator.isValid
import io.kotest.core.spec.style.FreeSpec
import io.kotest.matchers.booleans.shouldBeFalse
import io.kotest.matchers.booleans.shouldBeTrue
class PasswordValidatorTest : FreeSpec({
"Given a password" - {
"when validating it" - {
"then it should return true for a strong password" {
isValid("P@ssw0rd").shouldBeTrue()
}
"then it should return false for a weak password" - {
"if it has less than 8 characters" {
isValid("P@ssw0r").shouldBeFalse()
}
"if it has no uppercase letter" {
isValid("p@ssw0rd").shouldBeFalse()
}
"if it has no lowercase letter" {
isValid("P@SSW0RD").shouldBeFalse()
}
"if it has no digits" {
isValid("P@ssword").shouldBeFalse()
}
"if it has no special characters" {
isValid("Password1").shouldBeFalse()
}
}
}
}
})
Implementación de la Función de Validación
package com.github.username.password
object PasswordValidator {
private const val SPECIAL_CHARACTERS = "!@#$%^&*()-+"
fun isValid(password: String) =
password.length >= 8 &&
password.any { it.isDigit() } &&
password.any { it.isLowerCase() } &&
password.any { it.isUpperCase() } &&
password.any { it in SPECIAL_CHARACTERS }
}
El problema con nuestras pruebas
Aunque nuestras pruebas actuales son claras y concisas, su estructura se vuelve repetitiva y difícil de mantener a medida que aumentamos los casos de prueba. Cada prueba individual introduce redundancia en la lógica y el código, lo que complica su escalabilidad. Aquí es donde DDT ofrece una solución eficiente, permitiendo que un solo conjunto de pruebas cubra múltiples escenarios de manera organizada y sin duplicación innecesaria.
Implementación manual de DDT
La implementación manual de DDT es sencilla y flexible, lo que permite crear pruebas reutilizables para distintos conjuntos de datos. Este enfoque ayuda a simplificar los casos de prueba, especialmente cuando múltiples entradas deben ser validadas de la misma manera. Después de ver este ejemplo manual, compararemos el enfoque con la solución más elegante proporcionada por Kotest, que facilita la implementación de DDT.
Crearemos una prueba para validar contraseñas individuales:
- Código esencial
- Código completo
val testCases = listOf(
"P@ssw0rd" to true,
"P@ssw0r" to false,
"p@ssw0rd" to false,
"P@SSW0RD" to false,
"P@ssword" to false,
"Password1" to false
)
testCases.forEach { (password, expected) ->
isValid(password) shouldBe expected
}
package com.github.username.password
import com.github.username.password.PasswordValidator.isValid
import io.kotest.core.spec.style.FreeSpec
import io.kotest.matchers.shouldBe
class PasswordValidatorTest : FreeSpec({
"Given a password" - {
"when validating it" - {
"then it should return true if it is strong and false if it is weak" {
val testCases = listOf(
"P@ssw0rd" to true,
"P@ssw0r" to false,
"p@ssw0rd" to false,
"P@SSW0RD" to false,
"P@ssword" to false,
"Password1" to false
)
testCases.forEach { (password, expected) ->
isValid(password) shouldBe expected
}
}
}
}
})
- Lista de Casos de Prueba: Almacenamos los casos de prueba en una lista de pares
(password, expected)
. Este patrón es común en DDT. - Iteración sobre los Casos: Usamos
forEach
para ejecutar la función de validación para cada caso y verificar el resultado.
Implementación con Kotest y withData
Kotest ofrece una forma más elegante y concisa de implementar DDT a través de la función withData
. Esta función permite definir una tabla de datos y ejecutar una lógica de prueba para cada valor, mejorando la legibilidad del código y la organización de los casos de prueba. A diferencia de la implementación manual, withData
sigue ejecutando todos los casos de prueba incluso si uno de ellos falla, lo que genera un reporte más completo con todos los fallos.
Paso 1: Agregar la Dependencia de Kotest DataTest
Primero, debemos agregar la dependencia de Kotest DataTest en nuestro catálogo de versiones.
- Código esencial
- Código completo
[libraries]
kotest-datatest = { module = "io.kotest:kotest-framework-datatest", version.ref = "kotest-framework" }
[bundles]
kotest = ["kotest-runner-junit5", "kotest-datatest"]
[versions]
kotlin = "2.1.10"
testing = "1.0.0"
detekt = "1.23.8"
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" }
kotest-datatest = { module = "io.kotest:kotest-framework-datatest", version.ref = "kotest-framework" }
[plugins]
detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
[bundles]
kotest = ["kotest-runner-junit5", "kotest-datatest"]
El uso de bundles nos permite agrupar varias dependencias relacionadas, como las herramientas de Kotest, para simplificar la gestión de dependencias. Esto evita modificaciones dispersas en los archivos de configuración y facilita la actualización o incorporación de nuevas dependencias de forma centralizada.
Paso 2: Reescribir las Pruebas con withData
- Código esencial
- Código completo
withData(
"P@ssw0rd" to true,
"P@ssw0r" to false,
"p@ssw0rd" to false,
"P@SSW0RD" to false,
"P@ssword" to false,
"Password1" to false
) { (password, expected) ->
isValid(password) shouldBe expected
}
package com.github.username.password
import com.github.username.password.PasswordValidator.isValid
import io.kotest.core.spec.style.FreeSpec
import io.kotest.datatest.withData
import io.kotest.matchers.shouldBe
class PasswordValidatorTest : FreeSpec({
"Given a password" - {
"when validating it" - {
"then it should return true if it is strong and false if it is weak" - {
withData(
"P@ssw0rd" to true,
"P@ssw0r" to false,
"p@ssw0rd" to false,
"P@SSW0RD" to false,
"P@ssword" to false,
"Password1" to false
) { (password, expected) ->
isValid(password) shouldBe expected
}
}
}
}
})
Utilizamos withData
para definir una tabla de datos con los casos de prueba y ejecutar la lógica de prueba para cada valor. Esto simplifica la estructura de las pruebas y mejora la legibilidad del código.
Ampliando las Pruebas con withData
Anidados
Para validar múltiples políticas de contraseñas (como longitudes mínimas y conjuntos de caracteres especiales), podemos usar withData
anidados en Kotest, lo que permite probar combinaciones de casos sin duplicar lógica de prueba.
Paso 1: Modificar la Función para Aceptar Parámetros
package com.github.username.password
object PasswordValidator {
private const val SPECIAL_CHARACTERS = "!@#$%^&*()-+"
fun isValid(
password: String,
minLength: Int = 8,
requireDigit: Boolean = true,
requireLowerCase: Boolean = true,
requireUpperCase: Boolean = true,
requireSpecialChar: Boolean = true
) = password.length >= minLength &&
(!requireDigit || password.any { it.isDigit() }) &&
(!requireLowerCase || password.any { it.isLowerCase() }) &&
(!requireUpperCase || password.any { it.isUpperCase() }) &&
(!requireSpecialChar || password.any { it in SPECIAL_CHARACTERS })
}
Este diseño tiene un problema de demasiados parámetros, lo que puede reducir la legibilidad y hacer que el código sea más difícil de mantener. Siguiendo los principios de código limpio, debemos limitar el número de parámetros a tres como máximo. Una alternativa sería usar un objeto de configuración o el builder pattern para encapsular las opciones de validación.
Para no desviarnos del objetivo principal de la lección, mantendremos la función como está por simplicidad.
Paso 2: Escribir Pruebas con withData
Anidados
- Código esencial
- Código completo
withData(
"@" to true, "" to false, "!" to true, "\$" to true
) { (maybeSpecialChar, containsSpecialChar) ->
withData(
"a" to true, "b" to true, "c" to true, "" to false,
) { (maybeLowerCase, containsLowerCase) ->
withData(
"A" to true, "B" to true, "C" to true, "" to false,
) { (maybeUpperCase, containsUpperCase) ->
withData(
"1" to true, "2" to true, "3" to true, "" to false,
) { (maybeDigit, containsDigit) ->
withData(1, 2, 3, 4) { minLength ->
// ...
}
}
}
}
}
package com.github.username.password
import com.github.username.password.PasswordValidator.isValid
import io.kotest.core.spec.style.FreeSpec
import io.kotest.datatest.withData
import io.kotest.matchers.shouldBe
class PasswordValidatorTest : FreeSpec({
"Given a password" - {
"when validating it" - {
"then it should return true if it is strong and false if it is weak" - {
withData(
"@" to true, "" to false, "!" to true, "\$" to true
) { (maybeSpecialChar, containsSpecialChar) ->
withData(
"a" to true, "b" to true, "c" to true, "" to false,
) { (maybeLowerCase, containsLowerCase) ->
withData(
"A" to true, "B" to true, "C" to true, "" to false,
) { (maybeUpperCase, containsUpperCase) ->
withData(
"1" to true, "2" to true, "3" to true, "" to false,
) { (maybeDigit, containsDigit) ->
withData(1, 2, 3, 4) { minLength ->
val password = "$maybeLowerCase$maybeUpperCase$maybeDigit$maybeSpecialChar"
val expected = containsLowerCase && containsUpperCase &&
containsDigit && containsSpecialChar &&
password.length >= minLength
isValid(
password,
minLength,
requireLowerCase = true,
requireUpperCase = true,
requireDigit = true,
requireSpecialChar = true
) shouldBe expected
}
}
}
}
}
}
}
}
})
- Anidamiento de
withData
: UsamoswithData
anidados para probar combinaciones de casos de prueba sin duplicar la lógica de prueba. Esto simplifica la estructura de las pruebas y mejora la legibilidad del código. - Construcción de Contraseñas: Creamos contraseñas combinando los valores de
maybeLowerCase
,maybeUpperCase
,maybeDigit
ymaybeSpecialChar
para probar diferentes escenarios.
Anidar withData
puede aumentar significativamente la cantidad de pruebas, lo que puede ser beneficioso para validar múltiples combinaciones de datos.
En este caso, estamos probando combinaciones de datos con 45 líneas de código.
Beneficios y limitaciones
Beneficios
- Reutilización de código: La separación de los datos de prueba y la lógica de prueba reduce la duplicación de código y hace que las pruebas sean más fáciles de mantener.
- Cobertura más amplia: Al poder probar múltiples casos de manera eficiente, se puede garantizar que el sistema se comporta correctamente en una gama más amplia de escenarios.
- Legibilidad mejorada: Usar estructuras como
withData
en Kotest permite que las pruebas sean más claras y concisas, lo que mejora la comprensión del código. - Eficiencia en la ejecución: Se pueden ejecutar varias combinaciones de pruebas en una sola ejecución, reduciendo el tiempo necesario para escribir y ejecutar pruebas individuales.
Limitaciones
- Mayor complejidad: Si se anidan demasiados niveles de
withData
, las pruebas pueden volverse más difíciles de entender y depurar. - Cantidad masiva de pruebas: La generación de combinaciones de pruebas puede llevar a una cantidad de casos de prueba significativamente alta, lo que puede hacer que las ejecuciones sean más lentas.
- Difícil de interpretar los errores: Si hay muchas combinaciones, puede ser más difícil identificar qué datos específicos causaron un fallo, lo que puede ralentizar la depuración.
Ejercicio: Validar Números de Teléfono
Implementa una función isValidPhoneNumber
que valide números de teléfono según las siguientes reglas:
- Debe tener entre 8 y 11 dígitos, con un + adicional opcional al principio.
- El número puede contener espacios o guiones, pero no al principio ni al final.
- No puede contener otros caracteres no numéricos.
Escribe pruebas utilizando Data-Driven Testing con Kotest para validar múltiples casos de números de teléfono válidos e inválidos. No es necesario que escribas withData
anidados.
Solución
- Código esencial
- Código completo
withData(
"12345678" to true,
"123 4567 8910" to true,
"+123-4567-8910" to true,
"-123-4567-8910" to false,
" 123-4567-8910" to false,
"123" to false,
) { (phoneNumber, expected) ->
isValidPhoneNumber(phoneNumber) shouldBe expected
}
package com.github.username.phone
import com.github.username.phone.PhoneNumberValidator.isValidPhoneNumber
import io.kotest.core.spec.style.FreeSpec
import io.kotest.datatest.withData
import io.kotest.matchers.shouldBe
class PhoneNumberValidatorTest : FreeSpec({
"Given a phone number" - {
"when validating it" - {
"then it should return true if it is valid and false if it is invalid" - {
withData(
"12345678" to true,
"123 4567 8910" to true,
"+123-4567-8910" to true,
"-123-4567-8910" to false,
" 123-4567-8910" to false,
"123" to false,
) { (phoneNumber, expected) ->
isValidPhoneNumber(phoneNumber) shouldBe expected
}
}
}
}
})
package com.github.username.phone
object PhoneNumberValidator {
fun isValidPhoneNumber(phoneNumber: String): Boolean {
if (!phoneNumber.startsWith("+") && !phoneNumber.first().isDigit())
return false
// Remove all hyphens and spaces from the number
val cleanedNumber = phoneNumber.replace("[- ]".toRegex(), "")
// Check if the number starts with + and remove it
val hasPlusPrefix = cleanedNumber.startsWith("+")
val numberWithoutPlus =
if (hasPlusPrefix) cleanedNumber.substring(1)
else cleanedNumber
// Ensure the number contains only digits
if (!numberWithoutPlus.all { it.isDigit() }) return false
// Validate the length of the number
return numberWithoutPlus.length in 8..11
}
}
Conclusiones
En esta lección sobre Data-Driven Testing (DDT) con Kotest, exploramos cómo utilizar esta técnica para escribir pruebas más eficientes, reutilizables y escalables, tanto para contraseñas como números de teléfono. A través de ejemplos prácticos y el uso de Kotest con la función withData
, vimos cómo simplificar la validación de múltiples casos de prueba sin duplicar lógica innecesariamente.
Puntos clave
- Separación de datos y lógica de prueba: DDT nos permite ejecutar una misma lógica de prueba con diferentes conjuntos de datos, reduciendo la duplicación de código y mejorando la mantenibilidad.
- Mejora de la cobertura: Al probar diferentes escenarios de manera organizada, podemos cubrir una mayor variedad de casos, garantizando un comportamiento correcto en situaciones diversas.
- Uso de
withData
: Kotest ofrece una forma elegante de implementar DDT a través dewithData
, lo que mejora la legibilidad del código y permite una ejecución más eficiente de las pruebas. - Anidamiento de
withData
: En casos más complejos, como la validación de políticas de contraseñas, el uso dewithData
anidados permite probar múltiples combinaciones de datos sin duplicar código, pero con un aumento en la complejidad y la cantidad de pruebas. - Estrategias de validación flexibles: Vimos cómo implementar validaciones sencillas y cómo adaptarlas a diferentes requerimientos utilizando Kotest, lo que facilita la ampliación de las pruebas según las necesidades.
Esta lección nos proporciona una base sólida para aplicar Data-Driven Testing en la validación de bibliotecas de software y otros proyectos donde la consistencia y la cobertura de pruebas son fundamentales.