Node.js

Testing de servicios REST

     

En documentos anteriores hemos utilizado herramientas como EchoAPI o similares para probar nuestros servicios REST. Estas herramientas resultan útiles para pruebas puntuales de servicios, y comprobar que, en principio, cumplen su cometido. Algunas herramientas, como Postman, permiten incluso definir baterías de pruebas que lanzar sobre nuestros servicios y detectar si se pasan todas. Pero, en cualquier caso, el proceso puede resultar algo pesado, y en algunos casos limitado a cierto número de pruebas gratuitas.

En este documento veremos cómo automatizar las pruebas sobre nuestros servicios REST de dos formas posibles:

1. Testing en local desde JavaScript: Axios

Uno de los principales inconvenientes que podemos encontrar a la hora de probar nuestras aplicaciones REST con herramientas como EchoAPI o Postman es que su uso gratuito tiene ciertas limitaciones, bien porque no dejan hacer lo mismo que otras aplicaciones comerciales, o por el número de veces que podemos ejecutar una colección de forma gratuita en algunas de ellas.

Para paliar esta deficiencia y complementar mejor nuestras pruebas podemos hacer uso de alguna librería específica en JavaScript que permita lanzarlas de forma gratuita e ilimitada desde el código. Es el caso de Axios.

1.1. Instalación y configuración

Las pruebas que hagamos en Axios las podemos definir en un proyecto aparte del que queramos probar, o bien podemos definir alguna carpeta tests dentro del propio proyecto. Aunque, eso sí, necesitaremos ejecutar el programa de tests desde un terminal, y puede que el terminal del proyecto principal ya esté ocupado ejecutando Express.

En el proyecto donde vayamos a hacer las pruebas necesitamos instalar el módulo axios con el correspondiente comando:

npm install axios

Una vez instalado lo incorporamos al fichero fuente (o ficheros) donde vayamos a hacer los tests (por ejemplo, tests.js), y realizamos una configuración previa donde indicamos la URL base a la que queremos conectar (aquella donde estará escuchando el proyecto a probar), y el tipo de contenido que vamos a utilizar (en nuestro caso, contenido JSON):

require('dotenv').config();
const axios = require('axios');

const URL_TESTS = process.env.URL_TESTS || 'http://127.0.0.1:8080';

const api = axios.create({
    baseURL: URL_TESTS,
    headers: {
        'Content-Type': 'application/json',
    },    
    validateStatus: () => true // Captura cualquier código de estado sin lanzar excepciones de Axios
});

Además, vamos a declarar una serie de variables globales y métodos generales que nos serán de ayuda en los distintos tests que realicemos:

let testsTotales = 0;
let testsOK = 0;
let testsError = 0;

function assert(condicion, mensaje) {
    if (!condicion) {
        throw new Error(mensaje);
    }
}

async function runTest(nombre, testFn) {
    testsTotales++;
    try {
        await testFn();
        testsOK++;
        console.log(`[OK]: ${nombre}`);
    } catch (err) {
        testsError++;
        console.log(`[ERROR]: ${nombre}`);
        console.log(`    Detalle: ${err.message}`);
    }
}

1.2. Ejecución de pruebas simples

Para ilustrar este apartado con ejemplos nos basaremos en este proyecto, que es una versión en GitHub de la aplicación ContactosREST que hemos desarrollado en documentos anteriores. Instalaremos Axios en el proyecto y crearemos un fichero tests.js en la carpeta raíz, con el código de inicialización y configuración que hemos visto antes.

Vamos a añadir una primera prueba para el servicio de listado de contactos (GET /contactos). Podemos crear para ello una función principal donde iremos acumulando los tests que queramos realizar. Añadimos un código inicial para verificar la conexión con el servidor Express antes de nada:

async function main() {

    console.log(`Iniciando suite de pruebas HTTP contra ${URL_TESTS}...`);

    // Verificar si el servidor está en marcha
    try {
        await api.get('/contactos', { timeout: 2500 });
    } catch (err) {
        console.error(`[ERROR]: No se pudo conectar con el servidor en ${URL_TESTS}.`);
        console.error('Asegúrate de que el servidor Express esté previamente levantado y accesible antes de ejecutar los tests.\n');
        process.exit(1);
    }

    // Aquí iremos colocando nuestras pruebas
}

main();

Si la conexión es satisfactoria, añadimos esa primera prueba de GET /contactos:

// GET /contactos
await runTest('GET /contactos -> 200 devuelve listado de contactos', async () => {
    const res = await api.get('/contactos');
    assert(res.status === 200, `Esperado 200, recibido ${res.status}`);
    assert(res.data.ok === true, 'Esperado ok: true');
    assert(Array.isArray(res.data.resultado) && res.data.resultado.length > 0, 'Esperado array con contactos');
});

Observa el funcionamiento de la prueba, porque se repetirá en otras que hagamos:

Vamos ahora con la petición de inserción:

let idContactoCreado = null;

// POST /contactos (Caso OK)
await runTest('POST /contactos -> 200 crea nuevo contacto correctamente', async () => {
    const cuerpo = {
        nombre: 'Contacto Axios',
        edad: 20,
        telefono: '666554400'
    };
    const res = await api.post('/contactos', cuerpo);
    assert(res.status === 200, `Esperado 200, recibido ${res.status}`);
    assert(res.data.ok === true, 'Esperado ok: true');
    assert(res.data.resultado && res.data.resultado._id, 'Esperado objeto contacto creado con _id');
    assert(res.data.resultado.nombre === cuerpo.nombre, 'El nombre no coincide');
    idContactoCreado = res.data.resultado._id;
});

En este caso hemos definido una variable idContactoCreado para guardarnos el id del contacto, y poder luego hacer modificaciones, búsquedas o borrados sobre ese contacto de prueba. Por ejemplo, así definimos las otras tres pruebas básicas sobre el contacto (buscar por ID, modificar y borrar finalmente, para no dejar rastro del contacto):

// GET /contactos/:id (Caso OK)
await runTest(`GET /contactos/:id -> 200 devuelve el contacto por su ID`, async () => {
    assert(idContactoCreado !== null, 'Se requiere idContactoCreado');
    const res = await api.get(`/contactos/${idContactoCreado}`);
    assert(res.status === 200, `Esperado 200, recibido ${res.status}`);
    assert(res.data.ok === true, 'Esperado ok: true');
    assert(res.data.resultado._id === idContactoCreado, 'ID devuelto no coincide');
});

// PUT /contactos/:id (Caso OK)
await runTest(`PUT /contactos/:id -> 200 actualiza datos del contacto`, async () => {
    assert(idContactoCreado !== null, 'Se requiere idContactoCreado');
    const actualizacion = { nombre: 'Contacto Axios Modificado', edad: 21, telefono: '666554400' };
    const res = await api.put(`/contactos/${idContactoCreado}`, actualizacion);
    assert(res.status === 200, `Esperado 200, recibido ${res.status}`);
    assert(res.data.ok === true, 'Esperado ok: true');
    assert(res.data.resultado.nombre === actualizacion.nombre, 'Nombre actualizado no coincide');
});

// DELETE /contactos/:id (Caso OK)
await runTest(`DELETE /contactos/:id -> 200 elimina el contacto de prueba`, async () => {
    assert(idContactoCreado !== null, 'Se requiere idContactoCreado');
    const res = await api.delete(`/contactos/${idContactoCreado}`);
    assert(res.status === 200, `Esperado 200, recibido ${res.status}`);
    assert(res.data.ok === true, 'Esperado ok: true');
    assert(res.data.resultado._id === idContactoCreado, 'ID eliminado no coincide');
});

Además, podemos definir pruebas erróneas y verificar que, efectivamente falla el servicio. Por ejemplo, así intentamos insertar un contacto equivocado, o buscar uno con un id inventado que no existe:

// POST /contactos (Caso Error: datos inválidos)
await runTest('POST /contactos -> 400 error por datos no válidos (edad demasiado grande)', async () => {
    const cuerpo = {
        nombre: 'Contacto Axios Erróneo',
        edad: 150,
        telefono: '666005544'
    };
    const res = await api.post('/contactos', cuerpo);
    assert(res.status === 400, `Esperado 400, recibido ${res.status}`);
    assert(res.data.ok === false, 'Esperado ok: false');
});

// GET /contactos/:id (Caso Error: ID inexistente)

const fakeId = '507f1f77bcf86cd799439011';

await runTest(`GET /contactos/${fakeId} -> 400 contacto inexistente`, async () => {
    const res = await api.get(`/contactos/${fakeId}`);
    assert(res.status === 400, `Esperado 400, recibido ${res.status}`);
    assert(res.data.ok === false, 'Esperado ok: false');
});

1.3. Ejecución de pruebas con autenticación

Si necesitamos ejecutar pruebas con autenticación por tokens, necesitamos añadir algunos tests más a nuestro conjunto.

Por un lado, necesitamos comprobar un login correcto e incorrecto y, en el primer caso, guardarnos el token en una variable dentro del método main para poderlo usar en otras pruebas que requieran autenticación:

let token = null;

// POST /auth/login (Caso Error: credenciales incorrectas)
await runTest('POST /auth/login -> 401 error con credenciales incorrectas', async () => {
    const res = await api.post('/auth/login', {
        login: 'usuario_inexistente',
        password: 'password_incorrecto'
    });
    assert(res.status === 401, `Esperado 401, recibido ${res.status}`);
    assert(res.data.ok === false, 'Esperado ok: false');
    assert(!res.data.token, 'No debería devolver token en login fallido');
});

// POST /auth/login (Caso OK: credenciales válidas)
await runTest('POST /auth/login -> 200 devuelve token válido', async () => {
    const res = await api.post('/auth/login', {
        login: TEST_USER,
        password: TEST_PASS
    });
    assert(res.status === 200, `Esperado 200, recibido ${res.status}`);
    assert(res.data.ok === true, 'Esperado ok: true');
    assert(typeof res.data.token === 'string' && res.data.token.length > 0, 'Se esperaba un token en la respuesta');
    
    token = res.data.token;
});

Ahora que ya tenemos el token de la prueba exitosa, lo pasamos en la cabecera Authorization de las pruebas que lo requieran. Por ejemplo, así definiríamos dos pruebas POST: una incorrecta por no enviar token de autorización, y otra correcta:

// POST /contactos (Caso Error: no se envía token de autorización)
await runTest('POST /contactos -> 401 rechazado si no se envía token', async () => {
    const cuerpo = { nombre: 'Sin Token', edad: 25, telefono: '666112233' };
    const res = await api.post('/contactos', cuerpo);
    assert(res.status === 401, `Esperado 401, recibido ${res.status}`);
    assert(res.data.ok === false, 'Esperado ok: false');
});

// POST /contactos (Caso OK con token)
await runTest('POST /contactos -> 200 crea nuevo contacto correctamente (con token)', async () => {
    assert(token !== null, 'Se requiere un token válido del test de login');
    const cuerpo = {
        nombre: 'Contacto Axios',
        edad: 20,
        telefono: '666554400'
    };
    const res = await api.post('/contactos', cuerpo, {headers: { Authorization: `Bearer ${token}`);
    assert(res.status === 200, `Esperado 200, recibido ${res.status}`);
    assert(res.data.ok === true, 'Esperado ok: true');
    assert(res.data.resultado && res.data.resultado._id, 'Esperado objeto contacto creado con _id');
    assert(res.data.resultado.nombre === cuerpo.nombre, 'El nombre no coincide');
    idContactoCreado = res.data.resultado._id;
});

2. Testing integrado en repositorios y CI/CD

En este apartado aprenderemos una forma alternativa de ejecutar tests: los integraremos con la aplicación para que se lancen automáticamente en un repositorio remoto (GitHub) de modo que, sólo si los tests son satisfactorios, la aplicación se despliegue automáticamente en el servidor. Es lo que se conoce como CI/CD (Integración Continua / Despliegue Continuo).

Para todo este apartado, nos basaremos en este ejemplo que puedes descargar. Tendrás que subirlo, como veremos, a tu propio repositorio GitHub para comprobar el funcionamiento. Pero, antes de eso, explicaremos el contenido del proyecto para entender mejor qué vamos a hacer.

2.1. Definición de los tests

A la hora de integrar los tests en GitHub tenemos que hacer algunos cambios respecto a lo realizado en el apartado anterior. En lugar de usar Axios emplearemos otras librerías que tienen una integración directa con GitHub (como jest y supertest). Además, debemos desacoplar la creación del servidor Express de su posterior puesta en marcha. Esto permitirá crear instancias alternativas del servidor para pasar las pruebas sobre ellas.

2.1.1. Desacoplar app Express de inicio del servidor

Si echas un vistazo al proyecto que se da de ejemplo, verás que en la carpeta raíz hay, entre otras cosas, dos ficheros:

Con esto lo que conseguimos es desacoplar la creación de la instancia de Express con su puesta en marcha. Esto será particularmente útil unido a las librerías de pruebas que utilizaremos, ya que las pruebas pondrán en marcha su propia instancia de Express.

2.1.2. Instalación de las librerías necesarias

Como librerías necesarias en este proyecto hemos añadido tres (en modo dev): jest y supertest para definir y lanzar las pruebas, y mongodb-memory-server para ejecutar una instancia en memoria de MongoDB sobre la que ejecutar las operaciones (inserciones, borrados, búsquedas…) y no alterar la base de datos original, ni depender de una conexión con ella para hacer las pruebas.

2.1.3. Definición de las pruebas

Por completar

2.2. Integración con GitHub

Vamos a integrar las pruebas en GitHub de modo que se realicen automáticamente cada vez que se reciba un push. Para ello haremos uso de GitHub Actions, y definiremos los pasos a ejecutar en un fichero YAML.

2.3. Conexión con Render

Ahora vamos a conectar este repositorio GitHub con un Web Service de la plataforma Render. En este documento se habla de ella y se tiene un ejemplo de despliegue sencillo (sin integración de tests).