El desarrollo de software es un viaje continuo de construcción, refinamiento y mantenimiento. En este camino, la calidad, la robustez y la capacidad de adaptación son pilares fundamentales que todo desarrollador aspira a alcanzar. Una de las metodologías que más ha contribuido a la consecución de estos objetivos es el Desarrollo Dirigido por Pruebas (TDD, por sus siglas en inglés: Test-Driven Development). No es simplemente una técnica de testing; es una disciplina de diseño que moldea la arquitectura y el comportamiento de nuestro código desde sus cimientos. Y cuando combinamos la filosofía TDD con la potencia y seguridad de un lenguaje como Rust, la sinergia resultante puede ser realmente transformadora.
En este post, nos adentraremos en el corazón de una kata TDD, utilizando Rust como nuestro lienzo. Exploraremos cómo un ciclo incremental de "rojo, verde, refactor" nos permite construir software de manera metódica, segura y con una confianza inquebrantable. Prepárense para ensuciarse las manos con código, entender la mentalidad detrás de TDD y descubrir por qué Rust es un compañero excepcional en este viaje. Mi intención es compartir no solo el "cómo", sino también el "por qué" detrás de cada decisión, ofreciendo una perspectiva práctica y mis propias reflexiones sobre la experiencia.
¿Qué es una kata TDD?
Antes de sumergirnos en el código, es fundamental comprender qué significa una "kata TDD". En el contexto de las artes marciales, una kata es una secuencia de movimientos practicada repetidamente para perfeccionar la técnica y la memoria muscular. De manera análoga, una kata de programación, y más específicamente una kata TDD, es un ejercicio de codificación que se repite con el objetivo de mejorar las habilidades de desarrollo de software, especialmente en la aplicación de TDD.
El propósito no es tanto crear un producto final funcional y complejo, sino más bien internalizar el ciclo TDD: escribir una prueba fallida (rojo), escribir el código mínimo para que la prueba pase (verde) y luego refactorizar el código para mejorar su diseño y legibilidad, asegurándose de que todas las pruebas sigan pasando. Esta repetición ayuda a desarrollar un ritmo, una intuición para el diseño orientado a pruebas y la confianza para abordar problemas de software de manera incremental. Al practicar una kata, nos exponemos a errores y soluciones en un entorno de bajo riesgo, lo que nos permite aprender y crecer sin la presión de un proyecto real. Es, en esencia, un gimnasio mental para desarrolladores, donde se fortalececen los músculos del buen diseño y la implementación robusta.
¿Por qué Rust para TDD?
Rust se ha ganado una reputación por su seguridad de memoria, rendimiento y concurrencia sin comprometer la velocidad. Pero, ¿cómo encaja esto con TDD? La verdad es que Rust ofrece un ecosistema robusto que se alinea de manera excepcional con los principios del Desarrollo Dirigido por Pruebas.
Primero, el sistema de tipos estático y el compilador de Rust son increíblemente estrictos. Esto puede parecer una barrera inicialmente, pero en realidad, actúa como una red de seguridad formidable. Cuando escribimos pruebas y luego el código de producción, el compilador de Rust nos fuerza a pensar en la corrección, los casos límite y el manejo de errores desde el principio. Muchas veces, si el código compila en Rust, es muy probable que funcione correctamente a nivel de tipos y memoria, lo que nos permite centrarnos más en la lógica de negocio durante las pruebas.
Segundo, Rust viene con un sistema de testing integrado a través de cargo test. No necesitamos configurar marcos de pruebas complejos o herramientas externas. Simplemente agregando un bloque #[cfg(test)] y la anotación #[test] a una función, podemos escribir pruebas unitarias de manera eficiente. Esto reduce la fricción inicial y nos permite concentrarnos en lo que realmente importa: los requisitos y el comportamiento del sistema. Para mí, esta simplicidad en la configuración es un gran alivio, ya que permite mantener el foco en la tarea de diseño iterativo que TDD propone.
Además, la filosofía de "cero costo de abstracción" de Rust significa que podemos escribir código de alto nivel y legible sin sacrificar el rendimiento, lo cual es vital para construir componentes reutilizables y bien diseñados, un objetivo natural al seguir TDD. La capacidad de Rust para expresar ideas complejas con seguridad y concisión lo convierte en una herramienta potente para asegurar que, al final del ciclo "rojo, verde, refactor", no solo tenemos un código que funciona, sino un código que es seguro, performante y, lo más importante, fácil de mantener y evolucionar. Pueden aprender más sobre la filosofía de diseño de Rust en su página oficial.
Preparando el entorno
Para empezar nuestra kata, lo primero es configurar un nuevo proyecto de Rust. Si aún no tienen Rust instalado, pueden seguir las instrucciones en rustup.rs, que es el instalador y gestor de versiones oficial.
Una vez que Rust esté listo, abran su terminal y creen un nuevo proyecto de biblioteca (lib) con Cargo, el sistema de construcción y gestor de paquetes de Rust:
cargo new string_calculator --lib
cd string_calculator
Esto creará un directorio string_calculator con la siguiente estructura básica:
string_calculator/
├── src/
│ └── lib.rs
└── Cargo.toml
El archivo src/lib.rs es donde escribiremos tanto nuestra lógica de negocio como nuestras pruebas. Cargo ya ha incluido un ejemplo de prueba unitaria en lib.rs:
// src/lib.rs
#[cfg(test)]
mod tests {
#[test]
fn it_works() {
let result = 2 + 2;
assert_eq!(result, 4);
}
}
Pueden ejecutar esta prueba (y cualquier otra que escribamos) con el comando:
cargo test
Verán una salida similar a esta:
running 1 test
test tests::it_works ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
¡Excelente! Ya tenemos nuestro entorno listo para la acción. Ahora podemos borrar la prueba it_works para empezar de cero con nuestra kata.
La kata elegida: Calculadora de cadenas (String Calculator)
Para nuestra kata TDD, he elegido la "Calculadora de Cadenas" (String Calculator). Es un clásico que permite un crecimiento incremental y la introducción gradual de complejidad, ideal para practicar el ciclo Red-Green-Refactor. La premisa es simple: crear una función que sume números de una cadena de texto. Aquí están las reglas que iremos descubriendo y resolviendo una a una, siguiendo el orden en que las añadiríamos en una sesión TDD:
- La función
addpuede tomar 0, 1 o 2 números, y devolver su suma.- Para una cadena vacía, debe devolver 0.
- Para una cadena con un número ("1"), debe devolver el número (1).
- Para una cadena con dos números ("1,2"), debe devolver su suma (3).
- Permitir que la función
addmaneje un número desconocido de argumentos (es decir, "1,2,3" debería devolver 6). - Permitir que la función
addmaneje nuevas líneas entre números (en lugar de comas). Por ejemplo, "1\n2,3" debe devolver 6. - Soportar diferentes delimitadores. Para cambiar el delimitador, el principio de la cadena contendrá un formato: "//[delimitador]\n[números]". Por ejemplo, "//;\n1;2" debe devolver 3.
- Los números negativos deben lanzar una excepción. "Números negativos no permitidos: -2, -4".
- Los números mayores de 1000 deben ser ignorados. Por ejemplo, "1000,2" devuelve 1002, pero "1001,2" devuelve 2.
Esta progresión nos permitirá explorar varios aspectos del TDD y el manejo de cadenas y errores en Rust.
El ciclo TDD en acción (Red-Green-Refactor)
Aquí es donde comienza la diversión. Seguiremos el ciclo TDD para cada regla de la calculadora de cadenas.
Primera iteración: Suma básica (vacío y un número)
Empezaremos con la regla más simple: una cadena vacía debe devolver 0.
ROJO: Escribir una prueba fallida.
Abrimos src/lib.rs y añadimos nuestra primera prueba. Es importante que la prueba falle inicialmente; de lo contrario, no estamos probando nada nuevo.
// src/lib.rs
pub fn add(numbers: &str) -> i32 {
// Implementación futura
0 // Retornamos 0 temporalmente para que el compilador no se queje
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_string_should_return_zero() {
assert_eq!(add(""), 0);
}
}
Ejecutamos cargo test. ¡La prueba debería fallar! Ah, un momento, si nuestra función ya devuelve 0, la prueba pasará. Este es un error común para los principiantes en TDD. La clave es que el comportamiento que queremos añadir no existe aún. Así que nuestra función add inicialmente podría no compilar o simplemente devolver un valor genérico si no queremos que pase. En este caso, devolver 0 hace que la prueba pase, lo que no es el objetivo. Vamos a cambiar add para que no devuelva 0 sino que aún necesite implementación para forzar el fallo. O mejor, pensémoslo así: cuando escribimos la prueba, la función add no existe aún o está vacía.
Corregimos el add para que represente un "estado inicial" que no cumple la prueba:
// src/lib.rs
// Simplemente para que compile de momento, pero la prueba de abajo nos indicará que está mal.
// En un escenario real, esta función no existiría aún o sería un stub que falla.
pub fn add(numbers: &str) -> i32 {
panic!("Aún no implementado"); // Esto hará que la prueba falle.
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_string_should_return_zero() {
assert_eq!(add(""), 0);
}
}
Ahora sí, cargo test debería mostrar:
running 1 test
test tests::empty_string_should_return_zero ... FAILED
failures:
tests::empty_string_should_return_zero
thread 'tests::empty_string_should_return_zero' panicked at 'Aún no implementado', src/lib.rs:7:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::empty_string_should_return_zero
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass '--test'
¡Perfecto! Tenemos un fallo.
VERDE: Escribir el código mínimo para que la prueba pase.
Implementamos solo lo necesario para que empty_string_should_return_zero pase.
// src/lib.rs
pub fn add(numbers: &str) -> i32 {
if numbers.is_empty() {
0
} else {
panic!("Aún no implementado para otros casos"); // Mantenemos el panic para otros casos
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_string_should_return_zero() {
assert_eq!(add(""), 0);
}
}
Ejecutamos cargo test de nuevo. ¡Debería pasar!
running 1 test
test tests::empty_string_should_return_zero ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
REFACCIÓN: Mejorar el código sin cambiar su comportamiento externo.
En este punto, el código es bastante simple, así que no hay mucho que refactorizar. Quizás podríamos pensar en cómo manejar los panic! pero por ahora, está bien para la primera etapa.
Ahora, la siguiente parte de la regla 1: un número ("1") devuelve el mismo número (1).
ROJO: Escribir una nueva prueba fallida.
#[cfg(test)]
mod tests {
use super::*;
// ... (prueba anterior)
#[test]
fn single_number_should_return_itself() {
assert_eq!(add("1"), 1);
assert_eq!(add("5"), 5);
}
}
cargo test debería mostrar un fallo debido al panic! para casos no vacíos.
VERDE: Implementar para que la nueva prueba pase.
// src/lib.rs
pub fn add(numbers: &str) -> i32 {
if numbers.is_empty() {
0
} else {
numbers.parse::<i32>().unwrap_or_default() // Esto es lo mínimo, unwrap_or_default()
// es un poco tramposo si la cadena no es un número.
// Mejor usar un `expect` o `match` para fallar explícitamente.
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_string_should_return_zero() {
assert_eq!(add(""), 0);
}
#[test]
fn single_number_should_return_itself() {
assert_eq!(add("1"), 1);
assert_eq!(add("5"), 5);
}
}
Usar unwrap_or_default() para parse es una solución rápida para pasar la prueba, pero no es robusta. Si la cadena no es un número válido, devolvería 0. Para los propósitos de TDD, es la solución más simple para pasar la prueba actual. Una refactorización posterior podría manejar mejor los errores. cargo test pasa.
REFACCIÓN:
Podemos mejorar el manejo de errores del parse para que sea más explícito o incluso lanzar un error si la entrada no es un número válido. Pero las reglas de la kata no lo exigen explícitamente todavía, así que de momento, el unwrap_or_default es suficiente para pasar las pruebas actuales y es mínimo. Es importante no añadir más código del estrictamente necesario. Esto es algo que a veces cuesta, la tentación de "arreglar" más de lo que la prueba actual pide.
Segunda iteración: Suma de dos números
Regla 1, parte 3: "1,2" debe devolver 3.
ROJO: Nueva prueba fallida.
#[cfg(test)]
mod tests {
use super::*;
// ... (pruebas anteriores)
#[test]
fn two_numbers_should_return_sum() {
assert_eq!(add("1,2"), 3);
assert_eq!(add("5,3"), 8);
}
}
Ejecutamos cargo test. ¡Fallo! Nuestra implementación actual (numbers.parse::<i32>().unwrap_or_default()) no sabe cómo manejar comas.
VERDE: Implementar lo mínimo.
Aquí necesitamos dividir la cadena por comas y sumar los resultados.
// src/lib.rs
pub fn add(numbers: &str) -> i32 {
if numbers.is_empty() {
0
} else {
numbers.split(',')
.map(|s| s.parse::<i32>().unwrap_or_default()) // Convertir a números
.sum() // Sumar los números
}
}
#[cfg(test)]
mod tests {
use super::*;
// ... (todas las pruebas anteriores)
#[test]
fn two_numbers_should_return_sum() {
assert_eq!(add("1,2"), 3);
assert_eq!(add("5,3"), 8);
}
}
Ejecutamos cargo test. ¡Todo pasa! Noten cómo la nueva implementación automáticamente maneja también los casos de una sola palabra (simplemente split devuelve un solo elemento) y la cadena vacía (el split("") con map y sum sobre un iterador vacío devuelve 0). Podríamos incluso simplificar el if numbers.is_empty().
REFACCIÓN:
El if numbers.is_empty() ahora es redundante. El código es más conciso y claro si lo eliminamos.
// src/lib.rs
pub fn add(numbers: &str) -> i32 {
numbers.split(',')
.map(|s| s.parse::<i32>().unwrap_or_default())
.sum()
}
cargo test. Todas las pruebas siguen pasando. Esto demuestra el poder de la refactorización: eliminar duplicidades y simplificar el código mientras las pruebas nos dan la confianza de que no hemos roto nada.
Tercera iteración: Múltiples números
Regla 2: Un número desconocido de argumentos ("1,2,3" debe devolver 6). Nuestra implementación actual ya maneja esto, pero es bueno escribir la prueba para asegurarnos.
ROJO: Nueva prueba (aunque pasará).
#[cfg(test)]
mod tests {
use super::*;
// ... (pruebas anteriores)
#[test]
fn multiple_numbers_should_return_sum() {
assert_eq!(add("1,2,3"), 6);
assert_eq!(add("10,20,30,40"), 100);
}
}
cargo test. ¡La prueba pasa! Esto es un "Red" instantáneamente "Green". Es una situación común cuando el código ya anticipa un requisito futuro, pero aún así es crucial escribir la prueba para documentar el comportamiento esperado y protegerlo de futuras regresiones.
VERDE: N/A (ya es verde).
REFACCIÓN: N/A.
Cuarta iteración: Nuevos delimitadores (saltos de línea)
Regla 3: "1\n2,3" debe devolver 6.
ROJO: Nueva prueba fallida.
#[cfg(test)]
mod tests {
use super::*;
// ... (pruebas anteriores)
#[test]
fn new_lines_should_also_be_delimiters() {
assert_eq!(add("1\n2,3"), 6);
assert_eq!(add("1\n2\n3"), 6);
}
}
cargo test. ¡Fallo! Nuestro split(',') no maneja \n.
VERDE: Implementar lo mínimo.
Necesitamos dividir por comas o saltos de línea. La función split_terminator o split con múltiples delimitadores o un regex sería una opción. Pero para el mínimo viable, podemos reemplazar \n por , y luego hacer el split(',').
// src/lib.rs
pub fn add(numbers: &str) -> i32 {
let replaced_numbers = numbers.replace('\n', ","); // Reemplazamos \n por ,
replaced_numbers.split(',')
.map(|s| s.parse::<i32>().unwrap_or_default())
.sum()
}
cargo test. ¡Todas las pruebas pasan!
REFACCIÓN:
El código funci