Tests#

Motivaciones#

En el desarrollo de aplicaciones web, probar manualmente cada cambio es una tarea complicada y propensa a errores. Modificar una parte del código puede tener efectos colaterales difíciles de predecir, por lo que necesitamos automatizar las pruebas. Existen varias estrategias para realizar pruebas automáticas:

  • Probar elementos concretos sin un plan definido.

  • Desarrollo guiado por pruebas (Test Driven Development, TDD).

  • Desarrollo guiado por comportamiento (Behaviour Driven Development, BDD).

  • Desarrollo guiado por pruebas de aceptación (Acceptance Test Driven Development, ATDD).

Frameworks de Pruebas#

Algunos de los frameworks más utilizados para realizar pruebas en JavaScript son:

  • Jasmine

  • Mocha + Chai

  • Jest

  • Selenium

  • Puppeteer

  • Vitest

Nosotros usaremos Vitest en esta primera parte del curso porque se integra fácilmente en Vite y porque tiene algunas características de suites más modernas, en especial su facilidad de uso, su facilidad de emular a un navegador y su funcionamiento nativo con módulos ES6. Otra alternativa coherente con el curso podría ser Jasmine, ya que es la suite de tests por defecto en Angular. No obstante, es mucho más complicado de usar en Javascript Vanilla y funciona con dificultad con módulos ES6.

Instalación de Vitest#

Para instalar Vitest, utilizamos el siguiente comando:

npm install -D vitest

Debemos crear archivos de prueba que tengan la extensión .test.js.

Configuración en package.json#

Vitest acepta muchas opciones en sus comandos, estas se pueden escribir directamente en la terminal o guardar en package.json. Aquí tenemos un ejemplo bastante completo:

{
  "scripts": {
        "test": "vitest",
        "testrun": "vitest run",
        "webtest": "vitest --ui",  
        "coverage": "vitest run --coverage",
        "webtest:basic": "vitest spec/basic_spec.test.js --ui",
        "webtest:functional": "vitest spec/functional_spec.test.js --ui",
  }
}

Como se ve, los nombres de los scripts son arbitrarios. testrun ejecuta los tests una vez y no se queda a la espera de cambios en los ficheros. Esto puede ser útil para los tests en un proceso CI/CD. webtest abre un navegador con los tests. coverage informa del % de código que tiene tests. webtest:basic fuerza a testar solo con un fichero de tests específico.

Ahora haremos un test mínimo. Las condiciones para un “hola mundo” del test es importar la librería y hacer un describe con un test dentro de un fichero que necesariamente tiene la extensión: .test.js:

import { describe, expect, test, vi } from "vitest";
import * as sumModule from "./sum";
 describe("Sum", () => {
        test("Sum suma", () => {
            expect(sumModule.sum(1,2,3)).toBe(6);
        });
 });

Podemos ejecutar las pruebas y obtener informes de cobertura de código con los siguientes comandos:

npm run test
npm run coverage
npx vitest --ui

También podemos agregar Vitest como una extensión en Visual Studio Code desde el siguiente enlace: Vitest Explorer.

Desarrollo Guiado por Pruebas (TDD)#

El TDD es parte de la metodología de Extreme Programming y se basa en realizar pruebas unitarias. Sigue tres leyes fundamentales:

  1. No escribirás código de producción sin antes escribir una prueba que falle.

  2. No escribirás más de una prueba unitaria suficiente para fallar (y no compilar es fallar).

  3. No escribirás más código del necesario para hacer pasar la prueba.

Ciclo Red - Green - Refactor#

  1. Red: Escribir una prueba que falle antes de implementar la funcionalidad.

  2. Green: Implementar el código mínimo necesario para que la prueba pase.

  3. Refactor: Mejorar el código una vez que la prueba pase.

Una vez completado el ciclo, comenzamos de nuevo con el siguiente requisito. En cada ciclo se pueden redefinir o agregar nuevos requisitos.

Otro aspecto a tener en cuenta en los tests es la cobertura, es decir, el porcentaje de código que cubren los tests. Suele ser muy difícil tener el 100% salvo en librerías implementadas con funciones puras. En entornos reales, los métodos privados no pueden tener un test unitario y hay muchas funciones con efectos colaterales muy difíciles de testar. En vitest se hace con --coverage y retorna un HTML con esa información para poderlo compartir.

Sintaxis de la pruebas#

Vitest acepta pruebas similares a las de Mocha-Chai, Jasmine o Jest. Podemos usar test o it para definir pruebas, y expect o assert para realizar afirmaciones. Aquí algunos enlaces útiles:

Este sería un ejemplo muy básico para probar una función que está en un módulo ESM:

import {describe, expect, test } from "vitest";

import * as ex1 from "../index.js"

describe("Javascript Básico", function () {
    describe("Variables", function () {
      test("Debe retornar un array elementos de los tipo solicitados", function () {
        expect(ex1.createArraySomeTypes().map((element) => typeof element)).toEqual([
          "number",
          "string",
          "boolean",
          "object",
          "object",
          "object",
          "undefined",
          "number",
          "function",
        ]);
        expect(ex1.createArraySomeTypes()[3] instanceof Array).toBe(true);
        expect(ex1.createArraySomeTypes()[5] === null).toBe(true);
        expect(ex1.createArraySomeTypes()[6] === undefined).toBe(true);
        expect(isNaN(ex1.createArraySomeTypes(7))).toBe(true);
        expect(ex1.createArraySomeTypes()[8] instanceof Function).toBe(true);
      });
    });
});

En el ejemplo se importa como ex1 todo lo que exporta el index.js y se testa la función createArraySomeTypes(). Para ello se ha usado describe para organizar las suites de pruebas y test como función para hacer la prueba.

Una vez visto lo fundamental en cuanto a qué son los tests, vamos a detallar algunos aspectos técnicos para hacerlos. Lo mejor es ir directamente a la documentación de Vitest, pero puede resultar complicada en un principio.

Matchers#

Lo primero que debemos dominar son las diversas manera que tienen las suites de tests para comprobar si el resultado es el esperado. Para ello se usan funciones matchers que son más cómodas que probar las cosas artesanalmente y que retornan un error interpretable por el test.

Algunos matchers importantes que podemos usar en nuestras pruebas son:

  • not: expect(something).not.toBe(true);

  • toBe: expect(thing).toBe(realThing);

  • toBeDefined: expect(result).toBeDefined();

  • toBeInstanceOf: expect('foo').toBeInstanceOf(String);

  • toBeNaN, toBeNull, toBeUndefined: expect(thing).toBeNaN();

  • toContain: expect(array).toContain(anElement);

  • toEqual: expect(bigObject).toEqual({"foo": ['bar', 'baz']});

  • toMatch: expect("my string").toMatch(/string$/);

Con pocos podemos funcionar en la mayoría de las ocasiones, pero hay otros muy específicos como, por ejemplo, toHaveProperty() que comprueba que un objeto tiene una propiedad determinada. Cuanto más específico sea el matcher mejor, por ejemplo:

// Esto:
expect(user).toHaveProperty('age', 30);
// Es lo mismo que esto:
expect('name' in user).toBe(true);
expect(user.age).toBe(30);

En el ejemplo, el primero es más conciso y el error resultante se entiende mejor.

Pruebas de Promesas#

Para probar funciones asíncronas, solo necesitamos agregar async / await en la función de callback de test.

describe('Test Promesas', function() {
  describe('incrementar decrementar', function() {
    test('should return the number +1', async function() {
      expect(await incrementar(1)).toBe(2);
      expect(await incrementar(1000)).toBe(1001);
    });
    test('should return the number -1', async function() {
      expect(await decrementar(1)).toBe(0);
      expect(await decrementar(1000)).toBe(999);
    });
  });
});

Hay un problema con las promesas, si no se cumplen nunca, el test no acaba y tampoco falla. En caso de que pueda pasar eso, se puede usar setTimeOut con un tiempo razonable y dar fallo si no se cumple en ese plazo:

const timeout = (ms) => new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout exceeded')), ms));
try { // Al fer el try catch vitest no dona error al detectar rejects
 const result1 = await Promise.race([result, timeout(500)]);
 expect(result).toBe(1);
}
catch (error) {
 expect(error.message).toBe('Timeout exceeded');
}

Vitest acepta poner un timeout al test para sustituir al que tiene por defecto de 5 segundos. Pero es más recomendable el ejemplo anterior porque, en caso de fallo, tenemos un try..catch para controlarlo.

Promesas Deterministas#

Con las promesas podemos testar si retornan lo correcto o si manejan bien los errores. En caso de promesas de, por ejemplo, un fetch, debemos distinguir entre si queremos un test unitario o de integración. Si es unitario debemos hacer que ese fetch sea determinista mockeando el servidor y siempre retornando lo mismo o el mismo error.

export const promesas = (a, b) => new Promise(resolve => {
       Math.random() > 0.5 && resolve(a) || reject(new Error(a))
    });

 test("promesas debe funcionar de forma determinista", async () => {
  const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0.9); // Así seguro que no falla
  const result = promesas(1);
  await expect(result).resolves.toBe(1);
  randomSpy.mockRestore();
  const randomSpyReject = vi.spyOn(Math, "random").mockReturnValue(0.1); // Así siempre falla
  const result = promesas(1);
  await expect(result).rejects.toBe(1);
  randomSpy.mockRestore();
});

En el ejemplo anterior se falsea la funció random() para que el test siempre pase o no.

Mocking, Stubs, Spies, Fake, Dummy…#

A veces necesitamos datos, funciones o servicios ficticios para realizar pruebas. Esto puede ser útil para no afectar la base de datos en producción o para tener más control sobre los datos que se están probando.

Los términos enunciados en el título de esta sección se refieren a esas técnicas para aislar totalmente los tests de los datos y servicios en producción.

Así, llamamos stub a un servicio al que pueden consultar las funciones de una manera más sencilla, de manera que siempre retorna los datos conocidos y controlados (funciona o falla a nuestra elección). Los stub sustituyen a la función original. Un mock es un término más genérico. En términos de funciones, se suele usar para simular una función y observar si ha sido invocada o cuantas veces se llama. Los mock no sustituyen una función que debe funcionar.

Otra herramienta son los spies, que en el caso de Vitest se invocan con funciones como vi.spyOn(). Estos no cambian para nada el comportamiento, pero espian a las funciones para ver si son invocadas.

Los Fake son objetos, funciones o otras cosas que tienen una estructura como la real, pero que creamos específicamente para los tests. Si no tienen una estructura compleja o comportamiento propio, les llamamos Datos de prueba o fixtures.

Por otro lado, llamamos Dummies a variables o funciones que no importan, pero que deben estar ahí para que no falle el test. Son objetos o funciones ficticios que no importa lo que hagan mientras sean sintacticamente correctos.

En cualquier caso, mucha gente usar el término genérico mock para todo.

Datos de prueba de Variables#

En el siguiente ejemplo creamos tres constantes que sirven para ver si los resultados de las funciones son los esperables. Esto es muy habitual en los tests:

const initial_state = [[0,0,0],[0,0,0],[0,0,0]];
const example_state = [[0,1,0],[0,-1,0],[0,0,0]];
const full_state = [[-1,1,-1],[1,-1,1],[1,-1,1]];

describe('3 en raya', function() {
  describe('Funciones del juego', function() {
    test('TicTacToeGetInitialState Debe retornar un array 3x3 de 0s', function() {
      expect(alg.TicTacToeGetInitialState()).toEqual(initial_state);
    });
    test('TicTacToeGetNextState Debe retornar una copia con la jugada', function() {
      expect(alg.TicTacToeGetNextState(initial_state,2,1)).toEqual([[0,0,1],[0,0,0],[0,0,0]]);
      expect(alg.TicTacToeGetNextState(initial_state,3,-1)).toEqual([[0,0,0],[-1,0,0],[0,0,0]]);
      expect(alg.TicTacToeGetNextState(initial_state,2,1)).not.toBe(initial_state);
    });
    test('TicTacToeGetValidMoves Debe retornar la lista de posibles acciones', function() {
      expect(alg.TicTacToeGetValidMoves(initial_state)).toEqual([1,1,1,1,1,1,1,1,1]);
      expect(alg.TicTacToeGetValidMoves(example_state)).toEqual([1,0,1,1,0,1,1,1,1]);
      expect(alg.TicTacToeGetValidMoves(full_state)).toEqual([0,0,0,0,0,0,0,0,0]);
    });
  });
});

Mocking y spy de Funciones#

A veces queremos saber si una función se ejecutó (espiar), aunque no siempre nos interesa su retorno. Vitest proporciona la utilidad vi para este propósito.

  • vi.fn(): Crea una función simulada.

  • vi.spyOn(): Añade un espía a una función de un objeto.

test("ensureOneCall debe ejecutar la función sólo una vez", () => {
  //Creamos una función simulada
  const sum = vi.fn((a, b) => a + b);
  const onceSum = ensureOneCall(sum);

  const result1 = onceSum(5, 4);
  expect(result1).toBe(9);
  expect(sum).toHaveBeenCalledTimes(1);
  expect(sum).toHaveBeenCalledWith(5, 4);

  const result2 = onceSum(5, 3);
  expect(result2).toBeUndefined();
  expect(sum).toHaveBeenCalledTimes(1); // sigue siendo 1

  onceSum(10, 20);
  onceSum(0, 0);
  for (let i = 0; i < 10; i++) once(i,i);
  expect(sum).toHaveBeenCalledTimes(1);
});

Otro ejemplo con handlers:

 test("DOMEventListener añade un listenre a un evento", () => {
            const div = document.createElement('div'); 
            const handler = vi.fn();
            // se espera que retorne una función para eliminar el evento
            const removeListener = domEventListener(div)(handler); 
            div.dispatchEvent(new MouseEvent("click"));
            expect(handler).toHaveBeenCalled();
            expect(removeListener).toBeInstanceOf(Function);
            removeListener();
        });

También se puede ‘mockear’ el funcionamiento de la función al mismo tiempo que se espía:

test('sendForm debe enviar por post datos a un servidor', async function () {
            let formExample = document.createElement('form');
            formExample.innerHTML = `<form>
            <label for="login">Login:</label>
            <input type="text" id="login" name="login" value="usuario">
            <label for="password">Password:</label>
            <input type="password" id="password" name="password" value="1234">
            <button type="submit">Iniciar sesión</button>
          </form>`;
            const opciones = { method: 'POST', body: new FormData(formExample) }
            // Con mockImplementation modificamos el comportamiento de fetch
            const spyFetch = vi.spyOn(window, 'fetch').mockImplementation((url, options) => {
                console.log(options.body);
                return Promise.resolve(
                    new Response(JSON.stringify({ name: options.body.get('login'), password: options.body.get('password') }), {
                        status: 200,
                        statusText: 'OK',
                    })
                )
            });
            let postForm = http.sendForm(formExample, 'http://localhost/fakeServer');
            expect(postForm).toBeInstanceOf(Promise);
            let data = await postForm;
            expect(typeof data).toBe('string')
            expect(data).toBe(`{"name":"usuario","password":"1234"}`);
            expect(spyFetch).toHaveBeenCalled();
            // En este caso, comprobamos que se han enviado bien los datos. 
            expect(spyFetch).toHaveBeenCalledWith('http://localhost/fakeServer', opciones);
            
            // Restauramos la función anterior
            spyFetch.mockRestore();
        });

Mocking (stub) de la Red#

Vitest recomienda utilizar msw para interceptar peticiones y crear respuestas falsas.

import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';

const mockServer = setupServer(
        http.get('http://dominiobueno.com/datos.json', () => {
            return HttpResponse.json({ a: 1, b: 2 }); // Mock de respuesta exitosa
        }),
        http.get('http://dominioquenoexiste.noexiste/datos.json', () => {
            return HttpResponse.error();
        }),
        http.get('http://dominiobueno.com/noexiste.json', () => {
            return new HttpResponse('Not found', {
                status: 404,
                headers: {
                    'Content-Type': 'text/plain',
                },
            })
        }),
        http.get('http://dominiobueno.com/jsonMalo.json', () => {
            return HttpResponse.text('json malo')
        }),
        http.post('http://dominiobueno.com/', async ({ request }) => {
            const newPost = await request.clone().json();
            return HttpResponse.json(newPost);
        }),
        http.get('http://dominiobueno.com/logo.png', async () => {
            const __dirname = path.dirname(fileURLToPath(import.meta.url));
            const buffer = await readFile(path.join(__dirname, "logo.png"));
            //console.log(buffer);
            return HttpResponse.arrayBuffer(buffer, {
                headers: {
                    "content-type": "image/png",
                },
            });
        }),
    );

describe("Server", () => {
        beforeAll(() => mockServer.listen());  // Hay que inicializar y finalizar el servidor al acabar los tests
        afterAll(() => mockServer.close());
        afterEach(() => mockServer.resetHandlers());
        test("server ha de retornar un objecte", async () => {
            const result = await server("http://dominiobueno.com/datos.json");
            expect(typeof result).toBe('object');
            expect(result).toEqual({ a: 1, b: 2 });
        });
});

Es más correcto llamarlo stub porque sustituye el comportamiento real de un servicio por uno falso y controlado. Pero no es estrictamente un mock nos interesa “espiar” al servicio.

Red determinista#

El caso de uso más importante del mock de la red es para hacer que los tests sean deterministas. En este caso no nos importa si el servidor real contesta correctamente, tenemos acceso a la red o el nombre de dominio es el definitivo, lo importante es qué hacen estas funciones con la red y qué datos esperan.

Incluso se puede hacer que el servidor falso tenga un comportamiento errático o falle siempre. Así podemos comprobar la robustez de nuestras funciones frente a fallos de red sin tener que parar un servidor real o en producción.

Además de hacer las pruebas deterministas, el uso de mocks de red reduce el tiempo de ejecución de los tests (no hay esperas de red) y garantiza su independencia respecto a entornos externos o a la conectividad del desarrollador o del CI/CD.

Este comportamiento falso y determinista provoca que sigan siendo tests unitarios y no de integración.

Mocking (fake) del Navegador#

Vitest se ejecuta a través de la terminal, en Node.js. Para simular un navegador, se recomienda usar JSDOM. Simular el navegador nos permite acceder a document, eventos… De esta manera podemos testar funciones del frontend, siempre que no produzcan efectos colaterales.

Forzarnos a crear funciones de la vista testables es una buena práctica, ya que nos enfrentamos a algunas de las tareas maś complicadas de la programación funcional. De esta manera hay que aislar las funciones que usan el document.querySelector(), mutan el DOM o la gestión de eventos.

Instalación de JSDOM#

npm install -D jsdom

Y agregamos el siguiente comentario al principio del archivo de pruebas:

/**
 * @vitest-environment jsdom
 */

JSDOM no es del todo capaz de testar operaciones con el DOM, por ejemplo, no tiene total compatibilidad con el Shadow DOM o no funciona bien innerText. Los navegadores son mucho más potentes que un emulador. Si queremos tests más capaces hay que buscar alternativas más manuales o otras suites que se ejecuten en el navegador como Puppeteer o Karma con Jasmine.

Pruebas de integración#

Podemos enumerar algunos tests que, escritos con Vitest de la misma forma, son de integración:

  • Funciones que modifican el DOM del document: Deberían ser pocas, pero si existen, no son puras y dependen de un DOM existente determinado. Para que funcionen necesitamos JSDom y resetear el DOM al principio del test.

  • Funciones de APIs del navegador: No todas las soporta JSDom, así que se pueden mockear, usar polyfills o librerías específicas.

  • Funciones que utilizan otras funciones de nuestro mismo código: En realidad siguen siendo tests unitarios si no tienen efectos colaterales. Si integran dependencias que pueden testarse aisladas para ver cómo se comportan en conjunto se pueden considerar tests unitarios de composición o de flujo.

  • Funciones que conectan con nuestro servidor real o un servidor que también está testándose: Cuando los tests modifican datos reales (por ejemplo, creando usuarios, cambiando estados, eliminando registros, etc.), si no limpiamos la base de datos después los dejan datos residuales. Los resultados de los siguientes tests dependen del orden de ejecución y eso rompe una regla de oro del testing: los tests deben ser independientes y deterministas.

Las pruebas de integración deben ser consensuadas con el equipo de backend. Si el frontend prueba contra endpoints reales, necesita conocer qué datos devuelve el backend, qué formato tienen las respuestas, qué errores puede emitir y en qué entorno se están ejecutando las pruebas. Acordar esas pruebas permite definir conjuntamente los contratos de la API.

Mocking del DOM#

Para simular interacciones con el DOM, podemos utilizar DOM Testing Library.

Pruebas End-to-End (E2E) y QA#

Para pruebas de extremo a extremo, podemos utilizar herramientas como Cypress. Vitest proporciona una metodología más tradicional tipo “scripted testing”.