Una kata TDD (incluye código) en Rust

En el vibrante mundo del desarrollo de software, la creación de código robusto, mantenible y fiable es una prioridad ineludible. Entre las metodologías que nos asisten en esta misión, el Desarrollo Orientado a Pruebas (TDD, por sus siglas en inglés) se erige como una de las más potentes. Pero, ¿qué sucede cuando combinamos la disciplina de TDD con la potencia y seguridad sin concesiones de Rust? El resultado es una sinergia que puede transformar radicalmente la forma en que construimos nuestras aplicaciones. Este post no solo explorará los fundamentos de una kata TDD, sino que también nos sumergiremos en una implementación práctica utilizando Rust, desvelando cómo este binomio puede elevar la calidad de nuestro código a nuevos niveles. Prepárense para un viaje donde la seguridad en tiempo de compilación de Rust se une a la seguridad en tiempo de ejecución que nos brindan unas pruebas bien diseñadas.

¿Qué es una kata de código y por qué en Rust?

grey concrete wall

Antes de sumergirnos en el código, es fundamental comprender qué significa embarcarse en una "kata de código" y por qué Rust es un compañero excepcional para esta práctica.

¿Qué es una kata de código?

El término "kata" proviene de las artes marciales japonesas, donde se refiere a una secuencia de movimientos o ejercicios que se practican repetidamente para perfeccionar una técnica. En el contexto del desarrollo de software, una "kata de código" es precisamente eso: un ejercicio de programación pequeño y autocontenido que se realiza una y otra vez para mejorar habilidades específicas. El objetivo no es tanto resolver el problema de la manera más eficiente o elegante la primera vez, sino más bien internalizar un proceso, ya sea el TDD, la refactorización, el uso de un nuevo lenguaje o paradigma, o la aplicación de patrones de diseño.

Las katas de código son una herramienta invaluable para:

  • Aprender y practicar TDD: Permiten experimentar el ciclo "Rojo-Verde-Refactorizar" en un entorno de bajo riesgo.
  • Explorar un nuevo lenguaje: Ayudan a familiarizarse con la sintaxis, las bibliotecas estándar y las particularidades de un lenguaje (como el sistema de propiedad de Rust).
  • Mejorar habilidades de refactorización: Ofrecen oportunidades para reorganizar el código sin miedo, sabiendo que las pruebas están ahí para salvaguardar la funcionalidad.
  • Desarrollar un "sentido" para el diseño de software: Al resolver el mismo problema de diferentes maneras, se empieza a discernir qué enfoques conducen a un código más limpio y modular.

Ventajas de TDD con Rust

La combinación de TDD y Rust es, en mi opinión, una de las parejas más poderosas en el desarrollo moderno. Rust, con su enfoque en la seguridad, el rendimiento y la concurrencia, ya ofrece una base sólida para construir software de alta calidad. Cuando se integra TDD, estos beneficios se magnifican:

  • Retroalimentación instantánea del compilador: Rust tiene un compilador notoriamente estricto. Si bien esto puede ser frustrante al principio, en el contexto de TDD, se convierte en un aliado formidable. Los errores que otras lenguas permitirían en tiempo de ejecución, Rust los detecta en tiempo de compilación. Las pruebas unitarias de TDD complementan esto, verificando la lógica de negocio que el compilador no puede inferir. Es como tener dos niveles de validación constantes.
  • Refactorización con confianza: El sistema de tipos robusto de Rust y su modelo de propiedad (ownership) significan que, una vez que el código compila y las pruebas pasan, la probabilidad de introducir errores durante la refactorización se reduce drásticamente. Las pruebas unitarias garantizan la corrección funcional, mientras que el compilador asegura la corrección estructural y la seguridad de la memoria. Esto permite realizar cambios profundos con una confianza que es difícil de replicar en otros lenguajes.
  • Diseño más limpio y modular: TDD fomenta la escritura de funciones pequeñas, con responsabilidades claras y bien definidas, porque es más fácil probar unidades de código aisladas. Esta filosofía se alinea perfectamente con la mentalidad de Rust de construir componentes componibles y eficientes.
  • Manejo de errores explícito: Rust promueve el manejo explícito de errores a través de los tipos Result y Option. Las katas TDD brindan un excelente campo de juego para practicar cómo escribir pruebas que validen tanto los caminos de éxito como los de error, asegurando que el programa se comporte de manera predecible en todas las circunstancias.
  • Framework de pruebas integrado: Rust viene con un framework de pruebas incorporado (gracias a Cargo), lo que facilita enormemente la escritura y ejecución de pruebas. No hay necesidad de dependencias externas complejas para empezar con TDD.

Preparando el entorno para nuestra kata

Antes de escribir la primera línea de código de nuestra kata, necesitamos asegurarnos de que nuestro entorno de desarrollo de Rust esté configurado correctamente.

Lo primero y más importante es tener Rust instalado. La forma recomendada de instalar Rust es a través de rustup, una herramienta que gestiona las versiones de Rust y las herramientas asociadas. Si aún no lo tienes, puedes seguir las instrucciones en el sitio oficial de Rust: Instalar Rust con rustup.

Una vez que rustup esté instalado, puedes verificar tu instalación ejecutando rustc --version y cargo --version en tu terminal. Cargo es el gestor de paquetes y el sistema de construcción de Rust, y será nuestra herramienta principal para crear el proyecto y ejecutar las pruebas.

Para iniciar nuestra kata, crearemos un nuevo proyecto de biblioteca de Rust. Una biblioteca es ideal para este tipo de ejercicios, ya que nos permite enfocarnos en la lógica de negocio sin preocuparnos por una estructura de aplicación más grande.

cargo new --lib string_calculator_kata
cd string_calculator_kata

Esto creará una nueva carpeta llamada string_calculator_kata con la siguiente estructura básica:

string_calculator_kata/
├── Cargo.toml
└── src/
    └── lib.rs

El archivo Cargo.toml contiene los metadatos de nuestro proyecto y sus dependencias, mientras que src/lib.rs es donde escribiremos nuestro código principal y nuestras pruebas.

La kata: Calculadora de cadenas (String calculator)

Para nuestra kata, utilizaremos un clásico del TDD: la "Calculadora de cadenas" (String Calculator). Esta kata, popularizada por Roy Osherove, es excelente para practicar el ciclo TDD y explorar cómo agregar funcionalidad incrementalmente mientras se mantiene un código limpio y probado.

Definición del problema

El objetivo es crear una función add que acepte una cadena de texto como entrada y devuelva la suma de los números que contiene. Las reglas se introducirán de forma iterativa, tal como lo haríamos en un desarrollo real guiado por TDD.

Las reglas básicas son:

  1. Una cadena vacía debe devolver 0.
  2. Un solo número en la cadena debe devolver ese número.
  3. Dos números separados por una coma deben devolver su suma.
  4. La función add debe permitir manejar tanto comas como saltos de línea como delimitadores.
  5. Los números negativos no están permitidos. Si aparecen, la función debe lanzar una excepción (o en Rust, retornar un Result::Err o hacer panic!) indicando todos los números negativos encontrados.
  6. Los números mayores de 1000 deben ser ignorados (por ejemplo, "1001,2" debería devolver 2).
  7. Soporte para delimitadores personalizados. La cadena puede comenzar con "//[delimitador]\n" para definir un delimitador de un solo carácter. Por ejemplo, "//;\n1;2" debe devolver 3.

Como desarrolladores que adoptan el TDD, no implementaremos todas estas reglas de golpe. En su lugar, abordaremos cada regla con el ciclo "Rojo-Verde-Refactorizar".

Aplicando TDD paso a paso

Ahora, entremos en el corazón de nuestra kata. Abriremos el archivo src/lib.rs y comenzaremos a implementar nuestra función add y sus pruebas.

// src/lib.rs

// Definimos la función add aquí
pub fn add(numbers: &str) -> i32 {
    // Implementación inicial muy básica para pasar el primer test
    0
}

#[cfg(test)]
mod tests {
    use super::*;

    // Aquí irán nuestras pruebas
}

Paso 1: Cadena vacía

Rojo (Escribir una prueba que falle): El requisito es que una cadena vacía devuelva 0.

// En src/lib.rs, dentro de mod tests { ... }
#[test]
fn empty_string_should_return_zero() {
    assert_eq!(add(""), 0);
}

Ahora, ejecuta las pruebas con cargo test. Debería fallar, porque nuestra función add actual siempre devuelve 0, lo cual pasa la prueba, pero esto es un falso positivo porque la lógica no está completa. Vamos a simular que la función aún no existe o devuelve otra cosa para ver el rojo. Pero como hemos puesto un 0 en la implementación, este test pasaría. La clave es que la implementación sólo haga lo mínimo para pasar el test.

Verde (Hacer que la prueba pase con la mínima implementación): Nuestra implementación actual ya hace esto.

// En src/lib.rs
pub fn add(numbers: &str) -> i32 {
    0 // Implementación mínima para pasar la prueba de cadena vacía
}

cargo test ahora pasaría.

Refactorizar: En este punto, no hay mucho que refactorizar. El código es demasiado simple.

Paso 2: Un número

Rojo: Un solo número en la cadena debe devolver ese número.

// En src/lib.rs, dentro de mod tests { ... }
#[test]
fn single_number_should_return_itself() {
    assert_eq!(add("1"), 1);
    assert_eq!(add("5"), 5);
}

cargo test debería fallar ahora, ya que add("1") devolvería 0 en nuestra implementación actual.

Verde: Necesitamos parsear la cadena para obtener el número.

// En src/lib.rs
pub fn add(numbers: &str) -> i32 {
    if numbers.is_empty() {
        0
    } else {
        numbers.parse().unwrap_or(0) // Usamos unwrap_or(0) para simplificar por ahora
    }
}

cargo test ahora debería pasar.

Refactorizar: El unwrap_or(0) no es ideal si la cadena no es un número válido. Para esta kata, asumiremos entradas válidas, pero en un contexto real, manejaríamos el Result adecuadamente. Aquí no hay refactorización de estructura significativa todavía.

Paso 3: Dos números (delimitador coma)

Rojo: Dos números separados por una coma deben devolver su suma.

// En src/lib.rs, dentro de mod tests { ... }
#[test]
fn two_numbers_comma_separated_should_return_sum() {
    assert_eq!(add("1,2"), 3);
    assert_eq!(add("5,7"), 12);
}

cargo test debería fallar. Nuestra función solo puede manejar un número.

Verde: Necesitamos dividir la cadena por la coma, parsear cada parte y sumar.

// En src/lib.rs
pub fn add(numbers: &str) -> i32 {
    if numbers.is_empty() {
        0
    } else {
        numbers
            .split(',') // Dividimos por la coma
            .map(|s| s.parse::<i32>().unwrap_or(0)) // Parseamos cada parte a i32
            .sum() // Sumamos los resultados
    }
}

cargo test debería pasar ahora.

Refactorizar: La función add está creciendo. Podríamos extraer la lógica de parsing y suma en una función auxiliar si fuera más compleja. Por ahora, es manejable. Una pequeña mejora podría ser asegurar que parse no falle silenciosamente con unwrap_or(0) si tuviéramos que manejar errores más rigurosamente, pero para la kata está bien.

Paso 4: Delimitadores múltiples (coma y salto de línea)

Rojo: La función add debe permitir manejar tanto comas como saltos de línea como delimitadores.

// En src/lib.rs, dentro de mod tests { ... }
#[test]
fn numbers_with_newlines_and_commas_should_sum() {
    assert_eq!(add("1\n2,3"), 6);
    assert_eq!(add("1\n2\n3"), 6);
    assert_eq!(add("1,2\n3"), 6);
}

cargo test fallará, ya que solo dividimos por comas.

Verde: Podemos usar split con un cierre para manejar múltiples delimitadores.

// En src/lib.rs
pub fn add(numbers: &str) -> i32 {
    if numbers.is_empty() {
        0
    } else {
        numbers
            .split(|c| c == ',' || c == '\n') // Dividimos por coma o salto de línea
            .map(|s| s.parse::<i32>().unwrap_or(0))
            .sum()
    }
}

cargo test ahora debería pasar.

Refactorizar: El código sigue siendo conciso y legible. No hay refactorizaciones obvias en este momento.

Paso 5: Números negativos (excepción)

Rojo: Los números negativos no están permitidos. Si aparecen, la función debe lanzar una excepción (o hacer panic!) indicando todos los números negativos encontrados.

// En src/lib.rs, dentro de mod tests { ... }
#[test]
#[should_panic(expected = "Negative numbers not allowed: -1, -5")]
fn negative_numbers_should_panic_with_message() {
    add("1,-1,2,-5");
}

#[test]
#[should_panic(expected = "Negative numbers not allowed: -1")]
fn single_negative_number_should_panic() {
    add("-1");
}

cargo test fallará porque nuestra función simplemente sumaría los negativos.

Verde: Necesitamos iterar sobre los números, recolectar los negativos y, si hay alguno, panic!.

// En src/lib.rs
pub fn add(numbers: &str) -> i32 {
    if numbers.is_empty() {
        return 0;
    }

    let parsed_numbers: Vec<i32> = numbers
        .split(|c| c == ',' || c == '\n')
        .map(|s| s.parse::<i32>().unwrap_or(0))
        .collect(); // Recolectamos los números parseados

    let negative_numbers: Vec<i32> = parsed_numbers
        .iter()
        .filter(|&&n| n < 0)
        .cloned() // Clona los i32 para recolectarlos en un Vec
        .collect();

    if !negative_numbers.is_empty() {
        let neg_str = negative_numbers
            .iter()
            .map(|n| n.to_string())
            .collect::<Vec<String>>()
            .join(", ");
        panic!("Negative numbers not allowed: {}", neg_str);
    }

    parsed_numbers.iter().sum() // Sumamos los números (ahora sin negativos, o paniqueamos antes)
}

cargo test debería pasar ahora. Este es un buen ejemplo de cómo Rust nos permite construir lógica compleja de forma declarativa.

Refactorizar: La lógica de recolectar y luego sumar, más la verificación de negativos, está bien organizada. Es un buen punto para considerar que parsed_numbers se utiliza dos veces, pero el collect es necesario para la comprobación de negativos antes de la suma final.

Paso 6: Ignorar números mayores de 1000

Rojo: Los números mayores de 1000 deben ser ignorados.

// En src/lib.rs, dentro de mod tests { ... }
#[test]
fn numbers_greater_than_1000_should_be_ignored() {
    assert_eq!(add("1001,2"), 2);
    assert_eq!(add("1000,2"), 1002); // 1000 no se ignora
    assert_eq!(add("1000,1001,2"), 1002);
}

cargo test fallará.

Verde: Modificamos la iteración para filtrar los números mayores de 1000 antes de la suma.

// En src/lib.rs
pub fn add(numbers: &str) -> i32 {
    if numbers.is_empty() {
        return 0;
    }

    let parsed_numbers: Vec<i32> = numbers
        .split(|c| c == ',' || c == '\n')
        .map(|s| s.parse::<i32>().unwrap_or(0))
        .collect();

    let negative_numbers: Vec<i32> = parsed_numbers
        .iter()
        .filter(|&&n| n < 0)
        .cloned()
        .collect();

    if !negative_numbers.is_empty() {
        let neg_str = negative_numbers
            .iter()
            .map(|n| n.to_string())
            .collect::<Vec<String>>()
            .join(", ");
        panic!("Negative numbers not allowed: {}", neg_str);
    }

    parsed_numbers
        .iter()
        .filter(|&&n| n <= 1000) // Filtramos los números mayores de 1000
        .sum()
}

cargo test debería pasar.

Refactorizar: La cadena de métodos de iterador es bastante expresiva en Rust. La lógica está clara.

Paso 7: Delimitadores personalizados (opcional)

Rojo: Soporte para delimitadores personalizados. La cadena puede comenzar con "//[delimitador]\n" para definir un delimitador de un solo carácter.

// En src/lib.rs, dentro de mod tests { ... }
#[test]
fn custom_delimiter_should_work() {
    assert_eq!(add("//;\n1;2"), 3);
    assert_eq!(add("//$\n1$2$3"), 6);
}

cargo test fallará. Esta regla añade un nivel significativo de complejidad en el parsing.

Verde: Necesitamos detectar si hay un delimitador personalizado y ajustar el split en consecuencia. Esto implica una lógica condicional al principio de la función.

// En src/lib.rs
pub fn add(numbers: &str) -> i32 {
    if numbers.is_empty() {
        return 0;
    }

    let (delimiter_chars, actual_numbers_str) = if numbers.starts_with("//") {
        let parts: Vec<&str> = numbers.splitn(2, '\n').collect();
        if parts.len() == 2 {
            let custom_delimiter = parts[0].trim_start_matches("//");
            (vec![custom_delimiter.chars().next().unwrap_or(',') as char, ',', '\n'], parts[1])
        } else {
  
Diario Tecnología