En el vertiginoso mundo del desarrollo de software, la calidad, la mantenibilidad y la confianza son pilares fundamentales que a menudo se ven comprometidos por la presión del tiempo y la complejidad inherente a nuestros sistemas. Sin embargo, existe una práctica que, lejos de ser un mero "extra", se revela como una herramienta indispensable para construir software robusto y escalable: el Desarrollo Dirigido por Pruebas (TDD, por sus siglas en inglés). Cuando combinamos la disciplina de TDD con la potencia y seguridad que ofrece Rust, la sinergia resultante es formidable. Prepárese para sumergirse en una exploración profunda de cómo una "kata" de TDD en Rust no solo afina nuestras habilidades de programación, sino que también eleva la calidad del código a nuevas alturas.
¿Por Qué TDD? Más Allá de la Cobertura de Código

El Test-Driven Development no es simplemente una estrategia para asegurar una alta cobertura de pruebas. Es, ante todo, una metodología de diseño. Su premisa central es sencilla pero profundamente transformadora: escribir una prueba fallida antes de escribir cualquier código de producción. Este ciclo, conocido como "Red, Green, Refactor", fuerza al desarrollador a pensar en el comportamiento externo del software desde la perspectiva del consumidor del código, en lugar de en su implementación interna.
El proceso es así:
- Rojo (Red): Escribe una pequeña prueba unitaria para una nueva funcionalidad o un cambio en el comportamiento existente. Esta prueba debe fallar, ya sea porque la funcionalidad aún no existe o porque el comportamiento es incorrecto. Este paso valida que la prueba es correcta y realmente comprueba lo que se espera.
- Verde (Green): Escribe la cantidad mínima de código de producción necesaria para que la prueba pase. No se preocupe por el diseño elegante ni la optimización; el objetivo es que la prueba pase lo más rápido posible.
- Refactorizar (Refactor): Una vez que la prueba pasa (y todas las demás pruebas también), es el momento de mejorar la calidad del código. Reorganice, simplifique, optimice y elimine duplicidades, siempre con la confianza de que sus pruebas le alertarán si rompe algo.
Los beneficios de esta disciplina son múltiples. TDD mejora el diseño al obligarnos a crear interfaces claras y desacopladas; reduce los errores porque estamos verificando constantemente pequeñas piezas de funcionalidad; actúa como una documentación viva de nuestro código; y, quizás lo más importante, infunde una inmensa confianza en nuestro trabajo. Saber que una suite de pruebas robusta valida cada cambio permite refactorizar con audacia, algo esencial para mantener la agilidad en proyectos a largo plazo. En mi experiencia, TDD es menos sobre "probar el código" y más sobre "diseñar el código con la guía de pruebas". Es una herramienta de pensamiento que moldea la forma en que abordamos los problemas. Para profundizar más en los fundamentos de TDD, recomiendo el clásico artículo de Martin Fowler sobre el tema: Test-Driven Development by Martin Fowler.
Rust y TDD: Una Sinergia Poderosa
Rust se ha ganado una reputación envidiable por su enfoque en la seguridad, el rendimiento y la concurrencia, todo ello sin un recolector de basura. Pero, ¿cómo encaja TDD en este panorama? La respuesta es: de manera excepcional. Rust ofrece características que complementan el TDD de formas muy potentes:
- Sistema de Tipos Fuerte: El compilador de Rust es famoso por ser un "tirano amistoso". Detecta una inmensa cantidad de errores en tiempo de compilación que en otros lenguajes solo se manifestarían en tiempo de ejecución. Al combinar esto con TDD, se reduce aún más la superficie de error, ya que Rust se encarga de la corrección del tipo y la seguridad de la memoria, mientras que TDD valida la corrección del comportamiento.
- Gestión de Memoria Segura (Ownership y Borrowing): El sistema de propiedad y préstamo de Rust garantiza la seguridad de la memoria sin depender de un recolector de basura, eliminando categorías enteras de errores como punteros nulos o fugas de memoria. Esto significa que podemos enfocarnos en la lógica de negocio en nuestras pruebas TDD, en lugar de preocuparnos por problemas de memoria.
-
Framework de Pruebas Integrado: Rust viene con un excelente sistema de pruebas unitarias incorporado. Basta con la anotación
#[test]
sobre una función para quecargo test
la descubra y ejecute. Esto hace que escribir y ejecutar pruebas sea increíblemente sencillo y rápido. - Rendimiento: Las aplicaciones de Rust son intrínsecamente rápidas. Esto se traduce en que las suites de pruebas también lo son, lo que facilita ejecutar las pruebas con frecuencia, un requisito clave para un ciclo TDD efectivo.
La combinación de la seguridad de Rust y la disciplina de TDD resulta en un código que no solo es funcionalmente correcto, sino también excepcionalmente robusto y resistente a errores. El sitio oficial de Rust ofrece una excelente introducción a su ecosistema: The Rust Programming Language.
Preparando el Terreno: El Entorno y la Mentalidad de Kata
Una "kata de programación" es un ejercicio de repetición diseñado para mejorar las habilidades a través de la práctica. Al igual que los artistas marciales practican katas para perfeccionar sus movimientos, los desarrolladores de software las usamos para dominar técnicas de codificación, como TDD, refactoring, o el uso de patrones de diseño. Elegir una buena kata es crucial; debe ser lo suficientemente simple como para no abrumar, pero con suficiente complejidad para permitir la exploración de diferentes requisitos.
Para nuestro ejemplo, utilizaremos la popular "String Calculator Kata". Esta kata es ideal porque comienza muy simple y gradualmente introduce más reglas, lo que nos permite demostrar el ciclo Red-Green-Refactor de manera efectiva.
Primero, asegúrese de tener Rust instalado. Si no es así, la forma más sencilla es a través de rustup
: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
.
Creemos un nuevo proyecto de biblioteca en Rust. Una biblioteca es ideal para una kata, ya que podemos exportar una función y probarla sin necesidad de un ejecutable completo.
cargo new --lib string_calculator_kata
cd string_calculator_kata
Dentro del directorio src/
, tendremos un archivo lib.rs
. Aquí es donde escribiremos tanto la función que estamos desarrollando como sus pruebas.
Nuestra mentalidad durante la kata debe ser:
- Pasos pequeños: No intente resolver todo el problema de una vez. Aborde una regla a la vez.
- Red, Green, Refactor: Siga estrictamente el ciclo.
- Comprométase: Haga commits pequeños y frecuentes después de cada refactorización exitosa, lo que le da un historial claro de su progreso.
Para aprender más sobre cómo cargo test
funciona, consulte la documentación oficial: Cargo Test Documentation.
La Kata del Calculador de Cadenas (String Calculator) - Paso a Paso con TDD en Rust
El objetivo es crear una función add(numbers: &str)
que toma una cadena de texto y devuelve la suma de los números que contiene.
Regla 1: Cadena vacía devuelve 0.
Rojo (Red): Escribir la prueba que falla.
Abrimos src/lib.rs
y añadimos el esqueleto de nuestra función y la primera prueba:
pub fn add(numbers: &str) -> i32 {
unimplemented!() // Esto hará que la prueba falle
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_string_should_return_zero() {
assert_eq!(add(""), 0);
}
}
Ejecutamos las pruebas: cargo test
. Deberíamos ver una falla, probablemente un panic
debido a unimplemented!()
.
Verde (Green): Hacer que la prueba pase con la mínima implementación.
Modificamos add
para que simplemente devuelva 0.
pub fn add(numbers: &str) -> i32 {
if numbers.is_empty() {
0
} else {
unimplemented!() // Las reglas futuras se manejarán aquí
}
}
Ejecutamos cargo test
de nuevo. ¡Debería pasar!
Refactorizar (Refactor):
En este punto, no hay mucho que refactorizar. El código es simple y claro.
Regla 2: Un solo número devuelve el mismo número.
Rojo (Red): Escribir la prueba que falla.
Añadimos otra prueba a nuestro módulo tests
:
// ... (código anterior)
#[test]
fn single_number_should_return_itself() {
assert_eq!(add("1"), 1);
assert_eq!(add("5"), 5);
}
cargo test
debería fallar (por el unimplemented!()
).
Verde (Green): Hacer que la prueba pase.
Modificamos add
para parsear el número si no está vacío:
pub fn add(numbers: &str) -> i32 {
if numbers.is_empty() {
0
} else {
// Asume que es un solo número por ahora
numbers.parse::<i32>().unwrap()
}
}
cargo test
debería pasar.
Refactorizar (Refactor):
El unwrap()
aquí es un poco rudo; asume que la entrada siempre será un número válido. Para una kata, podemos aceptarlo por simplicidad, pero en código real usaríamos ?
o manejaríamos Result
explícitamente. Por ahora, es lo suficientemente bueno.
Regla 3: Dos números separados por coma devuelven su suma.
Rojo (Red): Escribir la prueba que falla.
// ... (código anterior)
#[test]
fn two_numbers_comma_delimited_should_return_sum() {
assert_eq!(add("1,2"), 3);
assert_eq!(add("10,20"), 30);
}
cargo test
falla. Nuestra implementación actual intentaría parsear "1,2" como un solo número, lo que fallaría.
Verde (Green): Hacer que la prueba pase.
Ahora tenemos que manejar el caso de múltiples números. Necesitamos dividir la cadena por comas, parsear cada parte y sumarlas.
pub fn add(numbers: &str) -> i32 {
if numbers.is_empty() {
0
} else {
numbers
.split(',') // Divide la cadena por comas
.map(|s| s.parse::<i32>().unwrap_or(0)) // Parsea cada parte, 0 si falla (ej. si hay una coma extra al final)
.sum() // Suma todos los números
}
}
cargo test
debería pasar. ¡Todos los tests pasan!
Refactorizar (Refactor):
La función map(|s| s.parse::<i32>().unwrap_or(0))
es un buen paso para la robustez, manejando entradas que no son números. En Rust, es común usar Result
para manejar errores de forma más explícita, pero para esta kata, unwrap_or(0)
es aceptable para mantener la simplicidad.
Regla 4: Permite saltos de línea como delimitadores (además de comas).
Rojo (Red): Escribir la prueba que falla.
// ... (código anterior)
#[test]
fn newlines_as_delimiters_should_return_sum() {
assert_eq!(add("1\n2,3"), 6); // 1 + 2 + 3 = 6
assert_eq!(add("1\n2\n3"), 6);
}
cargo test
falla porque split(',')
no manejará \n
.
Verde (Green): Hacer que la prueba pase.
Necesitamos dividir por múltiples delimitadores. Una opción es reemplazar los saltos de línea por comas y luego usar la lógica existente, o usar split_terminator
o split_whitespace
de forma más inteligente. Para esta kata, un reemplazo simple es suficiente.
pub fn add(numbers: &str) -> i32 {
if numbers.is_empty() {
0
} else {
numbers
.replace('\n', ",") // Reemplaza saltos de línea con comas
.split(',')
.map(|s| s.parse::<i32>().unwrap_or(0))
.sum()
}
}
cargo test
pasa.
Refactorizar (Refactor):
El .replace('\n', ",")
funciona, pero podríamos generalizar un poco más si hubiera muchos delimitadores. Por ahora, es legible y cumple su función.
Regla 5: Los números negativos deben lanzar un error (panic en Rust).
Rojo (Red): Escribir la prueba que falle.
Rust no tiene excepciones al estilo de otros lenguajes, pero podemos usar panic!
para indicar un error irrecuperable o devolver un Result
. Para esta regla de la kata, un panic!
es aceptable. La forma de probar un panic
en Rust es con #[should_panic]
.
// ... (código anterior)
#[test]
#[should_panic(expected = "negative numbers not allowed: -1")] // Especifica el mensaje esperado
fn negative_numbers_should_panic() {
add("-1,2");
}
#[test]
#[should_panic(expected = "negative numbers not allowed: -4,-5")]
fn multiple_negative_numbers_should_panic_with_all_of_them() {
add("2,-4,3,-5");
}
cargo test
fallará porque el código actual no detecta negativos.
Verde (Green): Hacer que la prueba pase.
Tenemos que filtrar los números negativos y, si encontramos alguno, hacer panic!
.
pub fn add(numbers: &str) -> i32 {
if numbers.is_empty() {
0
} else {
let parsed_numbers: Vec<i32> = numbers
.replace('\n', ",")
.split(',')
.filter_map(|s| s.parse::<i32>().ok()) // Usamos ok() para descartar no-números aquí
.collect();
let negatives: Vec<i32> = parsed_numbers
.iter()
.filter(|&n| *n < 0)
.cloned()
.collect();
if !negatives.is_empty() {
panic!("negative numbers not allowed: {}", negatives.iter().map(|n| n.to_string()).collect::<Vec<String>>().join(","));
}
parsed_numbers.iter().sum()
}
}
cargo test
debería pasar, incluyendo las pruebas de pánico.
Refactorizar (Refactor):
El código para manejar negativos se ha vuelto un poco más complejo, pero es bastante legible. Usar filter_map(|s| s.parse::<i32>().ok())
es más robusto que unwrap_or(0)
si queremos solo números válidos. La construcción del mensaje de pánico es un buen ejemplo de cómo manejar colecciones de forma idiomática en Rust. Si quisiéramos una API más limpia, podríamos considerar devolver un Result<i32, String>
en lugar de hacer panic!
. Esto es más idiomático en Rust para errores recuperables.
Regla 6: Los números mayores de 1000 deben ser ignorados.
Rojo (Red): Escribir la prueba que falla.
// ... (código anterior)
#[test]
fn numbers_greater_than_1000_should_be_ignored() {
assert_eq!(add("1001,2"), 2);
assert_eq!(add("1000,2"), 1002); // 1000 should NOT be ignored
assert_eq!(add("1001,2\n1005"), 2);
}
cargo test
fallará porque 1001 y 1005 no serán ignorados.
Verde (Green): Hacer que la prueba pase.
Ahora tenemos que añadir un filtro para ignorar los números mayores de 1000. Este filtro debe aplicarse después de comprobar los negativos, pero antes de la suma.
pub fn add(numbers: &str) -> i32 {
if numbers.is_empty() {
0
} else {
let processed_numbers: Vec<i32> = numbers
.replace('\n', ",")
.split(',')
.filter_map(|s| s.parse::<i32>().ok())
.collect();
let negatives: Vec<i32> = processed_numbers
.iter()
.filter(|&n| *n < 0)
.cloned()
.collect();
if !negatives.is_empty() {
panic!("negative numbers not allowed: {}", negatives.iter().map(|n| n.to_string()).collect::<Vec<String>>().join(","));
}
// Filtra los números mayores de 1000 antes de la suma
processed_numbers.iter().filter(|&n| *n <= 1000).sum()
}
}
cargo test
debería pasar todas las pruebas.
Refactorizar (Refactor):
Ahora la función add
está bastante densa. Podríamos extraer la lógica de parsing y filtrado en una función auxiliar para mejorar la legibilidad. Por ejemplo:
// Esta sería una función auxiliar, no pública, posiblemente privada a este módulo
fn parse_and_validate_numbers(s: &str) -> Vec<i32> {
let processed_numbers: Vec<i32> = s
.replace('\n', ",")
.split(',')
.filter_map(|part| part.parse::<i32>().ok())
.collect();
let negatives: Vec<i32> = processed_numbers
.iter()
.filter(|&n| *n < 0)
.cloned()
.collect();
if !negatives.is_empty() {
panic!("negative numbers not allowed: {}", negatives.iter().map(|n| n.to_string()).collect::<Vec<String>>().join(","));
}
processed_numbers.into_iter().filter(|&n| n <= 1000).collect()
}
pub fn add(numbers: &str) -> i32 {
if numbers.is_empty() {
0
} else {
parse_and_validate_numbers(numbers).iter().sum()
}
}
Este refactoring hace que add
sea mucho más limpia y su propósito más claro, al delegar la complejidad de parsing y validación a una función dedicada. Es un excelente ejemplo de cómo el paso de refactorización mejora la estructura del código sin cambiar su comportamiento.
Reflexiones y Buenas Prácticas en TDD con Rust
Al realizar esta kata, hemos tocado varias buenas prácticas de TDD y Rust:
- Nombres de Pruebas Descriptivos