Skip to main content

Caso de estudio: Máximo de una lista

⏱ Dedicación recomendada: 0 minutos
Esto considera el contenido visible y relevante, e ignora texto colapsado o marcado como opcional.


r8vnhill/testing-kt

Be lazy...

Puedes ejecutar el siguiente comando para crear el módulo

./gradlew setupBiggestModule

Mientras se crean los archivos necesarios, puedes leer el código para saber qué está pasando.

convention-plugins/src/main/kotlin/biggest.gradle.kts
import tasks.ModuleSetupTask

tasks.register<ModuleSetupTask>("setupBiggestModule") {
description = "Creates the base module and files for the testing introductory lesson"
module.set("pbt:biggest")
doLast {
createFiles(
"biggest",
main to "Biggest.kt",
test to "BiggestTest.kt",
)
}
}

Preocúpate de que el plugin biggest esté aplicado en el archivo build.gradle.kts de tu proyecto.

./gradlew setupBiggestModule

Preocúpate de que el nuevo módulo esté incluido en el archivo settings.gradle.kts.

Otro ejemplo es encontrar el máximo de una lista de enteros. La dificultad aquí radica en definir una propiedad que no coincida exactamente con nuestra implementación, pero que siga validando su correctitud. Una forma eficaz de definir estas propiedades es pensar en implementaciones alternativas, que aunque más verbosas o ineficientes, nos ayuden a verificar el comportamiento de la función.

Por ejemplo, obtener el máximo de una lista puede lograrse en O(n)O(n) mediante una búsqueda secuencial. Una implementación equivalente, aunque menos eficiente, sería ordenar la lista en orden creciente en O(nlogn)O(n \log n) y luego tomar el último elemento en O(1)O(1).

Test

Siguiendo el enfoque BDD, comenzamos declarando la estructura básica de nuestro test.

"Given an integer list" - {
"when getting the biggest element" - {
"should return the last element of the sorted list" {}
}
}

A continuación, definimos la propiedad que queremos verificar, utilizando generadores para listas de enteros.

checkAll(Arb.list(Arb.int())) { list ->
biggest(list) shouldBe list.sorted().last()
}
¿Qué acabamos de hacer?
  • Utilizamos Arb.list(Arb.int()) para generar listas de enteros arbitrarias, lo que nos permite probar diferentes escenarios automáticamente.
  • Comprobamos que biggest(list) devuelva el mismo valor que el último elemento de la lista ordenada.

Implementación

La siguiente función biggest busca el mayor valor dentro de una lista de enteros. Utiliza un enfoque iterativo para recorrer la lista y actualizar el valor máximo encontrado hasta el momento.

pbt/biggest/src/main/kotlin/com/github/username/biggest/Biggest.kt
package com.github.username.biggest

fun biggest(list: List<Int>): Int {
var biggest = list[0]
for (number in list) {
if (number > biggest) {
biggest = number
}
}
return biggest
}

Ejecución

Si ahora ejecutamos los tests con ./gradlew test, deberíamos ver un error como el siguiente:

Property failed after 76 attempts

Arg 0: []

Repeat this test by using seed 6060908536876112051
¿Qué acabamos de hacer?
  • [1]: La propiedad falla después de 76 intentos, lo que indica que hay un problema con nuestra implementación.
  • [3]: El argumento con el que falló la propiedad es una lista vacía.
  • [5]: La semilla usada para generar los valores aleatorios. Podemos repetir la prueba con esta semilla para depurar el error.

Corrigiendo el test

Para solucionar el problema en nuestro test, es necesario segmentar el espacio de búsqueda de manera más precisa. Debemos considerar dos casos distintos:

  1. Lista vacía: Cuando la lista no contiene elementos, la función debería manejar este caso de manera adecuada.
  2. Lista no vacía: Cuando la lista tiene al menos un elemento, la función debería devolver el mayor de ellos.
"when getting the biggest element of a non-empty list" - {
"should return the last element of the sorted list" {
checkAll(Arb.list(Arb.int(), 1..100)) { list ->
biggest(list) shouldBe list.sorted().last()
}
}
}

"when getting the biggest element of an empty list" - {
"should return the smallest integer" {
biggest(emptyList()) shouldBe Int.MIN_VALUE
}
}
¿Qué acabamos de hacer?
  • Caso no vacío: Generamos listas con al menos un elemento utilizando Arb.list(Arb.int(), 1..100) y verificamos que el resultado de biggest(list) coincida con el último elemento de la lista ordenada.
  • Caso vacío: Cuando la lista está vacía, verificamos que la función biggest(emptyList()) devuelva Int.MIN_VALUE, manejando así correctamente la lista vacía.

Implementación corregida

Para manejar el caso de una lista vacía, podemos devolver Int.MIN_VALUE como valor predeterminado. De esta manera, si la lista está vacía, el resultado será el menor valor posible para un entero.

pbt/biggest/src/main/kotlin/com/github/username/biggest/Biggest.kt
package com.github.username.biggest

fun biggest(list: List<Int>) = if (list.isEmpty()) {
Int.MIN_VALUE
} else {
var biggest = list[0]
for (number in list) {
if (number > biggest) {
biggest = number
}
}
biggest
}

Si ejecutamos nuevamente los tests con ./gradlew test, deberíamos ver que todos los tests pasan correctamente.

Ejercicio

Escribe un test basado en propiedades para una función reverse: (List<Int>) -> List<Int> que toma una lista y retorna la misma lista en orden inverso.

No es necesario implementar la función, sólo el test.

Ver hint
  • Utiliza dos listas y verifica el resultado de invertir la concatenación
Solución
class ReverseTest : FreeSpec({
"Given two integer lists" - {
"when reversing the concatenation of the lists" - {
("should return the reverse of the second list" +
"concatenated with the reverse of the first list") {
checkAll(
Arb.list(Arb.int()),
Arb.list(Arb.int())
) { list1, list2 ->
reverse(list1 + list2) shouldBe
(reverse(list2) + reverse(list1))
}
}
}
}
})
Ejercicio

Diseña propiedades para probar la función capitalizeWords: (String) -> String que toma una cadena y capitaliza la primera letra de cada palabra utilizando PBT.

Las propiedades por probar son:

  • El número de palabras no cambia.
  • Idempotencia (aplicar la función dos veces no cambia el resultado después de la primera aplicación)
  • Sólo la primera letra de cada palabra debe estar en mayúsculas (no consideres palabras vacías)

Utiliza Arb.string(0..100, Codepoint.az()) para generar los strings a probar.

No es necesario implementar la función, sólo los tests.

Ver hints
  • Utiliza la función split: String.(String) -> List<String> para dividir la cadena en palabras.
  • Puedes utilizar la función count: Iterable<T>.((T) -> Boolean) -> Int para contar el número de palabras en una lista. Para contar el número de palabras puedes contar la cantidad de elementos no vacíos haciendo .count { it.isNotEmpty() }.
  • Puedes utilizar la función isUpperCase: Char.() -> Boolean para verificar si la primera letra de una palabra está en mayúsculas.
  • Puedes utilizar la función isLowerCase: Char.() -> Boolean para verificar si el resto de las letras de una palabra están en minúsculas.
Solución
class CapitalizeTest : FreeSpec({
"A String" - {
"when capitalizing its words" - {
"should not change the number of words" {
checkAll(Arb.string(1..100, Codepoint.az())) { string ->
countWords(string) shouldBe countWords(capitalizeWords(string))
}
}

"should be idempotent" {
checkAll(Arb.string(1..100, Codepoint.az())) { string ->
capitalizeWords(capitalizeWords(string)) shouldBe capitalizeWords(string)
}
}

"should contain only words with the first letter capitalized" {
checkAll(Arb.string(1..100, Codepoint.az())) { string ->
for (word in capitalizeWords(string).split(" ")) {
word.first().isUpperCase().shouldBeTrue()
for (i in 1..<word.length) {
word[i].isLowerCase().shouldBeTrue()
}
}
}
}
}
}
})

private fun countWords(string: String) =
string.split(" ")
.count { it.isNotBlank() }

¿Qué aprendimos?

En esta lección, exploramos cómo utilizar Property-Based Testing (PBT) para validar funciones, específicamente aquellas que operan sobre colecciones como listas. Además, corregimos errores comunes en los tests y mejoramos la implementación de nuestras funciones para manejar adecuadamente casos límite, como listas vacías.

Puntos clave

  • Segmentación de pruebas: Es importante manejar casos especiales como listas vacías, asegurándonos de que nuestra función implemente una solución adecuada para cada escenario.
  • Uso de generadores arbitrarios: Al utilizar generadores como Arb.list(Arb.int()), podemos automatizar las pruebas para múltiples escenarios sin necesidad de escribir cada caso manualmente.
  • Validación de propiedades: Aseguramos que el comportamiento de la función bajo prueba coincida con una implementación alternativa que, aunque menos eficiente, garantiza la corrección.

El enfoque con Property-Based Testing nos ofrece una forma poderosa y escalable para garantizar la fiabilidad de nuestras implementaciones, especialmente cuando trabajamos con grandes conjuntos de datos o casos límite difíciles de prever con pruebas manuales.

Bibliografías Recomendadas

  • 📚 "Writing Properties". (2019). Fred Hébert, en Property-based testing with PropEr, Erlang, and Elixir: Find bugs before your users do, (pp. 17-32.) The Pragmatic Bookshelf.