Las katas de esta serie son ejercicios propuestos en el excelente curso Testing Sostenible con TypeScript de Miguel A. Gómez y Carlos Blé
Introducción
FizzBuzz es un problema que ya se ha convertido en un clásico de la programación debido a que comenzó a ser una pregunta común de entrevista hace algunos años. El problema es muy sencillo, hay que crear una función que tome un número natural ( a ) y cumpla con las siguientes condiciones:
- Si un número es divisible entre 3, entonces debe regresar
"fizz"
. - Si un número es divisible entre 5, entonces debe regresar
"buzz"
. - Si un número es divisible entre 3 y 5, entonces debe regresar
"fizzbuzz"
. - Si un número no cumple las condiciones anteriores, entonces debe regresar el propio número como cadena de texto.
En este artículo veremos cómo crear una función fizzBuzz
que cumpla con estas condiciones utilizando Jest, Test Driven Development (TDD) y una técnica conocida como Red-Green-Refactor
.
Tabla de Contenido
Red-Green-Refactor
Esta técnica consiste en un ciclo de tres fases:
Red
. Creamos una prueba con la intención de que falle, entonces nos aparecerá en color rojo (red).Green
. Una vez que la prueba falle, escribimos la implementación mínima necesaria para que la prueba pase. Una prueba exitosa nos aparecerá en color verde (green).Refactor
. Ya que tenemos una implementación que pasa la prueba, es hora de refactorizar para que el código sea más simple, siga buenas prácticas y sea fácil de mantener. Claramente, hay que ejecutar la(s) prueba(s) después de este proceso para verificar que la implementación todavía sea funcional.
Estas fases se realizan en ciclo hasta que todos los casos estén cubiertos y las pruebas sean exitosas. Esta técnica surge naturalmente a partir del concepto de TDD, pues el desarrollo se basa en pruebas, así que creamos pruebas primero e implementaciones después.
¿Esperabas una imagen bonita con el ciclo? Una disculpa, pero eso de encontrar imágenes libres de derechos no está fácil y se complica más si son técnicas
Es importante mencionar que realizar este proceso nos permite tener una idea de la estructura que debe tener el proyecto, porque para hacer pruebas nos tenemos que preguntar qué debe hacer alguna parte del sistema. Por lo tanto, casi sin darnos cuenta, construimos una funcionalidad que vista desde la implementación dice cómo hacerlo, pero para lograrlo solo tuvimos que preocuparnos por el objetivo a alcanzar.
Preparación del entorno
No sé si alguien más lo ha dicho, pero como sea, un package.json
dice más que mil palabras:
{
"name": "ts-eslint-prettier-jest-minimal",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"analize": "npm run lint:fix && npm run compile",
"compile": "tsc --noEmit",
"compile:watch": "npm run compile -- --watch",
"compile:build": "tsc -b",
"lint": "eslint . --ext .ts,.tsx",
"lint:watch": "esw --color --watch",
"lint:fix": "npm run lint -- --fix",
"format": "prettier --config .prettierrc '**/*.+(ts|tsx)'",
"format:fix": "npm run format -- --write",
"test": "jest --verbose",
"test:watch": "npm run test -- --watchAll",
"test:coverage": "npm run test -- --coverage",
"upgrade": "ncu -u"
},
"author": "Softwarecrafters.io",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.12",
"@typescript-eslint/eslint-plugin": "^7.7.0",
"@typescript-eslint/parser": "^7.7.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-watch": "^8.0.0",
"husky": "^9.0.11",
"jest": "^29.7.0",
"lint-staged": "^15.2.2",
"npm-check-updates": "^16.14.18",
"prettier": "^3.2.5",
"ts-jest": "^29.1.2",
"typescript": "^5.4.5",
"@types/node": "^20.12.7"
},
"husky": {
"hooks": {
"pre-commit": "lint-staged",
"pre-push": "npm run test"
}
},
"lint-staged": {
"*.+(ts|tsx)": [
"npm run lint:fix",
"npm run compile",
"git add . "
]
}
}
Este package.json
es un buen punto de partida para un proyecto con TypeScript, Jest, Husky, ESLint y Prettier, cortesía de los creadores del curso. Puedes encontrar la plantilla aquí. Como se puede ver, para ejecutar nuestras pruebas usaremos el comando npm test
.
Aplicación de Red-Green-Refactor
Antes que nada, vamos a crear dos archivos: fizzbuzz.ts
, el cual contendrá la función fizzBuzz
; y fizzbuzz.test.ts
, el cual contendrá las pruebas.
// src/core/fizzbuzz.ts
export function fizzbuzz(number: number) {
// implementación aquí
}
// src/tests/fizzbuzz.test.ts
import {fizzBuzz} from "../core/fizzbuzz";
Para agrupar nuestras pruebas, vamos a definir una suite que contenga como descripción "FizzBuzz"
.
// src/tests/fizzbuzz.test.ts
describe("FizzBuzz", () => {
// pruebas aquí
});
Aplicando Red-Green-Refactor
, en la fase Red
debemos crear una prueba con la intención de que falle, y después hacer la implementación mínima para que pasar la prueba y luego refactorizar. Empecemos con un caso muy sencillo: la prueba para el número 1
. Debe regresar "1"
porque 1
no es divisible entre 3
ni 5
.
// src/tests/fizzbuzz.test.ts
it("should return 1 for the number 1", () => {
const result = fizzBuzz(1);
const expected = "1";
expect(result).toBe(expected);
});
Si corremos las pruebas con npm test
, fallarán con ese Red
esperado, porque la función hasta el momento no hace nada, o, técnicamente, regresa undefined
.
Ahora pasamos a la fase de Green
, en donde hacemos la implementación mínima para que pase la prueba, y con mínima me refiero a solo preocuparnos por eso, porque solo tenemos un caso, entonces simplemente regresamos "1"
.
// src/core/fizzbuzz.ts
export function fizzbuzz(number: number) {
return "1";
}
Si corremos las pruebas ahora, obtendremos ese Green
que buscábamos:
Ahora llegaríamos a la fase de Refactor
, pero hasta este punto no hay nada que refactorizar, no podemos simplificar más ese return
. Entonces pasamos a Red
otra vez. Ahora, vamos a crear una prueba para el número 3
, para el que debe regresar "fizz"
.
// src/tests/fizzbuzz.test.ts
it("should return 'fizz' for the number 3", () => {
const result = fizzBuzz(3);
const expected = "fizz";
expect(result).toBe(expected);
});
Ya sabemos qué ocurrirá: la nueva prueba fallará. Para no llenar esto de imágenes redundantes, sabemos que veremos Red
. Entonces, ahora pasamos a Green
y realizamos la implementación mínima, verificando que la función todavía pase la prueba anterior. Para esto, simplemente agregaremos un condicional para regresar "fizz"
cuando number
sea 3
.
// src/core/fizzbuzz.ts
export function fizzBuzz(number: number) {
if (number === 3) {
return "fizz";
}
return "1";
}
Aquí, veremos Green
al correr las pruebas. En la fase de Refactor
tampoco habría nada por hacer, no se puede simplificar más (las generalizaciones las haremos más adelante). Llegamos de nuevo a la fase de Red
y esta vez creamos la prueba para el número 5
, para el que nos debe regresar "buzz"
.
// src/tests/fizzbuzz.test.ts
it("should return 'buzz' for the number 5", () => {
const result = fizzBuzz(5);
const expected = "buzz";
expect(result).toBe(expected);
});
Al fallar la prueba, ahora tenemos que implementar lo necesario para que la función la pase. Para esto, realizaremos lo mismo que con el número 3
, ajustado al caso del 5
:
// src/core/fizzbuzz.ts
export function fizzBuzz(number: number) {
if (number === 3) {
return "fizz";
}
if (number === 5) {
return "buzz";
}
return "1";
}
Ahora veremos Green
en las pruebas. Pasando a la fase de Refactor
, de nuevo, no hay nada por hacer. Llegamos de nuevo a la fase de Red
, y ahora, crearemos la prueba para el número 15
, el cual debe regresarnos "fizzbuzz"
.
// src/tests/fizzbuzz.test.ts
it("should return 'fizzbuzz' for the number 15", () => {
const result = fizzBuzz(15);
const expected = "fizzbuzz";
expect(result).toBe(expected);
});
Al fallar las pruebas, pasamos a Green
, en donde haremos una implementación similar a las anteriores:
// src/core/fizzbuzz.ts
export function fizzBuzz(number: number) {
if (number === 3) {
return "fizz";
}
if (number === 5) {
return "buzz";
}
if (number === 15) {
return "fizzbuzz";
}
return "1";
}
La nueva prueba pasará ahora. Una vez más, no hay nada por hacer en Refactor
. Otra vez estamos en Red
, y la prueba por hacer es la primera generalización: vamos a probar el caso donde debe regresar "fizz"
para cualquier número divisible entre 3
.
// src/tests/fizzbuzz.test.ts
it("should return 'fizz' for a number divisible by 3", () => {
const result = fizzBuzz(6);
const expected = "fizz";
expect(result).toBe(expected);
});
La prueba fallará. Avanzamos a Green
y en la implementación debemos checar que si el módulo del número dividido entre 3
es 0
, entonces hay que regresar "fizz"
.
// src/core/fizzbuzz.ts
export function fizzBuzz(number: number) {
if (number === 3) {
return "fizz";
}
if (number === 5) {
return "buzz";
}
if (number === 15) {
return "fizzbuzz";
}
if (number % 3 === 0) {
return "fizz";
}
return "1";
}
Colocamos el condicional al final porque, de lo contrario, la prueba de 15
fallaría. Si corremos las pruebas, la nueva prueba ahora pasará. Ahora, tenemos la novedad de que sí tenemos que hacer algo en Refactor
: eliminar el condicional que checa por la igualdad con 3
, porque ahora lo cubre el nuevo condicional.
// src/core/fizzbuzz.ts
export function fizzBuzz(number: number) {
if (number === 5) {
return "buzz";
}
if (number === 15) {
return "fizzbuzz";
}
if (number % 3 === 0) {
return "fizz";
}
return "1";
}
Claro que debemos checar que las pruebas sigan pasando. Después de esto, regresamos a Red
. En esta ocasión, crearemos la prueba para el caso donde debe regresar "buzz"
para cualquier número divisible entre 5
.
// src/tests/fizzbuzz.test.ts
it("should return 'buzz' for a number divisible by 5", () => {
const result = fizzBuzz(10);
const expected = "buzz";
expect(result).toBe(expected);
});
La nueva prueba fallará. Ahora estamos en Green
. La implementación es similar a la anterior:
// src/core/fizzbuzz.ts
export function fizzBuzz(number: number) {
if (number === 5) {
return "buzz";
}
if (number === 15) {
return "fizzbuzz";
}
if (number % 3 === 0) {
return "fizz";
}
if (number % 5 === 0) {
return "buzz";
}
return "1";
}
Ahora, la nueva prueba pasará. De manera similar, en la fase de Refactor
eliminaremos el condicional que checa por el número 5
exactamente:
// src/core/fizzbuzz.ts
export function fizzBuzz(number: number) {
if (number === 15) {
return "fizzbuzz";
}
if (number % 3 === 0) {
return "fizz";
}
if (number % 5 === 0) {
return "buzz";
}
return "1";
}
Estamos de nuevo en Red
. La prueba que crearemos ahora es para el caso donde debe regresar "fizzbuzz"
para un número divisible entre 15
. ¿Por qué 15
? Porque si recordamos una de las condiciones: "Si un número es divisible entre 3 y 5, entonces regresa "fizzbuzz"
". Esto es lo mismo a decir que el número es divisible entre .
// src/tests/fizzbuzz.test.ts
it("should return 'fizzbuzz' for a number divisible by 15", () => {
const result = fizzBuzz(30);
const expected = "fizzbuzz";
expect(result).toBe(expected);
});
La prueba fallará. Pasamos a la fase de Green
y para la implementación hacemos algo similar a lo que hicimos con 3
y 5
.
// src/core/fizzbuzz.ts
export function fizzBuzz(number: number) {
if (number === 15) {
return "fizzbuzz";
}
if (number % 15 === 0) {
return "fizzbuzz";
}
if (number % 3 === 0) {
return "fizz";
}
if (number % 5 === 0) {
return "buzz";
}
return "1";
}
Colocamos el condicional por encima de los otros dos porque, si es divisible tanto entre 3
como entre 5
, entonces debe tener prioridad que regrese "fizzbuzz"
. Una vez que las pruebas pasen, seguimos con la fase de Refactor
, en donde haremos algo similar a lo que hicimos con 3
y 5
: eliminar el condicional que checa exactamente por 15
.
// src/core/fizzbuzz.ts
export function fizzBuzz(number: number) {
if (number % 15 === 0) {
return "fizzbuzz";
}
if (number % 3 === 0) {
return "fizz";
}
if (number % 5 === 0) {
return "buzz";
}
return "1";
}
Regresamos a la fase de Red
para crear la última prueba para el caso donde debe regresar el propio número como cadena de texto si no cumple con ninguna de las condiciones anteriores.
// src/tests/fizzbuzz.test.ts
it("should return the string of the number itself for the rest of numbers", () => {
const result = fizzBuzz(28);
const expected = "28";
expect(result).toBe(expected);
});
La prueba fallará, pasaremos a Green
y realizaremos la implementación para que en lugar de "1"
, regrese el número convertido a cadena de texto cuando no cumple con ninguna de las condiciones establecidas.
// src/core/fizzbuzz.ts
export function fizzBuzz(number: number) {
if (number % 15 === 0) {
return "fizzbuzz";
}
if (number % 3 === 0) {
return "fizz";
}
if (number % 5 === 0) {
return "buzz";
}
return number.toString();
}
La nueva prueba ahora pasará. Se podría decir que aquí se hizo refactorización, pero en realidad no, simplemente realizamos la implementación con lo mínimo necesario para pasar la prueba, así que algo como dejar el "1"
con un condicional y number.toString()
como default, hubiera sido más que lo mínimo. Ahora, en la fase de Refactor
podemos mejorar la función para utilizar una función auxiliar isDivisibleBy
que tome el divisor y cheque si el número es divisible entre dicho divisor. Nos queda lo siguiente:
// src/core/fizzbuzz.ts
export function fizzBuzz(number: number) {
function isDivisibleBy(divisor: number) {
return number % divisor === 0;
}
if (isDivisibleBy(15)) return "fizzbuzz";
if (isDivisibleBy(3)) return "fizz";
if (isDivisibleBy(5)) return "buzz";
return number.toString();
}
De esta forma, hemos logrado crear una función fizzBuzz
que cumpla con los requisitos dados simplemente pensando en pruebas que debe pasar. A este pequeño truco le llamamos TDD.
Un pequeño detalle
En la introducción se mencionó que el problema de FizzBuzz es para números naturales, pero en la función no se checa por esto, no hay una condición para revisar que si el número es menor que 1, entonces debe tirar un error. Podría agregarse la condición y también agregar una prueba para eso, pero en términos prácticos, se puede dejar lo que tenemos ahora y para este caso regresaría el propio número convertido a cadena de texto.
Enlace al repositorio de GitHub
Puedes encontrar esta kata, y el resto de ellas, aquí.
Conclusión
Tristemente, es común en los equipos de desarrollo que ni siquiera se realicen pruebas en código, o que se realicen, pero no sean de calidad porque no revisan un comportamiento en específico o no aportan en realidad nada de seguridad en el código. Aquí podemos ver que utilizar TDD es sencillo cuando es bien entendido, y el ciclo Red-Green-Refactor
es fácil de seguir si tenemos claro el comportamiento de lo que estamos probando. A partir de esto, yo no veo ninguna excusa para no crear pruebas en los proyectos. No es ningún santo grial, pero bien empleado cuando corresponda, el TDD es un aliado muy poderoso a la hora de desarrollar software.
Espero que este pequeño ejemplo te haya ayudado a entender mejor a qué se refiere desarrollar software pensando primero en las pruebas, y que te motive a realizar pruebas con la misma pasión con la que realizas implementaciones. Si tienes alguna duda o quieres compartir algo, déjalo en los comentarios :)