Automatización de Pruebas con Mocha
En las próximas tareas, exploraremos cómo automatizar pruebas, una práctica esencial en proyectos reales.
¿Por Qué Necesitamos Pruebas?
Al crear una función, generalmente sabemos qué debería hacer: para ciertos parámetros, esperamos un resultado específico.
Durante el desarrollo, podemos probar la función ejecutándola y comparando el resultado con lo esperado, por ejemplo, en la consola.
Si algo falla, corregimos el código, lo ejecutamos nuevamente y verificamos el resultado, repitiendo este proceso hasta que funcione correctamente.
Sin embargo, las pruebas manuales tienen sus limitaciones.
Cuando probamos manualmente, es fácil pasar por alto algo.
Por ejemplo, imaginemos que estamos desarrollando una función f
. Escribimos algo de código y probamos: f(1)
funciona, pero f(2)
no. Corregimos el código y ahora f(2)
funciona. ¿Terminamos? No necesariamente, ya que podríamos haber olvidado volver a probar f(1)
, lo que podría introducir errores.
Este es un problema común. Durante el desarrollo, pensamos en muchos casos de uso posibles. Sin embargo, no es razonable esperar que un programador pruebe todos los casos manualmente después de cada cambio. Es fácil corregir un error y crear otro.
La automatización implica escribir código de prueba adicional al código principal. Estas pruebas ejecutan nuestras funciones de varias maneras y comparan los resultados con los esperados.
Desarrollo Guiado por Comportamiento (BDD)
Utilizaremos una técnica conocida como Desarrollo Guiado por Comportamiento (BDD).
BDD es una combinación de pruebas, documentación y ejemplos.
Para entender BDD, veremos un caso práctico:
Desarrollo de «pow»: Especificación
Supongamos que queremos crear una función pow(x, n)
que eleve x
a la potencia de un número entero n
, asumiendo que n ≥ 0
.
Esta tarea es solo un ejemplo; en JavaScript ya existe el operador **
que realiza esta operación. Queremos centrarnos en el flujo de desarrollo, aplicable a tareas más complejas.
Antes de escribir el código de pow
, podemos imaginar lo que la función debería hacer y describirlo.
Esta descripción se llama especificación o «spec» y contiene descripciones de uso junto con las pruebas para verificarlas, como:
describe("pow", function() {
it("eleva a la n-ésima potencia", function() {
assert.equal(pow(2, 3), 8);
});
});
Una especificación tiene tres bloques principales:
describe(«título», function() { … }): Descripción de la funcionalidad que estamos describiendo. En nuestro caso, la función
pow
. Se utiliza para agrupar bloques de trabajo: los bloquesit
.it(«título», function() { … }): Bloque
it
. En el título deit
, describimos el caso de uso. El segundo argumento es la función que lo prueba.assert.equal(value1, value2): Comprobación. El código dentro del bloque
it
que, si la implementación es correcta, debe ejecutarse sin errores.
Las funciones assert.*
se utilizan para verificar que pow
funcione como esperamos. Aquí usamos una de ellas: assert.equal
, que compara argumentos y produce un error si no son iguales. Arriba se está verificando que el resultado de pow(2, 3)
sea 8. Hay otros tipos de comparaciones y verificaciones que veremos más adelante.
El Flujo de Desarrollo
El flujo de desarrollo es el siguiente:
- Se escribe una especificación inicial, con pruebas para la funcionalidad más básica.
- Se crea una implementación inicial.
- Para verificar que funciona, ejecutamos el framework de pruebas Mocha (detallado más adelante) que ejecuta la “spec”. Mostrará los errores mientras la funcionalidad no esté completa. Hacemos correcciones hasta que todo funcione.
- Ahora tenemos una implementación inicial con pruebas.
- Añadimos más casos de uso a la especificación, probablemente no soportados aún por la implementación. Las pruebas comienzan a fallar.
- Regresamos al paso 3, actualizando la implementación hasta que las pruebas no den errores.
- Repetimos los pasos 3-6 hasta que la funcionalidad esté lista.
De esta manera, el desarrollo es iterativo. Escribimos la especificación, la implementamos, nos aseguramos de que las pruebas pasen, luego escribimos más pruebas, y nuevamente verificamos que pasen, etc. Al final, tenemos una implementación funcional con pruebas que la verifican.
La Especificación en Acción
Usaremos las siguientes bibliotecas JavaScript para las pruebas en este tutorial:
- Mocha: El framework principal que proporciona funciones para pruebas comunes como
describe
eit
, y la función principal que ejecuta las pruebas. - Chai: Una biblioteca con muchas funciones de comprobación (assertions). Permite el uso de diferentes comprobaciones. Usaremos
assert.equal
por ahora. - Sinon: Una biblioteca para espiar funciones, emular funciones incorporadas al lenguaje, y más. La necesitaremos a menudo más adelante.
Estas bibliotecas son adecuadas tanto para pruebas en el navegador como en el lado del servidor. Aquí nos enfocaremos en el navegador.
La siguiente es una página HTML con estos frameworks y nuestra especificación de pow
:
Esta página se puede dividir en cinco partes:
- El
<head>
: Importa bibliotecas de terceros y estilos para las pruebas. - El
<script>
con la función a comprobar, en nuestro caso con el código depow
. - Las pruebas, en nuestro caso un fichero externo
test.js
que contiene una sentenciadescribe("pow", ...)
al inicio. - El elemento HTML
<div id="mocha">
utilizado para la salida de los resultados. - Los tests se inician con el comando
mocha.run()
.
El resultado:
Por ahora, la prueba falla. Esto es lógico: nuestra función pow
está vacía, por lo que pow(2,3)
devuelve undefined
en lugar de 8
.
Para más adelante, recuerda que hay avanzadas herramientas para ejecutar pruebas (test-runners), como Karma y otras. Así que generalmente no es un problema configurar muchas pruebas diferentes.
Implementación Inicial
Vamos a realizar una implementación simple de pow
, apenas suficiente para pasar la prueba:
function pow(x, n) {
return 8; // :) ¡Estamos haciendo trampa!
}
¡Ahora funciona!
Mejorando la Especificación
Lo que hicimos es trampa. La función no funciona correctamente: si ejecutamos un cálculo diferente, como pow(3,4)
, obtenemos un resultado incorrecto, pero la prueba pasa.
Esta situación es común en la práctica. Las pruebas pasan, pero la función no funciona bien. Nuestra especificación está incompleta. Necesitamos añadir más casos de uso a la especificación.
Añadamos una prueba para verificar que pow(3,4)
sea 81
.
Podemos organizar la prueba de dos maneras:
- Añadir un
assert
en el mismoit
:
function pow(x, n) {
return 8; // :) ¡Estamos haciendo trampa!
}
¡Ahora funciona!
Mejorando la Especificación
Lo que hicimos es trampa. La función no funciona correctamente: si ejecutamos un cálculo diferente, como pow(3,4)
, obtenemos un resultado incorrecto, pero la prueba pasa.
Esta situación es común en la práctica. Las pruebas pasan, pero la función no funciona bien. Nuestra especificación está incompleta. Necesitamos añadir más casos de uso a la especificación.
Añadamos una prueba para verificar que pow(3,4)
sea 81
.
Podemos organizar la prueba de dos maneras:
- Añadir un
assert
en el mismoit
:
describe("pow", function() {
it("eleva a la n-ésima potencia", function() {
assert.equal(pow(2, 3), 8);
assert.equal(pow(3, 4), 81);
});
});
- Hacer dos pruebas:
describe("pow", function() {
it("2 elevado a la potencia de 3 es 8", function() {
assert.equal(pow(2, 3), 8);
});
it("3 elevado a la potencia de 4 es 81", function() {
assert.equal(pow(3, 4), 81);
});
});
Notemos que cuando assert
lanza un error, el bloque it
termina inmediatamente. Aquí vemos la diferencia principal: si en la primera forma el primer assert
falla, nunca veremos el resultado del segundo assert
.
Hacer las pruebas por separado es útil para obtener información sobre lo que está pasando, así que la segunda forma es mejor.
Además, tiene sentido poner más pruebas. Por ejemplo, añadamos una prueba para el caso base pow(1, 100)
:
describe("pow", function() {
it("2 elevado a la potencia de 3 es 8", function() {
assert.equal(pow(2, 3), 8);
});
it("3 elevado a la potencia de 4 es 81", function() {
assert.equal(pow(3, 4), 81);
});
it("1 elevado a cualquier potencia es 1", function() {
assert.equal(pow(1, 100), 1);
});
});
Hagamos una implementación simple:
function pow(x, n) {
if (n == 0) return 1;
let result = x;
for (let i = 1; i < n; i++) {
result *= x;
}
return result;
}
Probémoslo:
Las pruebas pasan, lo que significa que todo funciona según lo especificado.
Agrupación de it
A veces, se puede agrupar los bloques it
adicionales dentro de describe
anidado, para agrupar casos de uso similares.
Por ejemplo, añadamos más pruebas para verificar casos adicionales de la función pow
. Además de números naturales, queremos probar casos fraccionales como 1.5^3
.
Para hacer esto, creamos un bloque describe("x elevado a n")
adicional y movemos nuestras pruebas allí:
describe("pow", function() {
describe("eleva x a la potencia de n", function() {
function makeTest(x) {
let expected = x * x * x;
it(`${x} elevado a la potencia de 3 es ${expected}`, function() {
assert.equal(pow(x, 3), expected);
});
}
for (let x = 1; x <= 5; x++) {
makeTest(x);
}
});
it("eleva 2 a la potencia de 3", function() {
assert.equal(pow(2, 3), 8);
});
it("eleva 3 a la potencia de 4", function() {
assert.equal(pow(3, 4), 81);
});
it("eleva 1 a la potencia de cualquier número", function() {
assert.equal(pow(1, 100), 1);
});
});
De este modo, podemos añadir más pruebas fácilmente y organizar nuestro código de prueba de manera limpia y clara.