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.

{
  "scripts": {
    "test": "vitest",
    "coverage": "vitest run --coverage"
  }
}

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

El ejemplo anterior sería el mínimo que se puede hacer, pero hay muchas más opciones, como las siguientes:

{
  "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.

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));
const result = await Promise.race([incrementar(1), timeout(5000)]);
expect(result).toBe(2);

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 llamar sólo una vez a la función', function () {
            window.sum = (a,b) => a+b;
            let callback = vi.spyOn(window,'sum');
            const onceSum = ensureOneCall(window.sum);
            expect(onceSum(5,4)).toBe(9);
            expect(callback).toHaveBeenCalled();
            expect(onceSum(5,3)).toBe(undefined);
            expect(callback).toHaveBeenCalledTimes(1);
        });

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 server = setupServer(
    http.get('liga.json', (req, res, ctx) => {
        return res(ctx.json(liga)); // Mock de respuesta exitosa
    }),
     http.get('JavaScript-logo.png', (req, res, ctx) => {
        const blob = createMockBlob(logo); // Crear el blob desde la imagen importada
        return res(
          ctx.set('Content-Type', 'image/png'), // Especificar el tipo de contenido
          ctx.body(blob)  // Devolver el blob como respuesta
        );
      }),
     http.get('http://dominioquenoexiste.noexiste/liga.json', (req, res, ctx) => {
        return res.networkError('Error de red'); // Mock de error de red
    }),
     http.get('noexiste.json', (req, res, ctx) => {
        return res(ctx.status(500), ctx.json({ error: 'Error en el servidor' })); // Mock de error en servidor
    }),
     http.get('jsonMalo.json', (req, res, ctx) => {
        return res(ctx.body('This is not valid JSON')); // Mock de JSON malo
    })
);

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.

Mocking (fake) del Navegador#

Vitest se ejecuta a través de la terminal, en Node.js. Para simular un navegador, se recomienda usar JSDOM.

Instalación de JSDOM#

npm install -D jsdom

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

/**
 * @vitest-environment jsdom
 */

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”.