En el vertiginoso mundo del desarrollo de software, la calidad, la mantenibilidad y la adaptabilidad son pilares fundamentales. Sin embargo, en la prisa por entregar funcionalidades, a menudo sacrificamos la atención al detalle que estas cualidades requieren. Aquí es donde entra en juego el Desarrollo Guiado por Pruebas (TDD, por sus siglas en inglés), una metodología que no solo mejora la calidad del código, sino que también guía su diseño. Si a esto le sumamos las "katas de código", ejercicios repetitivos diseñados para afinar nuestras habilidades, obtenemos una combinación poderosa para cualquier desarrollador. En este post, exploraremos cómo aplicar una kata TDD utilizando Go, un lenguaje que, con su simplicidad y herramientas integradas, se presta maravillosamente a este enfoque. Prepárense para sumergirse en un ciclo de red-green-refactor que transformará su manera de pensar sobre el código.
¿Qué es TDD y por qué es crucial?
El Desarrollo Guiado por Pruebas, o TDD, es más que una simple práctica de testing; es una filosofía de diseño de software. Se basa en un ciclo iterativo de tres pasos, conocido como Red-Green-Refactor:
- Rojo (Red): Escriba una prueba automatizada que falle. Esta prueba debe representar una pequeña porción de la funcionalidad que desea implementar o corregir. El fallo es intencional, ya que demuestra que la funcionalidad aún no existe.
- Verde (Green): Escriba la cantidad mínima de código de producción necesaria para que la prueba que acaba de escribir, y todas las anteriores, pasen. El objetivo aquí es simplemente hacer que la prueba funcione, sin preocuparse demasiado por la elegancia o la optimización.
- Refactorizar (Refactor): Una vez que todas las pruebas están en verde, es el momento de mejorar el diseño del código. Esto puede implicar reorganizar la lógica, eliminar duplicidades, mejorar la legibilidad o aplicar patrones de diseño, todo mientras se asegura de que las pruebas existentes sigan pasando, lo que le brinda una red de seguridad inestimable.
La adopción de TDD trae consigo una plétora de beneficios que impactan directamente en la salud del proyecto y en la productividad del equipo. Primero, fomenta un diseño de software más limpio y modular. Al pensar en cómo testear una funcionalidad antes de implementarla, nos vemos obligados a considerar la interfaz pública de nuestro código, lo que a menudo lleva a diseños más desacoplados y fáciles de mantener. Segundo, actúa como una documentación viva. Las pruebas, bien escritas, explican cómo se espera que funcione cada parte del sistema, lo cual es invaluable para nuevos miembros del equipo o para recordar la intención original de una característica. Tercero, y quizás lo más importante, reduce drásticamente los errores y aumenta la confianza. Saber que cada cambio que realizamos está respaldado por un conjunto robusto de pruebas nos permite refactorizar sin miedo, experimentar con nuevas ideas y acelerar el ciclo de desarrollo sin comprometer la estabilidad. En mi experiencia, muchos desarrolladores inicialmente sienten que TDD los ralentiza, pero a largo plazo, la inversión inicial se recupera con creces en tiempo de depuración y mantenimiento reducido. Para una comprensión más profunda de la metodología, recomiendo revisar la descripción de TDD en el sitio web de Martin Fowler.
Las katas de código: una herramienta para el aprendizaje deliberado
El término "kata" proviene de las artes marciales japonesas, donde se refiere a una secuencia de movimientos practicada repetidamente para perfeccionar la técnica y construir memoria muscular. En el contexto del software, una kata de código es un ejercicio de programación pequeño y bien definido que se practica una y otra vez para mejorar habilidades específicas, como el diseño orientado a objetos, el manejo de pruebas, o simplemente para familiarizarse con un nuevo lenguaje o framework. El valor de las katas no reside tanto en la solución final, sino en el proceso de llegar a ella, en la repetición y en la reflexión que se hace sobre cada iteración. No se trata de resolver un problema complejo una vez, sino de resolver un problema más simple de muchas maneras, cada vez con mayor maestría. Code Kata de Dave Thomas es una excelente fuente de inspiración y ofrece varias katas para practicar.
Combinar TDD con katas de código es una estrategia poderosa. Las katas proporcionan el escenario perfecto para practicar el ciclo Red-Green-Refactor en un entorno de bajo riesgo, permitiéndonos cometer errores y aprender de ellos sin las presiones de un proyecto real. A medida que nos familiarizamos con el ritmo de TDD a través de las katas, este enfoque se vuelve instintivo, y pronto descubriremos que aplicamos sus principios incluso en los desafíos más complejos.
Configurando nuestro entorno Go para TDD
Go es un lenguaje fantástico para TDD, en gran parte gracias a su robusto paquete de pruebas integrado, testing, y al comando go test. No hay necesidad de instalar frameworks de terceros complejos para empezar; todo lo que necesitamos ya está ahí.
Asegúrese de tener Go instalado en su sistema. Si no es así, puede descargarlo desde la página oficial de Go. Una vez instalado, la configuración para un proyecto simple es sencilla:
- Cree un nuevo directorio para su proyecto, por ejemplo,
string_calculator_kata. - Dentro de este directorio, inicialice un módulo Go:
go mod init string_calculator_kata. - Cree dos archivos: uno para su código de producción (
calculator.go) y otro para sus pruebas (calculator_test.go).
La convención de Go para los archivos de prueba es nombrarlos _test.go y colocarlos en el mismo paquete que el código que están testeando. Esto permite que las pruebas accedan a funciones y tipos no exportados, lo cual es útil para pruebas unitarias. Para ejecutar las pruebas, simplemente navegue al directorio raíz de su módulo y ejecute go test. Esto ejecutará todas las pruebas en todos los archivos _test.go dentro del paquete actual y sus subdirectorios.
La kata "String Calculator": un desafío clásico de TDD
Para nuestra demostración, elegiremos la popular kata "String Calculator". Es una excelente opción para principiantes de TDD porque comienza con requisitos muy simples que aumentan gradualmente en complejidad, permitiendo un flujo natural a través del ciclo Red-Green-Refactor. El objetivo es crear una función Add que tome una cadena de texto como entrada y devuelva la suma de los números que contiene, siguiendo estas reglas:
- La función
Addpuede recibir una cadena vacía, y debe devolver 0. - Puede recibir un número, y debe devolver ese número.
- Puede recibir dos números separados por coma, y debe devolver su suma.
- Debe manejar cualquier cantidad de números separados por coma.
- Debe permitir que las nuevas líneas (
\n) se usen también como delimitadores. - Debe permitir especificar un delimitador diferente. El formato será
"//[delimitador]\n[números...]". Por ejemplo,"//;\n1;2"debería devolver 3. - Los números negativos deben lanzar un error. Debería mostrar todos los negativos en el mensaje de error.
- Los números mayores que 1000 deben ser ignorados. Por ejemplo,
"1,2,1001"debería devolver 3.
Para el propósito de este post, cubriremos los primeros cinco puntos en detalle, demostrando el ciclo TDD. Los puntos restantes se mencionarán como ejercicios adicionales, para que el lector pueda continuar la kata de forma independiente.
Implementando la kata con TDD en Go
Comencemos con la implementación de nuestra función Add. Crearemos los archivos calculator.go y calculator_test.go dentro de nuestro módulo.
// calculator.go
package calculator
// La función Add se implementará aquí, siguiendo el ciclo TDD.
func Add(numbers string) (int, error) {
// Implementación inicial mínima para satisfacer el primer test
return 0, nil
}
// calculator_test.go
package calculator_test
import (
"calculator" // Importamos nuestro paquete
"testing"
)
// Las pruebas irán aquí.
Paso 1: Cadena vacía devuelve 0
Escribiendo la prueba (rojo)
Nuestro primer requisito es que una cadena vacía devuelva 0. Escribamos una prueba que valide esto.
// calculator_test.go
package calculator_test
import (
"calculator"
"testing"
)
func TestAdd_EmptyStringReturnsZero(t *testing.T) {
result, err := calculator.Add("")
if err != nil {
t.Fatalf("se esperaba nil error para cadena vacía, se obtuvo: %v", err)
}
if result != 0 {
t.Errorf("se esperaba 0 para cadena vacía, se obtuvo: %d", result)
}
}
Si intentamos ejecutar go test ahora, veremos que la prueba falla. El error es porque nuestra implementación inicial de Add ya devuelve 0, por lo que este test pasaría. Sin embargo, para fines didácticos, imaginemos que Add inicialmente devolvería un error o un valor diferente. Por ejemplo, si nuestra función Add aún no existe o devuelve un valor por defecto que no es 0. En este caso específico, nuestra primera implementación "accidentalmente" pasa. Esto subraya la importancia de empezar con la implementación mínima, aunque a veces, como aquí, esa mínima implementación coincida con el caso de uso. Es fundamental que la prueba falle *antes* de escribir el código de producción que la satisfaga.
Haciendo que pase (verde)
Nuestra implementación actual de Add ya cumple este requisito. Si nuestra función Add no existiera o devolviera algo diferente, el código mínimo para que la prueba pase sería simplemente:
// calculator.go
package calculator
func Add(numbers string) (int, error) {
if numbers == "" {
return 0, nil
}
return 0, nil // Placeholder para futuras implementaciones
}
Al ejecutar go test, la prueba TestAdd_EmptyStringReturnsZero pasará.
Refactorizando (azul/gris)
En este punto, el código es extremadamente simple, por lo que no hay mucho que refactorizar.
Paso 2: Un solo número devuelve el número
Escribiendo la prueba (rojo)
Ahora, la función debe manejar una cadena con un solo número.
// calculator_test.go
package calculator_test
import (
"calculator"
"testing"
)
// ... TestAdd_EmptyStringReturnsZero ...
func TestAdd_SingleNumberReturnsItself(t *testing.T) {
tests := []struct {
input string
expected int
}{
{"1", 1},
{"5", 5},
{"100", 100},
}
for _, test := range tests {
result, err := calculator.Add(test.input)
if err != nil {
t.Fatalf("se esperaba nil error para '%s', se obtuvo: %v", test.input, err)
}
if result != test.expected {
t.Errorf("para '%s', se esperaba %d, se obtuvo: %d", test.input, test.expected, result)
}
}
}
Al ejecutar go test, esta nueva prueba fallará porque nuestra función Add actual siempre devuelve 0. Estamos en la fase "roja".
Haciendo que pase (verde)
Necesitamos convertir la cadena de entrada en un entero. Usaremos strconv.Atoi para esto.
// calculator.go
package calculator
import (
"strconv"
)
func Add(numbers string) (int, error) {
if numbers == "" {
return 0, nil
}
// Convertir la cadena a entero
num, err := strconv.Atoi(numbers)
if err != nil {
return 0, err // En un futuro, manejaremos errores de formato más específicos
}
return num, nil
}
Después de este cambio, go test debería pasar ambas pruebas. Estamos en la fase "verde".
Refactorizando (azul/gris)
El código es todavía bastante simple. No hay una refactorización obvia aquí.
Paso 3: Dos números separados por coma devuelven su suma
Escribiendo la prueba (rojo)
El siguiente requisito es sumar dos números separados por coma.
// calculator_test.go
package calculator_test
import (
"calculator"
"testing"
)
// ... TestAdd_EmptyStringReturnsZero ...
// ... TestAdd_SingleNumberReturnsItself ...
func TestAdd_TwoNumbersCommaSeparatedReturnsSum(t *testing.T) {
tests := []struct {
input string
expected int
}{
{"1,2", 3},
{"5,5", 10},
{"10,20", 30},
}
for _, test := range tests {
result, err := calculator.Add(test.input)
if err != nil {
t.Fatalf("se esperaba nil error para '%s', se obtuvo: %v", test.input, err)
}
if result != test.expected {
t.Errorf("para '%s', se esperaba %d, se obtuvo: %d", test.input, test.expected, result)
}
}
}
Esta prueba fallará. Nuestra función actual intenta convertir toda la cadena "1,2" a un entero, lo cual resulta en un error de conversión. "Rojo".
Haciendo que pase (verde)
Necesitamos dividir la cadena por la coma, convertir cada parte a entero y sumarlas.
// calculator.go
package calculator
import (
"strconv"
"strings"
)
func Add(numbers string) (int, error) {
if numbers == "" {
return 0, nil
}
parts := strings.Split(numbers, ",")
sum := 0
for _, part := range parts {
num, err := strconv.Atoi(part)
if err != nil {
return 0, err // Manejo de errores para partes no numéricas
}
sum += num
}
return sum, nil
}
Ahora, go test debería pasar todas las pruebas existentes, incluyendo la nueva. ¡"Verde"!
Refactorizando (azul/gris)
La lógica de división y suma funciona, pero ¿qué pasa si el número es solo uno? Nuestra función actual lo dividirá en una sola parte y lo sumará, lo cual es correcto. La cláusula `if numbers == ""` es ahora redundante ya que `strings.Split("", ",")` devuelve `[""]`, y `strconv.Atoi("")` falla, o lo podríamos manejar explícitamente. Sin embargo, para este nivel, la lógica actual es suficientemente clara. Podríamos pensar en extraer la lógica de parsing de una sola parte a una función auxiliar si se vuelve más compleja. Por ahora, está bien.
Paso 4: Múltiples números separados por coma
Escribiendo la prueba (rojo)
Este requisito está cubierto implícitamente por nuestra implementación actual, pero siempre es una buena práctica escribir una prueba explícita para asegurar que nuestra lógica es robusta.
// calculator_test.go
package calculator_test
import (
"calculator"
"testing"
)
// ... pruebas anteriores ...
func TestAdd_MultipleNumbersCommaSeparatedReturnsSum(t *testing.T) {
tests := []struct {
input string
expected int
}{
{"1,2,3", 6},
{"10,20,30,40", 100},
{"0,0,0", 0},
}
for _, test := range tests {
result, err := calculator.Add(test.input)
if err != nil {
t.Fatalf("se esperaba nil error para '%s', se obtuvo: %v", test.input, err)
}
if result != test.expected {
t.Errorf("para '%s', se esperaba %d, se obtuvo: %d", test.input, test.expected, result)
}
}
}
Curiosamente, esta prueba debería pasar de inmediato porque nuestra lógica de dividir y sumar en un bucle ya maneja múltiples partes. A veces, las pruebas adicionales confirman que una solución existente ya es más general de lo que se esperaba. No obstante, escribir la prueba sigue siendo crucial para documentar el comportamiento esperado y garantizar que futuras refactorizaciones no rompan esta funcionalidad. Esto es un "rojo" que se convierte en "verde" sin cambios de código, lo cual no es lo ideal en TDD estricto, pero sucede cuando el paso anterior ya abordó la generalidad.
Haciendo que pase (verde)
No se necesitan cambios de código, ya que la implementación actual ya pasa esta prueba.
Refactorizando (azul/gris)
Nada que refactorizar en este paso.
Paso 5: Manejando saltos de línea como delimitadores
Escribiendo la prueba (rojo)
Ahora, necesitamos que nuestra función también acepte \n como delimitador.
// calculator_test.go
package calculator_test
import (
"calculator"
"testing"
)
// ... pruebas anteriores ...
func TestAdd_NewLinesAsDelimiters(t *testing.T) {
tests := []struct {
input string
expected int
}{
{"1\n2,3", 6},
{"1\n2\n3", 6},
{"4,5\n6", 15},
}
for _, test := range tests {
result, err := calculator.Add(test.input)
if err != nil {
t.Fatalf("se esperaba nil error para '%s', se obtuvo: %v", test.input, err)
}
if result != test.expected {
t.Errorf("para '%s', se esperaba %d, se obtuvo: %d", test.input, test.expected, result)
}
}
}
Ejecutar go test hará que estas pruebas fallen, ya que nuestra función solo sabe dividir por comas. "Rojo".
Haciendo que pase (verde)
Podemos reemplazar los saltos de línea con comas antes de dividir, o usar una función que divida por múltiples delimitadores. Optaremos por la primera opción por simplicidad.
// calculator.go
package calculator
import (
"strconv"
"strings"
)
func Add(numbers string) (int, error) {
if numbers == "" {
return 0, nil
}