Una guía completa para las pruebas de API de un extremo a otro con Docker

Las pruebas son un dolor en general. Algunos no ven el punto. Algunos lo ven, pero lo ven como un paso adicional que los ralentiza. A veces, las pruebas están ahí, pero son muy largas o inestables. En este artículo, verá cómo puede diseñar pruebas usted mismo con Docker.

Queremos pruebas rápidas, significativas y confiables escritas y mantenidas con un mínimo esfuerzo. Significa pruebas que son útiles para usted como desarrollador en el día a día. Deberían aumentar su productividad y mejorar la calidad de su software. Hacerse pruebas porque todo el mundo dice "debería hacerse las pruebas" no es bueno si lo hace más lento.

Veamos cómo lograrlo sin mucho esfuerzo.

El ejemplo que vamos a probar

En este artículo vamos a probar una API construida con Node / express y usaremos chai / mocha para probar. Elegí una pila JS'y porque el código es muy corto y fácil de leer. Los principios aplicados son válidos para cualquier pila tecnológica. Sigue leyendo incluso si Javascript te enferma.

El ejemplo cubrirá un conjunto simple de puntos finales CRUD para los usuarios. Es más que suficiente para comprender el concepto y aplicarlo a la lógica empresarial más compleja de su API.

Vamos a utilizar un entorno bastante estándar para la API:

  • Una base de datos de Postgres
  • Un clúster de Redis
  • Nuestra API utilizará otras API externas para hacer su trabajo

Es posible que su API necesite un entorno diferente. Los principios aplicados en este artículo seguirán siendo los mismos. Utilizará diferentes imágenes base de Docker para ejecutar cualquier componente que pueda necesitar.

¿Por qué Docker? Y de hecho Docker Compose

Esta sección contiene muchos argumentos a favor del uso de Docker para realizar pruebas. Puede omitirlo si desea pasar a la parte técnica de inmediato.

Las dolorosas alternativas

Para probar su API en un entorno cercano al de producción, tiene dos opciones. Puede simular el entorno a nivel de código o ejecutar las pruebas en un servidor real con la base de datos, etc. instalada.

Burlarse de todo a nivel de código satura el código y la configuración de nuestra API. A menudo, tampoco es muy representativo de cómo se comportará la API en producción. Ejecutarlo en un servidor real es una infraestructura pesada. Requiere mucha configuración y mantenimiento, y no se escala. Al tener una base de datos compartida, puede ejecutar solo una prueba a la vez para asegurarse de que las ejecuciones de prueba no interfieran entre sí.

Docker Compose nos permite obtener lo mejor de ambos mundos. Crea versiones "en contenedores" de todas las partes externas que usamos. Es una burla pero está fuera de nuestro código. Nuestra API cree que está en un entorno físico real. Docker compose también creará una red aislada para todos los contenedores para una ejecución de prueba determinada. Esto le permite ejecutar varios de ellos en paralelo en su computadora local o en un host de CI.

¿Exagerado?

Quizás se pregunte si no es excesivo realizar pruebas de un extremo a otro con Docker compose. ¿Qué tal simplemente ejecutar pruebas unitarias en su lugar?

Durante los últimos 10 años, las aplicaciones monolíticas de gran tamaño se han dividido en servicios más pequeños (con tendencia a los "microservicios" bulliciosos). Un componente de API determinado se basa en más partes externas (infraestructura u otras API). A medida que los servicios se reducen, la integración con la infraestructura se convierte en una parte más importante del trabajo.

Debe mantener una pequeña brecha entre su producción y sus entornos de desarrollo. De lo contrario, surgirán problemas al realizar la implementación de producción. Por definición, estos problemas aparecen en el peor momento posible. Darán lugar a arreglos apresurados, caídas en la calidad y frustración para el equipo. Nadie quiere eso.

Quizás se pregunte si las pruebas de extremo a extremo con Docker compose se ejecutan más tiempo que las pruebas unitarias tradicionales. Realmente no. Verá en el siguiente ejemplo que podemos mantener fácilmente las pruebas en menos de 1 minuto, y con un gran beneficio: las pruebas reflejan el comportamiento de la aplicación en el mundo real. Esto es más valioso que saber si tu clase en algún lugar en el medio de la aplicación funciona bien o no.

Además, si no tiene ninguna prueba en este momento, comenzar de un extremo a otro le brinda grandes beneficios con poco esfuerzo. Sabrá que todas las pilas de la aplicación funcionan juntas para los escenarios más comunes. ¡Eso ya es algo! A partir de ahí, siempre puede refinar una estrategia para realizar pruebas unitarias en partes críticas de su aplicación.

Nuestra primera prueba

Comencemos con la parte más fácil: nuestra API y la base de datos de Postgres. Y ejecutemos una prueba CRUD simple. Una vez que tengamos ese marco en su lugar, podemos agregar más funciones tanto a nuestro componente como a la prueba.

Aquí está nuestra API mínima con GET / POST para crear y listar usuarios:

const express = require('express'); const bodyParser = require('body-parser'); const cors = require('cors'); const config = require('./config'); const db = require('knex')({ client: 'pg', connection: { host : config.db.host, user : config.db.user, password : config.db.password, }, }); const app = express(); app.use(bodyParser.urlencoded({ extended: false })); app.use(bodyParser.json()); app.use(cors()); app.route('/api/users').post(async (req, res, next) => { try { const { email, firstname } = req.body; // ... validate inputs here ... const userData = { email, firstname }; const result = await db('users').returning('id').insert(userData); const id = result[0]; res.status(201).send({ id, ...userData }); } catch (err) { console.log(`Error: Unable to create user: ${err.message}. ${err.stack}`); return next(err); } }); app.route('/api/users').get((req, res, next) => { db('users') .select('id', 'email', 'firstname') .then(users => res.status(200).send(users)) .catch(err => { console.log(`Unable to fetch users: ${err.message}. ${err.stack}`); return next(err); }); }); try { console.log("Starting web server..."); const port = process.env.PORT || 8000; app.listen(port, () => console.log(`Server started on: ${port}`)); } catch(error) { console.error(error.stack); }

Aquí están nuestras pruebas escritas con chai. Las pruebas crean un nuevo usuario y lo recuperan. Puede ver que las pruebas no están acopladas de ninguna manera con el código de nuestra API. La SERVER_URLvariable especifica el punto final a probar. Puede ser un entorno local o remoto.

const chai = require("chai"); const chaiHttp = require("chai-http"); const should = chai.should(); const SERVER_URL = process.env.APP_URL || "//localhost:8000"; chai.use(chaiHttp); const TEST_USER = { email: "[email protected]", firstname: "John" }; let createdUserId; describe("Users", () => { it("should create a new user", done => { chai .request(SERVER_URL) .post("/api/users") .send(TEST_USER) .end((err, res) => { if (err) done(err) res.should.have.status(201); res.should.be.json; res.body.should.be.a("object"); res.body.should.have.property("id"); done(); }); }); it("should get the created user", done => { chai .request(SERVER_URL) .get("/api/users") .end((err, res) => { if (err) done(err) res.should.have.status(200); res.body.should.be.a("array"); const user = res.body.pop(); user.id.should.equal(createdUserId); user.email.should.equal(TEST_USER.email); user.firstname.should.equal(TEST_USER.firstname); done(); }); }); });

Bueno. Ahora, para probar nuestra API, definamos un entorno de composición de Docker. Un archivo llamado docker-compose.ymldescribirá los contenedores que Docker necesita para ejecutar.

version: '3.1' services: db: image: postgres environment: POSTGRES_USER: john POSTGRES_PASSWORD: mysecretpassword expose: - 5432 myapp: build: . image: myapp command: yarn start environment: APP_DB_HOST: db APP_DB_USER: john APP_DB_PASSWORD: mysecretpassword expose: - 8000 depends_on: - db myapp-tests: image: myapp command: dockerize -wait tcp://db:5432 -wait tcp://myapp:8000 -timeout 10s bash -c "node db/init.js && yarn test" environment: APP_URL: //myapp:8000 APP_DB_HOST: db APP_DB_USER: john APP_DB_PASSWORD: mysecretpassword depends_on: - db - myapp

Entonces que tenemos aqui. Hay 3 contenedores:

  • db genera una nueva instancia de PostgreSQL. Usamos la imagen pública de Postgres de Docker Hub. Configuramos el nombre de usuario y la contraseña de la base de datos. Le decimos a Docker que exponga el puerto 5432 que escuchará la base de datos para que otros contenedores puedan conectarse
  • myapp es el contenedor que ejecutará nuestra API. El buildcomando le dice a Docker que realmente cree la imagen del contenedor a partir de nuestra fuente. El resto es como el contenedor db: variables de entorno y puertos
  • myapp-tests es el contenedor que ejecutará nuestras pruebas. Utilizará la misma imagen que myapp porque el código ya estará allí, por lo que no es necesario volver a compilarlo. El comando que se node db/init.js && yarn testejecuta en el contenedor inicializará la base de datos (creará tablas, etc.) y ejecutará las pruebas. Usamos dockerize para esperar a que todos los servidores necesarios estén en funcionamiento. Las depends_onopciones asegurarán que los contenedores comiencen en un orden determinado. No asegura que la base de datos dentro del contenedor db esté realmente lista para aceptar conexiones. Tampoco que nuestro servidor API ya esté activo.

La definición del entorno es como 20 líneas de código muy fácil de entender. La única parte inteligente es la definición del entorno. Los nombres de usuario, las contraseñas y las URL deben ser coherentes para que los contenedores puedan funcionar juntos.

Una cosa a tener en cuenta es que Docker compose establecerá el host de los contenedores que crea con el nombre del contenedor. Entonces, la base de datos no estará disponible en localhost:5432pero db:5432. De la misma forma que se servirá nuestra API myapp:8000. No hay ningún host local de ningún tipo aquí.

Esto significa que su API debe admitir variables de entorno cuando se trata de la definición del entorno. No hay cosas codificadas. Pero eso no tiene nada que ver con Docker o este artículo. Una aplicación configurable es el punto 3 del manifiesto de la aplicación de 12 factores, por lo que ya debería estar haciéndolo.

Lo último que necesitamos decirle a Docker es cómo construir realmente el contenedor myapp . Usamos un Dockerfile como el siguiente. El contenido es específico para su pila tecnológica, pero la idea es agrupar su API en un servidor ejecutable.

El siguiente ejemplo de nuestra API de nodo instala Dockerize, instala las dependencias de la API y copia el código de la API dentro del contenedor (el servidor está escrito en JS sin formato, por lo que no es necesario compilarlo).

FROM node AS base # Dockerize is needed to sync containers startup ENV DOCKERIZE_VERSION v0.6.0 RUN wget //github.com/jwilder/dockerize/releases/download/$DOCKERIZE_VERSION/dockerize-alpine-linux-amd64-$DOCKERIZE_VERSION.tar.gz \ && tar -C /usr/local/bin -xzvf dockerize-alpine-linux-amd64-$DOCKERIZE_VERSION.tar.gz \ && rm dockerize-alpine-linux-amd64-$DOCKERIZE_VERSION.tar.gz RUN mkdir -p ~/app WORKDIR ~/app COPY package.json . COPY yarn.lock . FROM base AS dependencies RUN yarn FROM dependencies AS runtime COPY . .

Por lo general, desde la línea WORKDIR ~/appy más abajo, ejecutaría comandos que construirían su aplicación.

Y aquí está el comando que usamos para ejecutar las pruebas:

docker-compose up --build --abort-on-container-exit

Este comando le dirá a Docker compose que active los componentes definidos en nuestro docker-compose.ymlarchivo. La --buildbandera activará la construcción del contenedor myapp al ejecutar el contenido de lo Dockerfileanterior. El --abort-on-container-exitle dirá a Docker compose que cierre el entorno tan pronto como salga un contenedor.

Eso funciona bien ya que el único componente que debe salir es el contenedor de prueba myapp-tests después de que se ejecutan las pruebas. Cereza en el pastel, el docker-composecomando saldrá con el mismo código de salida que el contenedor que activó la salida. Esto significa que podemos verificar si las pruebas tuvieron éxito o no desde la línea de comandos. Esto es muy útil para compilaciones automatizadas en un entorno de CI.

¿No es esa la configuración de prueba perfecta?

El ejemplo completo está aquí en GitHub. Puede clonar el repositorio y ejecutar el comando docker compose:

docker-compose up --build --abort-on-container-exit

Por supuesto, necesita Docker instalado. Docker tiene la molesta tendencia de obligarte a registrarte para obtener una cuenta solo para descargarlo. Pero en realidad no es necesario. Vaya a las notas de la versión (enlace para Windows y enlace para Mac) y descargue no la última versión, sino la anterior. Este es un enlace de descarga directa.

La primera ejecución de las pruebas será más larga de lo habitual. Esto se debe a que Docker tendrá que descargar las imágenes base para sus contenedores y almacenar en caché algunas cosas. Las próximas carreras serán mucho más rápidas.

Los registros de la ejecución se verán como se muestra a continuación. Puede ver que Docker es lo suficientemente bueno como para colocar registros de todos los componentes en la misma línea de tiempo. Esto es muy útil cuando se buscan errores.

Creating tuto-api-e2e-testing_db_1 ... done Creating tuto-api-e2e-testing_redis_1 ... done Creating tuto-api-e2e-testing_myapp_1 ... done Creating tuto-api-e2e-testing_myapp-tests_1 ... done Attaching to tuto-api-e2e-testing_redis_1, tuto-api-e2e-testing_db_1, tuto-api-e2e-testing_myapp_1, tuto-api-e2e-testing_myapp-tests_1 db_1 | The files belonging to this database system will be owned by user "postgres". redis_1 | 1:M 09 Nov 2019 21:57:22.161 * Running mode=standalone, port=6379. myapp_1 | yarn run v1.19.0 redis_1 | 1:M 09 Nov 2019 21:57:22.162 # WARNING: The TCP backlog setting of 511 cannot be enforced because /proc/sys/net/core/somaxconn is set to the lower value of 128. redis_1 | 1:M 09 Nov 2019 21:57:22.162 # Server initialized db_1 | This user must also own the server process. db_1 | db_1 | The database cluster will be initialized with locale "en_US.utf8". db_1 | The default database encoding has accordingly been set to "UTF8". db_1 | The default text search configuration will be set to "english". db_1 | db_1 | Data page checksums are disabled. db_1 | db_1 | fixing permissions on existing directory /var/lib/postgresql/data ... ok db_1 | creating subdirectories ... ok db_1 | selecting dynamic shared memory implementation ... posix myapp-tests_1 | 2019/11/09 21:57:25 Waiting for: tcp://db:5432 myapp-tests_1 | 2019/11/09 21:57:25 Waiting for: tcp://redis:6379 myapp-tests_1 | 2019/11/09 21:57:25 Waiting for: tcp://myapp:8000 myapp_1 | $ node server.js redis_1 | 1:M 09 Nov 2019 21:57:22.163 # WARNING you have Transparent Huge Pages (THP) support enabled in your kernel. This will create latency and memory usage issues with Redis. To fix this issue run the command 'echo never > /sys/kernel/mm/transparent_hugepage/enabled' as root, and add it to your /etc/rc.local in order to retain the setting after a reboot. Redis must be restarted after THP is disabled. db_1 | selecting default max_connections ... 100 myapp_1 | Starting web server... myapp-tests_1 | 2019/11/09 21:57:25 Connected to tcp://myapp:8000 myapp-tests_1 | 2019/11/09 21:57:25 Connected to tcp://db:5432 redis_1 | 1:M 09 Nov 2019 21:57:22.164 * Ready to accept connections myapp-tests_1 | 2019/11/09 21:57:25 Connected to tcp://redis:6379 myapp_1 | Server started on: 8000 db_1 | selecting default shared_buffers ... 128MB db_1 | selecting default time zone ... Etc/UTC db_1 | creating configuration files ... ok db_1 | running bootstrap script ... ok db_1 | performing post-bootstrap initialization ... ok db_1 | syncing data to disk ... ok db_1 | db_1 | db_1 | Success. You can now start the database server using: db_1 | db_1 | pg_ctl -D /var/lib/postgresql/data -l logfile start db_1 | db_1 | initdb: warning: enabling "trust" authentication for local connections db_1 | You can change this by editing pg_hba.conf or using the option -A, or db_1 | --auth-local and --auth-host, the next time you run initdb. db_1 | waiting for server to start....2019-11-09 21:57:24.328 UTC [41] LOG: starting PostgreSQL 12.0 (Debian 12.0-2.pgdg100+1) on x86_64-pc-linux-gnu, compiled by gcc (Debian 8.3.0-6) 8.3.0, 64-bit db_1 | 2019-11-09 21:57:24.346 UTC [41] LOG: listening on Unix socket "/var/run/postgresql/.s.PGSQL.5432" db_1 | 2019-11-09 21:57:24.373 UTC [42] LOG: database system was shut down at 2019-11-09 21:57:23 UTC db_1 | 2019-11-09 21:57:24.383 UTC [41] LOG: database system is ready to accept connections db_1 | done db_1 | server started db_1 | CREATE DATABASE db_1 | db_1 | db_1 | /usr/local/bin/docker-entrypoint.sh: ignoring /docker-entrypoint-initdb.d/* db_1 | db_1 | waiting for server to shut down....2019-11-09 21:57:24.907 UTC [41] LOG: received fast shutdown request db_1 | 2019-11-09 21:57:24.909 UTC [41] LOG: aborting any active transactions db_1 | 2019-11-09 21:57:24.914 UTC [41] LOG: background worker "logical replication launcher" (PID 48) exited with exit code 1 db_1 | 2019-11-09 21:57:24.914 UTC [43] LOG: shutting down db_1 | 2019-11-09 21:57:24.930 UTC [41] LOG: database system is shut down db_1 | done db_1 | server stopped db_1 | db_1 | PostgreSQL init process complete; ready for start up. db_1 | db_1 | 2019-11-09 21:57:25.038 UTC [1] LOG: starting PostgreSQL 12.0 (Debian 12.0-2.pgdg100+1) on x86_64-pc-linux-gnu, compiled by gcc (Debian 8.3.0-6) 8.3.0, 64-bit db_1 | 2019-11-09 21:57:25.039 UTC [1] LOG: listening on IPv4 address "0.0.0.0", port 5432 db_1 | 2019-11-09 21:57:25.039 UTC [1] LOG: listening on IPv6 address "::", port 5432 db_1 | 2019-11-09 21:57:25.052 UTC [1] LOG: listening on Unix socket "/var/run/postgresql/.s.PGSQL.5432" db_1 | 2019-11-09 21:57:25.071 UTC [59] LOG: database system was shut down at 2019-11-09 21:57:24 UTC db_1 | 2019-11-09 21:57:25.077 UTC [1] LOG: database system is ready to accept connections myapp-tests_1 | Creating tables ... myapp-tests_1 | Creating table 'users' myapp-tests_1 | Tables created succesfully myapp-tests_1 | yarn run v1.19.0 myapp-tests_1 | $ mocha --timeout 10000 --bail myapp-tests_1 | myapp-tests_1 | myapp-tests_1 | Users myapp-tests_1 | Mock server started on port: 8002 myapp-tests_1 | ✓ should create a new user (151ms) myapp-tests_1 | ✓ should get the created user myapp-tests_1 | ✓ should not create user if mail is spammy myapp-tests_1 | ✓ should not create user if spammy mail API is down myapp-tests_1 | myapp-tests_1 | myapp-tests_1 | 4 passing (234ms) myapp-tests_1 | myapp-tests_1 | Done in 0.88s. myapp-tests_1 | 2019/11/09 21:57:26 Command finished successfully. tuto-api-e2e-testing_myapp-tests_1 exited with code 0

Podemos ver que db es el contenedor que se inicializa por más tiempo. Tiene sentido. Una vez hecho esto, comienzan las pruebas. El tiempo de ejecución total de mi computadora portátil es de 16 segundos. En comparación con los 880 ms utilizados para ejecutar las pruebas, es mucho. En la práctica, las pruebas que duran menos de 1 minuto son oro, ya que es una retroalimentación casi inmediata. Los 15 segundos de sobrecarga son un tiempo de compra que será constante a medida que agregue más pruebas. Puede agregar cientos de pruebas y aún así mantener el tiempo de ejecución por debajo de 1 minuto.

¡Voilà! Tenemos nuestro marco de prueba en funcionamiento. En un proyecto del mundo real, los siguientes pasos serían mejorar la cobertura funcional de su API con más pruebas. Consideremos las operaciones CRUD cubiertas. Es hora de agregar más elementos a nuestro entorno de prueba.

Agregar un clúster de Redis

Agreguemos otro elemento a nuestro entorno de API para comprender lo que se necesita. Alerta de spoiler: no es mucho.

Imaginemos que nuestra API mantiene las sesiones de los usuarios en un clúster de Redis. Si se pregunta por qué haríamos eso, imagine 100 instancias de su API en producción. Los usuarios acceden a uno u otro servidor según el equilibrio de carga por turnos. Cada solicitud debe estar autenticada.

Esto requiere datos de perfil de usuario para verificar privilegios y otra lógica comercial específica de la aplicación. Una forma de hacerlo es realizar un viaje de ida y vuelta a la base de datos para obtener los datos cada vez que los necesite, pero eso no es muy eficiente. El uso de un clúster de base de datos en memoria hace que los datos estén disponibles en todos los servidores por el costo de una lectura de variable local.

Así es como puede mejorar su entorno de prueba de composición de Docker con un servicio adicional. Agreguemos un clúster de Redis de la imagen oficial de Docker (solo conservé las partes nuevas del archivo):

services: db: ... redis: image: "redis:alpine" expose: - 6379 myapp: environment: APP_REDIS_HOST: redis APP_REDIS_PORT: 6379 ... myapp-tests: command: dockerize ... -wait tcp://redis:6379 ... environment: APP_REDIS_HOST: redis APP_REDIS_PORT: 6379 ... ...

Puedes ver que no es mucho. Agregamos un nuevo contenedor llamado redis . Utiliza la imagen oficial mínima de redis llamada redis:alpine. Agregamos la configuración de puerto y host de Redis a nuestro contenedor de API. Y hemos hecho que las pruebas lo esperen al igual que los otros contenedores antes de ejecutar las pruebas.

Modifiquemos nuestra aplicación para usar realmente el clúster de Redis:

const redis = require('redis').createClient({ host: config.redis.host, port: config.redis.port, }) ... app.route('/api/users').post(async (req, res, next) => { try { const { email, firstname } = req.body; // ... validate inputs here ... const userData = { email, firstname }; const result = await db('users').returning('id').insert(userData); const id = result[0]; // Once the user is created store the data in the Redis cluster await redis.set(id, JSON.stringify(userData)); res.status(201).send({ id, ...userData }); } catch (err) { console.log(`Error: Unable to create user: ${err.message}. ${err.stack}`); return next(err); } });

Cambiemos ahora nuestras pruebas para verificar que el clúster de Redis esté poblado con los datos correctos. Es por eso que el contenedor myapp-tests también incluye la configuración del puerto y el host de Redis docker-compose.yml.

it("should create a new user", done => { chai .request(SERVER_URL) .post("/api/users") .send(TEST_USER) .end((err, res) => { if (err) throw err; res.should.have.status(201); res.should.be.json; res.body.should.be.a("object"); res.body.should.have.property("id"); res.body.should.have.property("email"); res.body.should.have.property("firstname"); res.body.id.should.not.be.null; res.body.email.should.equal(TEST_USER.email); res.body.firstname.should.equal(TEST_USER.firstname); createdUserId = res.body.id; redis.get(createdUserId, (err, cacheData) => { if (err) throw err; cacheData = JSON.parse(cacheData); cacheData.should.have.property("email"); cacheData.should.have.property("firstname"); cacheData.email.should.equal(TEST_USER.email); cacheData.firstname.should.equal(TEST_USER.firstname); done(); }); }); });

Vea lo fácil que fue esto. Puede construir un entorno complejo para sus pruebas como ensamblar ladrillos de Lego.

Podemos ver otro beneficio de este tipo de pruebas de entorno completo en contenedores. En realidad, las pruebas pueden analizar los componentes del entorno. Nuestras pruebas no solo pueden verificar que nuestra API devuelva los códigos y datos de respuesta adecuados. También podemos verificar que los datos del clúster de Redis tengan los valores adecuados. También podríamos comprobar el contenido de la base de datos.

Agregar simulacros de API

Un elemento común para los componentes de la API es llamar a otros componentes de la API.

Digamos que nuestra API necesita verificar si hay correos electrónicos de usuarios no deseados al crear un usuario. La verificación se realiza mediante un servicio de terceros:

const validateUserEmail = async (email) => { const res = await fetch(`${config.app.externalUrl}/validate?email=${email}`); if(res.status !== 200) return false; const json = await res.json(); return json.result === 'valid'; } app.route('/api/users').post(async (req, res, next) => { try { const { email, firstname } = req.body; // ... validate inputs here ... const userData = { email, firstname }; // We don't just create any user. Spammy emails should be rejected const isValidUser = await validateUserEmail(email); if(!isValidUser) { return res.sendStatus(403); } const result = await db('users').returning('id').insert(userData); const id = result[0]; await redis.set(id, JSON.stringify(userData)); res.status(201).send({ id, ...userData }); } catch (err) { console.log(`Error: Unable to create user: ${err.message}. ${err.stack}`); return next(err); } });

Ahora tenemos un problema para probar cualquier cosa. No podemos crear ningún usuario si la API para detectar correos electrónicos no deseados no está disponible. Modificar nuestra API para omitir este paso en el modo de prueba es un desorden peligroso del código.

Incluso si pudiéramos utilizar el servicio real de terceros, no queremos hacer eso. Como regla general, nuestras pruebas no deberían depender de una infraestructura externa. En primer lugar, porque probablemente ejecutará muchas pruebas como parte de su proceso de CI. No es tan bueno consumir otra API de producción para este propósito. En segundo lugar, la API podría estar temporalmente inactiva y fallar en las pruebas por motivos incorrectos.

La solución correcta es simular las API externas en nuestras pruebas.

No es necesario ningún marco elegante. Construiremos un simulacro genérico en Vanilla JS en ~ 20 líneas de código. Esto nos dará la oportunidad de controlar qué devolverá la API a nuestro componente. Permite probar escenarios de error.

Ahora mejoremos nuestras pruebas.

const express = require("express"); ... const MOCK_SERVER_PORT = process.env.MOCK_SERVER_PORT || 8002; // Some object to encapsulate attributes of our mock server // The mock stores all requests it receives in the `requests` property. const mock = { app: express(), server: null, requests: [], status: 404, responseBody: {} }; // Define which response code and content the mock will be sending const setupMock = (status, body) => { mock.status = status; mock.responseBody = body; }; // Start the mock server const initMock = async () => { mock.app.use(bodyParser.urlencoded({ extended: false })); mock.app.use(bodyParser.json()); mock.app.use(cors()); mock.app.get("*", (req, res) => { mock.requests.push(req); res.status(mock.status).send(mock.responseBody); }); mock.server = await mock.app.listen(MOCK_SERVER_PORT); console.log(`Mock server started on port: ${MOCK_SERVER_PORT}`); }; // Destroy the mock server const teardownMock = () => { if (mock.server) { mock.server.close(); delete mock.server; } }; describe("Users", () => { // Our mock is started before any test starts ... before(async () => await initMock()); // ... killed after all the tests are executed ... after(() => { redis.quit(); teardownMock(); }); // ... and we reset the recorded requests between each test beforeEach(() => (mock.requests = [])); it("should create a new user", done => { // The mock will tell us the email is valid in this test setupMock(200, { result: "valid" }); chai .request(SERVER_URL) .post("/api/users") .send(TEST_USER) .end((err, res) => { // ... check response and redis as before createdUserId = res.body.id; // Verify that the API called the mocked service with the right parameters mock.requests.length.should.equal(1); mock.requests[0].path.should.equal("/api/validate"); mock.requests[0].query.should.have.property("email"); mock.requests[0].query.email.should.equal(TEST_USER.email); done(); }); }); });

Las pruebas ahora verifican que la API externa haya recibido los datos adecuados durante la llamada a nuestra API.

También podemos agregar otras pruebas que verifiquen cómo se comporta nuestra API en función de los códigos de respuesta de la API externa:

describe("Users", () => { it("should not create user if mail is spammy", done => { // The mock will tell us the email is NOT valid in this test ... setupMock(200, { result: "invalid" }); chai .request(SERVER_URL) .post("/api/users") .send(TEST_USER) .end((err, res) => { // ... so the API should fail to create the user // We could test that the DB and Redis are empty here res.should.have.status(403); done(); }); }); it("should not create user if spammy mail API is down", done => { // The mock will tell us the email checking service // is down for this test ... setupMock(500, {}); chai .request(SERVER_URL) .post("/api/users") .send(TEST_USER) .end((err, res) => { // ... in that case also a user should not be created res.should.have.status(403); done(); }); }); });

La forma en que maneja los errores de las API de terceros en su aplicación, por supuesto, depende de usted. Pero usted consigue el punto.

Para ejecutar estas pruebas necesitamos decirle al contenedor myapp cuál es la URL base del servicio de terceros:

 myapp: environment: APP_EXTERNAL_URL: //myapp-tests:8002/api ... myapp-tests: environment: MOCK_SERVER_PORT: 8002 ...

Conclusión y algunos otros pensamientos

Esperamos que este artículo le haya dado una idea de lo que Docker compose puede hacer por usted cuando se trata de pruebas de API. El ejemplo completo está aquí en GitHub.

El uso de Docker compose hace que las pruebas se ejecuten rápidamente en un entorno cercano a la producción. No requiere adaptaciones al código de su componente. El único requisito es admitir la configuración impulsada por variables de entorno.

La lógica del componente en este ejemplo es muy simple, pero los principios se aplican a cualquier API. Tus pruebas serán más largas o más complejas. También se aplican a cualquier pila de tecnología que se pueda poner dentro de un contenedor (eso es todo). Y una vez que esté allí, estará a un paso de implementar sus contenedores en producción si es necesario.

Si no tiene pruebas en este momento, así es como le recomiendo que comience: pruebas de extremo a extremo con Docker compose. Es tan simple que podría ejecutar su primera prueba en unas pocas horas. No dude en comunicarse conmigo si tiene preguntas o necesita un consejo. Estaré feliz de ayudar.

Espero que haya disfrutado de este artículo y comience a probar sus API con Docker Compose. Una vez que tenga las pruebas listas, puede ejecutarlas de inmediato en nuestra plataforma de integración continua Fire CI.

Una última idea para tener éxito con las pruebas automatizadas.

Cuando se trata de mantener grandes conjuntos de pruebas, la característica más importante es que las pruebas son fáciles de leer y comprender. Esto es clave para motivar a su equipo a mantener las pruebas actualizadas. Es poco probable que los marcos de pruebas complejos se utilicen correctamente a largo plazo.

Independientemente de la pila de su API, es posible que desee considerar el uso de chai / mocha para escribir pruebas para ella. Puede parecer inusual tener diferentes pilas de código de tiempo de ejecución y código de prueba, pero si hace el trabajo ... Como puede ver en los ejemplos de este artículo, probar una API REST con chai / mocha es tan simple como parece . La curva de aprendizaje es cercana a cero.

Entonces, si no tiene ninguna prueba y tiene una API REST para probar escrita en Java, Python, RoR, .NET o cualquier otra pila, podría considerar probar chai / mocha.

Si se pregunta cómo empezar con la integración continua, he escrito una guía más amplia al respecto. Aquí está: Cómo empezar con la integración continua

Publicado originalmente en el blog Fire CI.