6

Cómo adaptarse al desarrollo guiado por test (TDD)

En este artículo vamos a resolver un problema de lógica sacado de codewars.com, el lenguaje que usaremos es JavaScript.

No sé si te has fijado que en plataformas como CodeWars, LeetCode, HackerRank, etc. luego de resolver el problema y subir el código, te dicen si es correcto o no; bueno, eso se logra gracias a test runners ( Jest, Mocha, Chai, Jasmine, Karma, etc. ) que prueban los casos de uso en tu función y validan de este modo si es correcto o no.

Nosotros vamos a hacer lo mismo para cada problema que hagamos en estas plataformas.

Configurando el entorno de pruebas

Suponiendo que tiene los conocimientos básicos y eres capas de manejar la terminal y Visual Studio Code y además tienes Node.js instalado previamente. Crea un directorio con el nombre ( problemas ) y sigas estos pasos:

git init
npm init -y

Instalamos Jest

npm i -D jest

Agregamos el script al package.json para ejecutar las pruebas

"scripts": {
    "test": "jest",
    "test:cov": "jest --coverage"
  },
// tambien puedes usar "test:w":"jest --watch"

Nota: Sigue los pasos de configuración del jest --init y elige las opciones que más se acomoden a este proyecto, ejemplo yo trabajo con module.export y require, que son de Node.js.

Opcional: Crea una carpeta src y dentro puedes organizar los problemas por número ( prob1, p1, problem1, etc. ). Por lo general debes usar 3 archivos por problema, un readme con el enunciado, un archivo con la función y un archivo de test:

- src/
    -- problem1/
        --- p1-readme.md
        --- p1.js
        --- p1.test.js

TDD

Una vez leído y comprendido el enunciado del problema:

  1. Lo primero es escribir un test sencillo, luego lo ejecutamos con el script y como no tenemos función este falla ***( red/rojo )***.
  2. Luego escribimos la función para que pase el test ***( green/verde )***.
  3. Y finalmente mejoramos el código de nuestro test y de nuestra función ***( refactor/refactorizar )***.
  4. Repetimos el proceso, escribimos otro test y así hasta cubrir con pruebas todos los casos de uso de la función ( haciendo que la función resuelva el problema al final ).
  • REDGREENREFACTOR

Desglozando los requerimientos del problema

Este tipo de problemas requiere hacer funciones que tenga valores de entrada y valores de salida. Un primer test para comenzar sería comprobar una condición de entrada ( no son necesarias para resolver el problema en sí pero es buena práctica para tu futuro trabajo como desarrollador ).

Nos piden una función llamada cakes() a la cual se le pasan dos argumentos. El primero es un ojeto que representa la receta del cake y el segundo argumento también es un objeto pero representa los ingredientes que tiene Pete para hacer esa receta:

cakes( recipe, ingredients )

Además, para no poner unidades, asegura que el valor de cada key de estos objetos son números enteros positivos. Las keys que no esten en el objeto se consideran de valor cero.

const recipe = {
    sugar: 200,
    flour: 2
}

const ingredients = {
    sugar: 200,
    eggs: 5
}
// flour es 0 en ingredients

Finalmente, Pete quiere saber cuantos cakes puede hacer con los ingredientes que tiene dada una receta. Ayudemos a Pete que es malo en matemáticas 😃

Primer Test

Escribiremos nuestro primer test validando una de las condiciones de entrada, que la función reciba dos parámetros de entrada de tipo objeto y no otro tipo de dato:

// p1.test.jsconst cakes = require("./p1")

it('should recive only two object', () => {
      const recipe = {
        sugar: 10,
      }
      const recipe1 = 123const ingredient1 = 'sugar'const ingredient2 = 12const ingredient3 = ['sugar', '12']
      const ingredient4 = {
        sugar: 103,
      }

      expect(cakes(recipe, ingredient1)).toBeNull()
      expect(cakes(recipe1, ingredient1)).toBeNull()
      expect(cakes(recipe, ingredient2)).toBeNull()
      expect(cakes(recipe, ingredient3)).toBeNull()
      expect(cakes(recipe, ingredient4)).not.toBeNull()
})

(RED)
El test va a intentar importar la función cakes() que no existe y va a dar error, en caso de que tengas el archivo con la función, va a intentar pasarle los parámetros y esperará que el valor obtenido sea null o no null; esto también dará error porque no hemos escrito la función.

Escribimos la función y para que pase, asegúrate que los parámetros de la función sean objetos y en caso de no serlo regresen null:

// p1.jsfunctioncakes(rec, ing) {
    if(!(typeof rec === 'object') || !(typeof ing === 'object')) returnnullif(rec.length || ing.length ) returnnullreturntrue
}

module.exports = cakes

(GREEN)
Ejecutamos el test y comprobamos que pase, de no hacerlo insistimos hasta que logremos cumplir la condición del test.

Cuidado con:

  • Falsos positivos: Que el test diga que está mal y en realidad todo está bien. En este caso, revisa la lógica de tu test, la sintaxis y los matcher para encontrar posibles errores.
  • Falsos negativos: Que el test diga que está todo bien, pero en realidad no se han identificado todos los casos de uso.
  • Happy path: Casos de uso tontos que sabes que si van a pasar los test sin poner en apuros a tu función(son falsos negativos).
  • Asegúrate de exportar la función en su archivo e importarla en el test para poderla usar.

(REFACTOR)
Un vez en green podemos hacer refactor de nuestro código para que el test sea más legile y la función esté mejor construida:

describe("cacke", () => {

  describe("should recive two object, recipe and ingredients",() => {
    it("should recive only two object", () => {
      const recipe = {
        sugar: 10
      }
      const ingredient1 = "sugar"const ingredient2 = 12const ingredient3 = ["sugar", "12"]
      const ingredient4 = {
        sugar: 103
      }

      expect(cakes(recipe, ingredient1)).toBeNull()
      expect(cakes(recipe, ingredient2)).toBeNull()
      expect(cakes(recipe, ingredient3)).toBeNull()
      expect(cakes(recipe, ingredient4)).not.toBeNull()
    })
  })
})

Cosas de Jest

Usemos el describe() de jest para organizar/anidar nuestros test y poderle dar un mejor sentido de lectura.
En el primer describe() escribimos el nombre de lo que vamos a testear. El segundo se refiere a las condiciones de entrada de la función y sus test se asegurarán de probar los casos de uso necesarios.
Por otro lado, it() o también test()(usa la palabra que más te guste), es la prueba como tal. Al escribirlo usamos la metodología AAA:

const cakes = require("./p1")

it("should recive two object", () => {
    // Arange (dado estos valores)const recipe = { sugar: 10 }
    const ingredient = "sugar"// Act (si ejecuamos la función con ellos)const result = cakes(recipe, ingredient1)
    // Assert (entonces debo esperar que)
    expect(result).toBeNull()
    // (en este caso espero que sea null result, de no serlo da error el test)
})

Nota: Los matchers ( como el .toBeNull() ) los pueden consultar en la documentación de Jest, no hay que saberlos todos, con unos cuantos se resuelven la mayoría de las situaciones. Las demás palabras reservadas como expect(), it() y describe() son siempre usadas con esta misma estructura para construir la prueba.

Resolución del problema

Muchas veces puedes tomar los ejemplos que da el ejercicio como casos de uso y crear los test a partir de ellos, pero también muchas veces no cubren todos los casos de uso.

Ejemplos dados:

// debe regresar 2
cakes(
    {flour: 500, sugar: 200, eggs: 1},
    {flour: 1200, sugar: 1200, eggs: 5, milk: 200}
    )
// debe regresar 0
cakes(
    {apples: 3,flour: 300, sugar: 150, milk: 100, oil: 100},
    {flour: 2000, sugar: 500, milk: 2000}
    )

Estos serían los test correspondientes:

describe("how many cakes can Pete do", () => {

    it("must return 2, ", () => {
        const recipe = {flour: 500, sugar: 200, eggs: 1}
        const ingredient = {flour: 1200, sugar: 1200, eggs: 5, milk: 200}
        expect(cakes(recipe, ingredient)).toBe(2)
    })

    it("must return 0, ", () => {
        const recipe = {apples: 3,flour: 300, sugar: 150, milk: 100, oil: 100}
        const ingredient = {flour: 2000, sugar: 500, milk: 2000}
        expect(cakes(recipe, ingredient)).toBe(0)
    })
})

Lugo el algoritmo que los resuelva en nuestra función sería como el siguiente:

Recorro el objeto de recetas y si alguna key no está en el objeto ingredientes, no podemos hacer la receta, regreso un 0. De lo contrario, al vamos a dividir la cantidad de cada ingrediente disponible entre los que necesita la receta y redondeamos el valor por defecto ( si tengo 300 de azúcar y necesito 120: 300/120 = 2.5 que se aproxima por defecto a 2 ). Luego ese número obtenido ( representa las veces que puedo usar ese ingrediente en la receta ) lo guardo en un arreglo. Luego ordenamos el arreglo de menor a mayor y regresamos el valor en la posición 0 ( hago esto, ya que la cantidad de veces que puedo hacer la receta completa está limitada por el ingrediente que menos tengo para esa receta ).

La función quedaría:

functioncakes(rec, ing) {
    /* ...las validaciones de entrada... */const cantCakes = () => {
        let arrIng = []
        for(let i in rec) {
            if(!ing[i]) return0
            arrIng.push(Math.floor(ing[i]/rec[i]))
        }
        arrIng.sort((a,b) => a - b)
        return arrIng[0]
    }

    return cantCakes()
}

Resultados

De tu parte queda intentar encontrar otros casos de uso de tu función o posibles bugs. También puedes analizar la complejidad algorítmica ( Temporal y Espacial ) y otras métricas que te ayuden a mejorar tu código. Te recomiendo hacer pruebas estáticas de tu código desde el editor con eslint, prettier y usar TypeScript; te ayudará a evitar errores humanos.

Coverage

Los test no te libran 100% de los errores, pero los minimizan bastante y el código se siente más preparado para producción. Las pruebas de covertura te dan un indicador en % de que tan probado está tu código. No es una camisa de fuerza tener todas las pruebas al 100% de covertura, pero, por otro lado, no es algo malo y sí puede llegar a aportar valor.

npm run test:cov

Al ejecutar este comando en la terminal generará el reporte de covertura, que no es más que una tabla. También creará una carpeta llamada coverage, dentro hay otra carpeta llamada Icov-report, donde encontrarán un index.html para visualizar mejor el reporte en el navegador ( Shift+Alt+r para abrir el explorador de archivos y luego abrir con el navegador ).

Ya no tienes escusa para no usar TDD cuando programas, los test son imprescindibles para encontrar un buen trabajo como desarrollador hoy en día y que reconozcan tus habilidades. Practica con varios ejercicios como este y verás que te será más sencillo aplicarlo al frontend o al backend en el futuro.

Código

La Función:

functioncakes(recipe, available) {
  const cantCakes = () => {
    const arrIng = []
    for (const i in recipe) {
      if (!available[i]) return0if (typeof recipe[i] !== 'number' || typeof available[i] !== 'number') returnnull
      arrIng.push(Math.floor(available[i] / recipe[i]))
    }
    arrIng.sort((a, b) => a - b)
    return arrIng[0]
  }

  returntypeof recipe !== 'object'
  || typeof available !== 'object'
  || recipe.length
  || available.length
    ? null
    : cantCakes()
}

module.exports = cakes

Los Tests:

const cakes = require('./p1')

describe('cackes', () => {
  describe('should recive two object, recipe and ingredients', () => {
    it('should recive only two object', () => {
      const recipe = {
        sugar: 10,
      }
      const recipe1 = 123const ingredient1 = 'sugar'const ingredient2 = 12const ingredient3 = ['sugar', '12']
      const ingredient4 = {
        sugar: 103,
      }

      expect(cakes(recipe, ingredient1)).toBeNull()
      expect(cakes(recipe1, ingredient1)).toBeNull()
      expect(cakes(recipe, ingredient2)).toBeNull()
      expect(cakes(recipe, ingredient3)).toBeNull()
      expect(cakes(recipe, ingredient4)).not.toBeNull()
    })
    it('should have integer key values', () => {
      const recipe = {
        sugar: 10,
        edds: 2,
      }
      const recipe1 = {
        sugar: '10',
        edds: 2,
      }
      const ingredient1 = {
        sugar: '10',
        edds: ['sugar', '12'],
        flour: { cant: 34 },
      }
      const ingredient2 = {
        sugar: 103,
      }

      expect(cakes(recipe, ingredient1)).toBeNull()
      expect(cakes(recipe1, ingredient1)).toBeNull()
      expect(cakes(recipe, ingredient2)).not.toBeNull()
    })
  })

  describe('how many cakes can Pete do', () => {
    it('must return 2', () => {
      const recipe = { flour: 500, sugar: 200, eggs: 1 }
      const ingredient = {
        flour: 1200,
        sugar: 1200,
        eggs: 5,
        milk: 200,
      }
      expect(cakes(recipe, ingredient)).toBe(2)
    })
    it('must return 0', () => {
      const recipe = {
        apples: 3,
        flour: 300,
        sugar: 150,
        milk: 100,
        oil: 100,
      }
      const ingredient = { flour: 2000, sugar: 500, milk: 2000 }
      expect(cakes(recipe, ingredient)).toBe(0)
    })
  })
})

Escribe tu comentario
+ 2
1
22010Puntos

Sí, eso es a gusto de cada quien, lo que pasa con eso es que terminas escribiendo muchas más líneas de código para un ejercicio bastante sencillo de probar una función. Creo que así como planteas lo haría si probara un componente de React 😃. Gracias por la observación, saludos.

1
8730Puntos

Muy buen tutorial!

Tengo una sugerencia:

  • Te recomiendo que los tests prueben una única cosa. Es decir: que tengan un único expect. Así, cuando fallan, sólo tienen un motivo por el cual fallar.