El desarrollo guiado por pruebas (TDD, por sus siglas en inglés, Test-Driven Development) es una disciplina fundamental en el mundo del desarrollo de software moderno. No es solo una metodología para escribir pruebas, sino una filosofía de diseño que influye profundamente en la arquitectura de nuestro código, su mantenibilidad y su robustez. Para muchos, TDD puede parecer inicialmente contraintuitivo o una carga adicional, pero una vez que se experimentan sus beneficios, se convierte en una herramienta invaluable. Una de las mejores maneras de interiorizar TDD es a través de las "katas". Al igual que en las artes marciales, una kata de programación es un ejercicio repetitivo diseñado para mejorar una habilidad específica a través de la práctica constante y deliberada. En este post, exploraremos una kata TDD utilizando JavaScript, implementando un conocido problema que nos permitirá observar de primera mano el ciclo "rojo-verde-refactorizar". Mi objetivo es guiarte a través de un proceso que no solo te ayude a comprender TDD, sino que también te muestre cómo esta práctica puede llevar a un código más limpio y modular desde el principio.
¿Qué es una kata TDD?
Una kata TDD es un pequeño problema de programación que se resuelve aplicando rigurosamente los principios de TDD. La idea es practicar la técnica, no necesariamente resolver un problema complejo o crear una funcionalidad de negocio crítica. Es como los ejercicios de escalas para un músico o las formas para un artista marcial: son movimientos fundamentales que se repiten hasta que se convierten en algo instintivo. A través de las katas, los desarrolladores pueden mejorar su habilidad para escribir pruebas unitarias efectivas, refactorizar código sin miedo y, quizás lo más importante, diseñar software con una mentalidad orientada a la prueba, lo que a menudo resulta en un diseño más desacoplado y flexible.
Los beneficios de participar regularmente en katas TDD son múltiples:
- Dominio de TDD: Ayuda a automatizar el ciclo rojo-verde-refactorizar, haciendo que la escritura de pruebas sea una segunda naturaleza.
- Mejora de la calidad del código: Fomenta la creación de código más limpio, modular y fácil de entender.
- Confianza en el refactoring: Al tener una suite de pruebas robusta, los desarrolladores pueden refactorizar con la seguridad de que no están rompiendo funcionalidades existentes.
- Claridad de requisitos: La escritura de pruebas antes del código obliga a una comprensión más profunda de lo que la funcionalidad debe hacer.
- Desarrollo de habilidades de diseño: Promueve un diseño incremental y la identificación temprana de problemas de diseño.
A mi parecer, las katas son una herramienta subestimada en la formación continua de los desarrolladores. La teoría es importante, pero la práctica deliberada en un entorno de bajo riesgo es donde realmente se asientan las habilidades.
Configurando nuestro entorno de desarrollo en JavaScript
Para nuestra kata, utilizaremos JavaScript, un lenguaje extremadamente versátil y omnipresente. Como framework de pruebas, optaremos por Jest, una elección popular y robusta desarrollada por Facebook, conocida por su simplicidad y excelente rendimiento, especialmente en proyectos React, aunque es perfectamente adaptable a cualquier proyecto JS.
Preparando nuestro proyecto con Jest
Primero, asegúrate de tener Node.js instalado en tu sistema. Una vez hecho esto, crearemos un nuevo directorio para nuestra kata y lo inicializaremos:
mkdir string-calculator-kata
cd string-calculator-kata
npm init -y
Ahora, instalaremos Jest como una dependencia de desarrollo:
npm install --save-dev jest
Para que Jest sea fácil de usar, añadiremos un script de prueba en nuestro package.json. Abre el archivo package.json y modifica la sección scripts para que se vea así:
{
"name": "string-calculator-kata",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "jest --watchAll"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"jest": "^29.7.0"
}
}
El flag --watchAll es muy útil porque Jest se ejecutará automáticamente cada vez que detecte cambios en nuestros archivos, facilitando el ciclo TDD. Ahora estamos listos para comenzar nuestra kata.
La kata: la calculadora de cadenas
La kata de la calculadora de cadenas (String Calculator) es un ejercicio clásico que nos permite explorar de forma incremental los pasos de TDD. Empezaremos con requisitos muy básicos y los iremos añadiendo poco a poco.
Nuestro objetivo es crear una función add que reciba una cadena de texto y devuelva un número.
Paso 1: cadena vacía
Requisito: Si la cadena de entrada está vacía, la función add debe devolver 0.
Este es nuestro punto de partida más simple.
Código (Red): Escribimos la prueba que fallará.
Crearemos dos archivos: stringCalculator.js para nuestra lógica y stringCalculator.test.js para nuestras pruebas.
stringCalculator.js (inicialmente vacío o con una exportación mínima):
// stringCalculator.js
const add = (numbers) => {
// Nuestra implementación irá aquí
return 0; // Solo para que el primer test pase inicialmente si no queremos un error de "función no definida"
};
module.exports = add;
stringCalculator.test.js:
// stringCalculator.test.js
const add = require('./stringCalculator');
describe('add', () => {
test('debería devolver 0 para una cadena vacía', () => {
expect(add('')).toBe(0);
});
});
Ejecuta npm test. Verás que la prueba falla (o pasa si ya hemos añadido return 0; en add). Si aún no has escrito la lógica, Jest te dirá que add no es una función o que falla la expectativa.
Código (Green): Escribimos el código mínimo para que la prueba pase.
stringCalculator.js:
// stringCalculator.js
const add = (numbers) => {
if (numbers === '') {
return 0;
}
// No necesitamos más por ahora
};
module.exports = add;
Ejecuta npm test de nuevo. ¡Debería pasar! 🎉
Refactorizar: En este punto, no hay mucho que refactorizar. Nuestro código es simple y claro.
Paso 2: un solo número
Requisito: Si la cadena contiene un solo número, la función add debe devolver ese número.
Código (Red): Escribimos la prueba que fallará.
stringCalculator.test.js:
// stringCalculator.test.js
const add = require('./stringCalculator');
describe('add', () => {
test('debería devolver 0 para una cadena vacía', () => {
expect(add('')).toBe(0);
});
test('debería devolver el número para una cadena con un solo número', () => {
expect(add('1')).toBe(1);
expect(add('5')).toBe(5);
});
});
Ejecuta npm test. Nuestras nuevas pruebas fallarán porque nuestra función actual solo maneja la cadena vacía.
Código (Green): Escribimos el código mínimo para que las pruebas pasen.
stringCalculator.js:
// stringCalculator.js
const add = (numbers) => {
if (numbers === '') {
return 0;
}
return parseInt(numbers); // Convierte la cadena a número
};
module.exports = add;
Ejecuta npm test. ¡Todas las pruebas deberían pasar!
Refactorizar: Todavía es un código muy simple. parseInt es una función estándar y adecuada aquí.
Paso 3: dos números separados por coma
Requisito: Si la cadena contiene dos números separados por una coma, la función add debe devolver su suma.
Código (Red): Escribimos la prueba que fallará.
stringCalculator.test.js:
// stringCalculator.test.js
// ...
test('debería devolver la suma de dos números separados por coma', () => {
expect(add('1,2')).toBe(3);
expect(add('5,5')).toBe(10);
});
// ...
Ejecuta npm test. Esta prueba fallará porque parseInt('1,2') no devolverá 3.
Código (Green): Escribimos el código mínimo para que las pruebas pasen.
stringCalculator.js:
// stringCalculator.js
const add = (numbers) => {
if (numbers === '') {
return 0;
}
if (numbers.includes(',')) {
const parts = numbers.split(',');
const num1 = parseInt(parts[0]);
const num2 = parseInt(parts[1]);
return num1 + num2;
}
return parseInt(numbers);
};
module.exports = add;
Ejecuta npm test. ¡Todas las pruebas deberían pasar!
Refactorizar: Podemos empezar a ver cierta duplicación o caminos que se podrían unificar. Por ejemplo, si tenemos 1,2, también podemos pensarlo como una lista de números.
Paso 4: múltiples números
Requisito: La función add debe manejar un número desconocido de números.
Código (Red): Escribimos la prueba que fallará.
stringCalculator.test.js:
// stringCalculator.test.js
// ...
test('debería devolver la suma de múltiples números', () => {
expect(add('1,2,3')).toBe(6);
expect(add('10,20,30,40')).toBe(100);
});
// ...
Ejecuta npm test. Estas pruebas fallarán porque nuestra lógica actual solo maneja dos números.
Código (Green): Escribimos el código mínimo para que las pruebas pasen.
Para manejar múltiples números, podemos dividir la cadena por la coma y luego sumar todos los elementos.
stringCalculator.js:
// stringCalculator.js
const add = (numbers) => {
if (numbers === '') {
return 0;
}
const numbersArray = numbers.split(',').map(num => parseInt(num));
return numbersArray.reduce((sum, current) => sum + current, 0);
};
module.exports = add;
Ejecuta npm test. ¡Todas las pruebas deberían pasar! Y mira, nuestro código se ha simplificado considerablemente. ¡Esto es TDD en acción! Al añadir un nuevo requisito, pudimos refactorizar y mejorar la solución existente. La belleza de esto es que cada refactorización se realiza con la seguridad de que nuestras pruebas previas nos alertarán si rompemos algo.
Refactorizar: Este código ya es bastante limpio. La cadena vacía se maneja elegantemente porque split(',') en una cadena vacía devuelve [''], y parseInt('') devuelve NaN. Sin embargo, map(num => parseInt(num)) en [''] resultaría en [NaN], y reduce sobre [NaN] daría NaN. Esto significa que nuestra condición if (numbers === '') es aún necesaria. O podríamos filtrar los NaNs, pero por ahora, la condición explícita está bien y es legible.
Paso 5: manejar nuevas líneas como delimitadores
Requisito: Además de las comas, la función add debe aceptar nuevas líneas (\n) como delimitadores. Por ejemplo, "1\n2,3" debería devolver 6.
Código (Red): Escribimos la prueba que fallará.
stringCalculator.test.js:
// stringCalculator.test.js
// ...
test('debería manejar nuevas líneas como delimitadores', () => {
expect(add('1\n2,3')).toBe(6);
expect(add('1\n2\n3')).toBe(6);
});
// ...
Ejecuta npm test. Fallarán porque split(',') no maneja \n.
Código (Green): Escribimos el código mínimo para que las pruebas pasen.
Ahora necesitamos dividir por comas O por nuevas líneas. Una forma es reemplazar las nuevas líneas con comas antes de dividir, o usar una expresión regular con split. Optemos por la expresión regular para mayor flexibilidad.
stringCalculator.js:
// stringCalculator.js
const add = (numbers) => {
if (numbers === '') {
return 0;
}
// Dividir por coma o nueva línea
const numbersArray = numbers.split(/[\n,]/).map(num => parseInt(num));
return numbersArray.reduce((sum, current) => sum + current, 0);
};
module.exports = add;
Ejecuta npm test. ¡Todas las pruebas deberían pasar! ¡Fantástico!
Refactorizar: El código es ahora más robusto y conciso gracias a la expresión regular. Podríamos considerar un manejo más específico de NaN si queremos ser estrictos sobre entradas no numéricas, pero para esta kata, parseInt es suficiente.
Paso 6: soporte para delimitadores diferentes
Requisito: Se puede especificar un delimitador diferente al principio de la cadena. El formato será "//[delimitador]\n[números]". Por ejemplo, "//;\n1;2" debería devolver 3.
Código (Red): Escribimos la prueba que fallará.
stringCalculator.test.js:
// stringCalculator.test.js
// ...
test('debería soportar un delimitador personalizado', () => {
expect(add('//;\n1;2')).toBe(3);
expect(add('//|\n1|2|3')).toBe(6);
});
// ...
Ejecuta npm test. Estas pruebas fallarán.
Código (Green): Escribimos el código mínimo para que las pruebas pasen.
Necesitamos detectar si hay un delimitador personalizado. Si lo hay, lo extraemos y lo usamos para dividir la cadena.
stringCalculator.js:
// stringCalculator.js
const add = (numbers) => {
if (numbers === '') {
return 0;
}
let delimiter = /[\n,]/; // Delimitador por defecto
let numbersToParse = numbers;
// Comprobar si hay un delimitador personalizado
if (numbers.startsWith('//')) {
const delimiterEndIndex = numbers.indexOf('\n');
const customDelimiter = numbers.substring(2, delimiterEndIndex);
delimiter = new RegExp(`[\\n,${customDelimiter}]`); // Incluir el nuevo delimitador
numbersToParse = numbers.substring(delimiterEndIndex + 1);
}
const numbersArray = numbersToParse.split(delimiter).map(num => parseInt(num));
return numbersArray.reduce((sum, current) => sum + current, 0);
};
module.exports = add;
Ejecuta npm test. ¡Todas las pruebas deberían pasar! Un pequeño detalle a tener en cuenta es que new RegExp necesita que los caracteres especiales se escapen si el delimitador personalizado es uno de ellos. Para un ejercicio más avanzado, podríamos manejar eso, pero para esta kata, asumiremos delimitadores simples. Para más detalles sobre expresiones regulares, puedes consultar la documentación de MDN.
Refactorizar: Nuestro add se está volviendo un poco más complejo. La lógica para extraer el delimitador podría extraerse a una función auxiliar si crece mucho.
Paso 7: números negativos
Requisito: Si la cadena contiene números negativos, la función add debe lanzar una excepción con el mensaje "negativos no permitidos: " seguido de los números negativos encontrados.
Código (Red): Escribimos la prueba que fallará.
stringCalculator.test.js:
// stringCalculator.test.js
// ...
test('debería lanzar una excepción para números negativos', () => {
expect(() => add('-1,2')).toThrow('negativos no permitidos: -1');
expect(() => add('1,-2,3,-4')).toThrow('negativos no permitidos: -2,-4');
expect(() => add('//;\n-1;-2')).toThrow('negativos no permitidos: -1,-2');
});
// ...
Ejecuta npm test. Estas pruebas fallarán.
Código (Green): Escribimos el código mínimo para que las pruebas pasen.
Después de parsear los números, debemos filtrarlos para encontrar los negativos.
stringCalculator.js:
// stringCalculator.js
const add = (numbers) => {
if (numbers === '') {
return 0;
}
let delimiter = /[\n,]/;
let numbersToParse = numbers;
if (numbers.startsWith('//')) {
const delimiterEndIndex = numbers.indexOf('\n');
const customDelimiter = numbers.substring(2, delimiterEndIndex);
// Escapar el delimitador personalizado para la RegExp si fuera necesario
const escapedDelimiter = customDelimiter.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
delimiter = new RegExp(`[\\n,${escapedDelimiter}]`);
numbersToParse = numbers.substring(delimiterEndIndex + 1);
}
const numbersArray = numbersToParse.split(delimiter).map(num => parseInt(num));
const negativeNumbers = numbersArray.filter(num => num < 0);
if (negativeNumbers.length > 0) {
throw new Error(`negativos no permitidos: ${negativeNumbers.join(',')}`);
}
return numbersArray.reduce((sum, current) => sum + current, 0);
};
module.exports = add;
Ejecuta npm test. ¡Todas las pruebas deberían pasar! Nota que he añadido una pequeña mejora al escapar el delimitador personalizado en la expresión regular. Esto es un ejemplo de refactoring que se hace a menudo: al encontrar un caso que podría causar un error (un delimitador como . o *), lo corregimos inmediatamente.
Paso 8: ignorar números mayores de 1000
Requisito: Los números mayores de 1000 deben ser ignorados. Por ejemplo, "1001,2" debería devolver 2.
Código (Red): Escribimos la prueba que fallará.
stringCalculator.test.js: