Definiendo nuestras infraestructuras para desarrollo y testing con Docker
Tags: docker, javascript, typescript, tdd
Published: 2020-12-28 16:39:44

🇬🇧 [English version](https://dev.to/ddialar/mocking-our-development-and-testing-infrastructures-with-docker-4nf0)

## 🎯 Objetivo

Cuando estamos creando una nueva aplicación o funcionalidad, normalmente necesitamos enviar peticiones a recursos independientes como pueden ser una base de datos o servicios con comportamiento controlado pero obviamente, realizar estas tareas contra servidores en la nube tiene un coste.

En este tipo de situaciones es cuando el aislamiento de sistemas que nos proporcionan los contenedores de Docker, es realmente útil.

En este artículo vamos a ver cómo podemos usar Docker para levantar una infraestructura mínima que nos permita ejecutar las tareas de desarrollo y/o testing, localmente.

El principal objetivo de este texto es mostrar cómo utilizar un único archivo `docker-compose.yml` para ambos entornos, empleando diferentes archivos `.env` para personalizar cada contenedor específico tanto para desarrollo como para testing.

Además, nos centraremos en cómo arrancar un nuevo contenedor para testing, ejecutar los tests que sean pertinentes y finalmente, apagar dicho contenedor.

## 💻 Configuración del sistema

Si vamos a hablar sobre Docker, es obvio que necesitamos tenerlo instalado en nuestro sistema. Si aún no lo tienes, puedes seguir las indicaciones dadas en la [documentación oficial](https://docs.docker.com/get-docker/), para el sistema operativo que corresponda.

Otro elemento que vamos a necesitar tener instalado en nuestro sistema es `docker-compose`. De nuevo, si aún no lo tienes instalado, puedes seguir las indicaciones de la [documentación oficial](https://docs.docker.com/compose/install/).

Por último, dado que este ejemplo está orientado a aplicaciones basadas en JavaScript/TypeScript, necesitamos tener instalado NodeJS ([documentación oficial](https://nodejs.org/en/download/)).

## 🏗 Inicialización del proyecto

🔥 Si ya tienes inicializando tu propio proyecto basado en NodeJS, puedes saltarte esta sección 🔥

Vamos a inicializar nuestro proyecto NodeJS abriendo una consola de comandos, en el directorio donde queramos trabajar, y escribimos el siguiente comando:

```sh
npm init -y
```

Esta acción nos creará una único archivo `package.json` en la raíz de nuestro proyecto, con el siguiente contenido:

{% gist https://gist.github.com/ddialar/91ed49d69d70ef03f8c8ec43febf0bd0 file=initial.package.json %}

Ahora podemos instalar [Jest](https://jestjs.io/) ejecutando la siguiente instrucción en nuestra consola de comandos, para incluir esta librería en nuestro proyecto:

```sh
npm i -D jest
```

El siguiente paso es crear la estructura más básica de directorios para el proyecto.

```sh
/
|-- /docker # <= Nuevo directorio.
|-- /node_modules
|-- /src # <= Nuevo directorio.
|-- package-lock.json
|-- package.json
```

## 🐳 Definiendo la configuración de Docker

Vamos a tener dos entornos principales (`development` y `test`) y la idea es tener un único archivo `docker-compose.yml` para gestionar los contenedores de ambos entornos.

### 📄 Definición del archivo `docker-compose.yml`

Para conseguir nuestro objetivo, dentro del directorio `/docker` vamos a crear un único archivo llamado `docker-compose.yml`, el cual contendrá el siguiente código:

{% gist https://gist.github.com/ddialar/91ed49d69d70ef03f8c8ec43febf0bd0 file=coupled.docker-compose.yml %}

Como podemos apreciar, hay varias líneas marcadas como `coupling smell`. Esto significa que, con la configuración actual, podemos ejecutar un único contenedor de Docker destinado principalmente para tareas de desarrollo. Por lo tanto, está altamente acoplado a su entorno de ejecución.

¿No sería genial si fuésemos capaces de reemplazar esas configuraciones definidas directamente en el código, por referencias las cuales vinieran establecidas por algún tipo de archivo de configuración?

### ⚙ Archivos `.env` para contenedores Docker

!Sí! Podemos usar archivos `.env` de la misma manera que ya los usamos para nuestras aplicaciones, pero para configurar contenedores de Docker.

Lo primero que necesitamos hacer es modificar el archivo `docker-compose.yml` que acabamos de crear para usar plantillas basadas en llaves, para definir nombres de constantes que reemplazaremos con los valores indicados en nuestros archivos `.env`. De este modo, el contenido del archivo `docker-compose.yml` quedará de la siguiente manera:

{% gist https://gist.github.com/ddialar/91ed49d69d70ef03f8c8ec43febf0bd0 file=generic.docker-compose.yml %}

Como podemos ver, hemos reemplazado los valores directamente escritos en el código por referencias del tipo `${CONSTANT_NAME}`. El nombre de las variables escrito entre llaves será el nombre de los valores definidos en nuestros archivos `.env`. De esta manera, cuando arranquemos el comando `docker-compose` usando una opción específica de la línea de comandos que veremos más adelante, el contenido del archivo `.env` será reemplazado en nuestro archivo `docker-compose.yml` antes de que se cree el contenedor de Docker.

Ahora es el momento de definir nuestros entornos así que modificamos el contenido del directorio `/docker` para que quede tal que así:

```sh
/
|-- /docker
|   |-- /dev # <= Nuevo directorio y archivo.
|   |   |-- .docker.dev.env
|   |-- /test # <= Nuevo directorio y archivo.
|   |   |-- .docker.test.env
|   |-- docker-compose.yml
|-- /node_modules
|-- /src
|-- package-lock.json
|-- package.json
```

Por cada entorno hemos creado un único subdirectorio: `dev` y `test`.

Dentro de cada subdirectorio de entorno hemos creado un archivo `.env` específico: `.docker.dev.env` y `.docker.test.env`.

🙋❓ **¿Sería posible nombrar los archivos de entorno sólo como  `.env`?**

Sí, es posible y además, no habría ningún problema en ello pero... un nombre de archivo tan descriptivo es una ayuda para nuestro rol como profesionales del desarrollo. Dado que en un mismo proyecto es muy probable que haya múltiples archivos de configuración, es útil ser capaz de diferenciarlos cuando tenemos varios de ellos abiertos, en el editor de código, al mismo tiempo. Esta es la razón por la que los archivos `.env` tienen uno nombres tan descriptivos.

Ahora pasaremos a definir el contenido de nuestros archivos de entornos, para que queden de la siguiente manera:

{% gist https://gist.github.com/ddialar/91ed49d69d70ef03f8c8ec43febf0bd0 file=.docker.dev.env %}

y...

{% gist https://gist.github.com/ddialar/91ed49d69d70ef03f8c8ec43febf0bd0 file=.docker.test.env %}

Hay cuatro propiedades a las que debemos prestar atención a la hora de diferenciar entre los dos archivos:

- `CONTAINER_NAME`
- `EXTERNAL_PORT`
- `VOLUME_NAME`
- `CONFIGURATION_PATH`

La propiedad `CONTAINER_NAME` nos permite definir el nombre del contenedor que veremos después de que éste ha sido creado y además, cuando ejecutamos el comando `docker ps -a` para listar todos los contenedores presentes en nuestro sistema.

`EXTERNAL_PORT` es una propiedad realmente sensible ya que nos permite definir el puerto que el contenedor tendrán publicado y a través del cual, nuestra aplicación podrá conectarse con él. Es realmente importante tener cuidado con este parámetro porque algunas veces nos interesará tener levantados ambos entornos al mismo tiempo (`development` y `test`), pero si hemos definido el mismo puerto de acceso para ambos contenedores, el sistema nos lanzará un error al lanzar el segundo contenedor, ya que el puerto estará ocupado.

La propiedad `VOLUME_NAME` definirá el nombre del almacenamiento de datos en nuestro sistema.

Finalmente, en caso de que hayamos definido cualquier tipo de conjunto de datos para inicializar nuestra base de datos antes de usarla, la propiedad `CONFIGURATION_PATH` nos permitirá definir dónde está ubicado ese conjunto de datos.

🙋‍♀️❓ **Oye pero, ¿qué pasa con la propiedad `COMPOSE_PROJECT_NAME`?**

Esa es una magnífica pregunta.

Nuestro primer objetivo es crear un contenedor específico por cada entorno, basándonos en el mismo archivo `docker-compose.yml`.

Ahora mismo, si ejecutamos nuestro `docker-compose` para `development`, por ejemplo, crearemos el contenedor con esa definición de entorno y el archivo `docker-compose.yml` quedará enlazado a dicho contenedor.

De este modo, si intentamos ahora arrancar el mismo archivo pero utilizando la configuración para `testing`, el resultado final será que hemos actualizado el contenedor previo de `development`, sin la configuración para el entorno de `testing`. ¿Por qué? Pues porque el archivo de composición está enlazado al contenedor que arrancamos inicialmente.

Para conseguir nuestro objetivo satisfactoriamente, empleamos la propiedad `COMPOSE_PROJECT_NAME` dentro de cada archivo `.env` y le asignamos valores diferentes dependiendo del entorno al que pertenezca.

De esta manera, cada vez que ejecutemos el archivo de composición, dado que el nombre de proyecto es diferente para cada archivo `.env`, las modificaciones que se apliquen sólo afectarán al contenedor que corresponda con dicho nombre de proyecto.

🙋❓ **Vale, bien, pero hemos usado la propiedad `COMPOSE_PROJECT_NAME` sólo dentro de nuestros archivos `.env` y no en el archivo `docker-compose.yml`. ¿Cómo es posible que afecte al resultado final?**

Es posible porque esa propiedad es leída directamente por el comando `docker-compose` y no es necesario que esté incluida dentro del archivo `docker-compose.yml`.

En este enlace puedes encontrar toda la [documentación oficial](https://docs.docker.com/compose/reference/envvars/#compose_project_name) about `COMPOSE_PROJECT_NAME`.

## 🤹‍♂️ Inicializando la base de datos

🔥 Advertencia: El proceso que se expone a continuación está dirigido a inicializar el contenido de una base de datos MongoDB. Si quieres usar un motor diferente, necesitarás adaptar este proceso así como la configuración del `docker-compose.yml` para ello. 🔥

El concepto más básico que debemos saber, si es que no lo sabemos ya, es que cuando un contenedor basado en MongoDB se ejecuta por primera vez, todos los archivos con extensión `.sh` o `.js` ubicados en el directorio `/docker-entrypoint-initdb.d` dentro del propio contenedor, son ejecutados.

Esto nos proporciona una manera para inicializar nuestra base de datos.

Si quieres profundizar en esta propiedad, puedes consultar la [documentación de la imagen oficial de MongoDB en Docker](https://hub.docker.com/_/mongo).

### 🧪 Configuración del entorno de testing

Para ver cómo podemos hacer esto, vamos a empezar por el entorno de `testing` así que antes de nada, tenemos que crear la siguiente estructura de archivos dentro del directorio `/docker/test` de nuestro proyecto:

```sh
/
|-- /docker
|   |-- /dev
|   |   |-- .docker.dev.env
|   |-- /test
|   |   |-- /configureDatabase # <= Nuevo directorio y archivo.
|   |   |   |-- initDatabase.js
|   |   |-- .docker.test.env
|   |-- docker-compose.yml
|-- /node_modules
|-- /src
|-- package-lock.json
|-- package.json
```

El contenido del archivo `initDatabase.js` será el siguiente:

{% gist https://gist.github.com/ddialar/91ed49d69d70ef03f8c8ec43febf0bd0 file=test.initDatabase.js %}

Este script está dividido en tres elementos diferentes.

La constante `apiDatabases` contiene todas las definiciones de bases de datos que queremos crear para nuestro contenedor.

Cada definición de base de datos contendrá su nombre (`dbName`), un array de usuarios (`dbUsers`) los cuales estarán autorizados para operar con la base de datos (incluyendo la definición de sus privilegios de acceso) y el conjunto de datos con los que inicializaremos la base de datos.

La función `createDatabaseUser` está destinada a gestionar la información contenida en cada bloque del `apiDatabases`, procesar los datos de usuarios y crearlos dentro de la base de datos indicada.

Finalmente, el bloque `try/catch` contiene la magia porque en este bloque iteramos sobre la constante `apiDatabase`, conmutamos entre bases de datos y procesamos la información.

Una vez que hemos analizado este código, si recordamos el contenido de nuestro archivo `docker-compose.yml`, dentro de la sección `volumes` definimos la siguiente línea:

`- ${CONFIGURATION_PATH}:/docker-entrypoint-initdb.d:rw`

Además, para el entorno de `testing`, dentro del archivo `.docker.test.env`, configuramos lo siguiente:

`CONFIGURATION_PATH="./test/configureDatabase"`

Con esta acción, el proceso `docker-compose` está copiando el contenido de la ruta indicada por `CONFIGURATION_PATH` dentro del directorio del contenedor `/docker-entrypoint-initdb.d:rw` antes de que éste se arranque por primera vez. Así es como estamos definiendo el script de configuración de nuestra base de datos, para que sea ejecutado al iniciarse el contenedor.

🙋‍♀️❓ **Para esta configuración no estás usando ningún conjunto de datos iniciales. ¿Por qué?**

Porque esta será la base de datos de testing y la intención es que se almacenen y eliminen datos ad-hoc en base a los tests que estén ejecutándose en un momento concreto. Por esta razón no tiene sentido que inicialicemos la base de datos con información que vamos a crear/editar/eliminar dinámicamente.

### 🛠 Configuración del entorno de desarrollo

Esta configuración es muy similar a la de `testing`.

Lo primero que tenemos que hacer es modificar el subdirectorio  `/docker/dev` de nuestro proyecto, para que quede tal que así:

```sh
/
|-- /docker
|   |-- /dev
|   |   |-- /configureDatabase # <= Nuevo directorio y archivos.
|   |   |   |-- initDatabase.js
|   |   |   |-- postsDataToBePersisted.js
|   |   |   |-- usersDataToBePersisted.js
|   |   |-- .docker.dev.env
|   |-- /test
|   |   |-- /configureDatabase
|   |   |   |-- initDatabase.js
|   |   |-- .docker.test.env
|   |-- docker-compose.yml
|-- /node_modules
|-- /src
|-- package-lock.json
|-- package.json
```

Los archivos `postsDataToBePersisted.js` y `usersDataToBePersisted.js` sólo contienen información estática definida dentro de constantes independientes. Esta información será almacenad en la base de datos indicada, dentro de la colección especificada.

La estructura de dichos contenidos será la siguiente:

{% gist https://gist.github.com/ddialar/91ed49d69d70ef03f8c8ec43febf0bd0 file=postsDataToBePersisted.js %}

{% gist https://gist.github.com/ddialar/91ed49d69d70ef03f8c8ec43febf0bd0 file=usersDataToBePersisted.js %}

Por otro lado, el contenido del archivo `initDatabase.js` es bastante similar al del entorno de `testing` pero un poco más complejo ya que ahora tenemos que gestionar colecciones y datos. De este modo, el resultado final es este:

{% gist https://gist.github.com/ddialar/91ed49d69d70ef03f8c8ec43febf0bd0 file=dev.initDatabase.js %}

En este script hay varias partes que necesitamos analizar.

En la cabecera tenemos un bloque compuesto por dos llamadas a la función `load()` encaminadas a importar los datos preparados y almacenados en las constantes que declaramos en los otros archivos JavaScript.

{% gist https://gist.github.com/ddialar/91ed49d69d70ef03f8c8ec43febf0bd0 file=loadingMockedData.js %}

🔥 Hay que prestar atención a que la ruta indicada para hacer referencia a los archivos de datos, es relativa al interior de la estructura de ficheros del contenedor de Docker y no a la de nuestro sistema. 🔥

ℹ️ Si quieres aprender más acerca de cómo ejecutar MongoDB archivos JavaScript en su consola de comandos, echa un vistazo a su [documentación oficial](https://docs.mongodb.com/manual/tutorial/write-scripts-for-the-mongo-shell/#execute-a-javascript-file).

Después de "importar" las definiciones de las constantes `usersToBePersisted` y `postsToBePersisted` mediante el uso de la función `load()`, estas están disponibles globalmente dentro del contexto de nuestro script de inicialización.

El siguiente bloque a analizar es el de la constante `apiDatabases` donde, además de los campos `dbName` y `dbUsers` que ya vimos en la configuración de `testing`, en este caso el array `dbData` es un poco más complejo.

Cada objeto declarado dentro del array `dbData` define el nombre de la colección así como el conjunto de datos que debe ser almacenado en dicha colección.

{% gist https://gist.github.com/ddialar/91ed49d69d70ef03f8c8ec43febf0bd0 file=dataCollectionJoining.js %}

Ahora nos encontramos con la definición de la constante `collections`. Es la definición de un mapa de funciones el cual contiene las acciones que se deben ejecutar por cada colección definida en el bloque `apiDatabases.dbData`.

{% gist https://gist.github.com/ddialar/91ed49d69d70ef03f8c8ec43febf0bd0 file=collectionsHandler.js %}

Como podemos ver, en estas funciones estamos invocando directamente instrucciones nativas de MongoDB.

La siguiente función que nos encontramos es `createDatabaseUsers` la cual no tiene diferencias con la que definimos para el entorno de `testing`.

Justo antes de terminar el archivo, encontramos la función `populateDatabase`.

{% gist https://gist.github.com/ddialar/91ed49d69d70ef03f8c8ec43febf0bd0 file=populateDatabase.js %}

En esta función es donde vamos a través de las colecciones de bases de datos, insertando los datos asignados y aquí es donde invocamos al mapa de funciones `collections`.

Finalmente tenemos el bloque `try/catch` donde ejecutamos las mismas acciones que para el entorno `testing` pero hemos incluido la llamada a la función `populateDatabase`.

{% gist https://gist.github.com/ddialar/91ed49d69d70ef03f8c8ec43febf0bd0 file=execDatabasePopulation.js %}

De esta manera es como hemos podido configurar el script de inicialización para nuestra base de datos del entorno de desarrollo.

## 🧩 Comando de Docker Compose

Una vez que hemos definido el archivo de composición así como el conjunto de datos que inicializará nuestra base de datos, tenemos que definir los campos mediante los cuales, operaremos nuestros contenedores.

{% gist https://gist.github.com/ddialar/91ed49d69d70ef03f8c8ec43febf0bd0 file=docker-compose.commands.sh %}

🔥 Hay que prestar especial atención al hecho de que las rutas empleadas están referenciadas a la raíz de nuestro proyecto. 🔥

## 🌟 Configurando los últimos detalles para NodeJS

El último paso es definir los scripts necesarios dentro de nuestro archivo `package.json`.

{% gist https://gist.github.com/ddialar/91ed49d69d70ef03f8c8ec43febf0bd0 file=complete.package.json %}

Para proporcionar una mejor modularización de los scripts, es muy recomendable que se dividan en diferentes scripts atómicos y luego, crear otros scripts diferentes para agrupar aquellos que sean más específicos.

Por ejemplo, en este código hemos definido los scripts `dev_infra:up`, `dev_infra:down`, `test:run`, `test_infra:up` and `test_infra:down` que son atómicos porque definen una acción simple y serán los encargados de arrancar y para los contenedores para cada entorno, así como de ejecutar la suite de test.

Por el contrario tenemos los scripts `build:dev` y `test` que son compuestos ya que cada uno involucra varios scripts atómicos.

## 🤔 FAQ

❓ **¿Qué pasa si la suite de testing se para repentinamente porque alguno de los tests ha fallado?**

No hay que preocuparse por esto porque es verdad que la infraestructura de testing continuará ejecutándose pero tenemos dos opciones:

1. Mantener en ejecución el contenedor ya que la próxima vez que ejecutemos la suite de tests, el comando `docker-compose` actualizará el contenido del contenedor.
2. Ejecutar manualmente el script de apagado del contenedor de testing.

❓ **¿Qué sucede si en lugar de una base de datos, necesitamos ejecutar algún servicio más complejo como una API?**

Sólo necesitamos configurar los contenedores/servicios necesarios dentro del archivo `docker-compose.yml`, prestando especial atención a la configuración `.env` para cada entorno.

No importa lo que queramos incluir en nuestros contenedores. Lo importante aquí es que vamos a ser capaces de arrancarlos y detenerlos cuando nuestro proyecto lo necesite.

## 👋 Conclusiones finales

Con esta configuración podemos incluir la gestión de la infraestructura necesaria para nuestros proyectos con NodeJS.

Este tipo de configuraciones nos proporciona un nivel de desacoplamiento que aumenta nuestra independencia durante la fase de desarrollo, ya que vamos a tratar elementos externos a nuestro código como una caja negra con la cual interactuar.

Otro punto interesante de esta estrategia es que cada vez que arrancamos el contenedor mediante el comando `docker-compose`, éste es totalmente renovado lo que nos permite asegurar que nuestras suites de tests van a ejecutarse sobre sistemas completamente limpios.

Además, mantendremos limpio nuestro propio sistema ya que no necesitaremos instalar ningún tipo de aplicación auxiliar porque todas ellas, estarán incluidas en diferentes contenedores que compondrán nuestras infraestructura de pruebas.

Sólo una advertencia a este respecto, trata de mantener el contenido de dichos contenedores lo más actualizado posible para, de ese modo, hacer las pruebas contra un entorno lo más parecido posible al que podemos encontrarnos en producción.

Espero que este contenido te sea útil. Si tienes cualquier pregunta, siéntete totalmente libre de contactar conmigo. Aquí están mis perfiles de [Twitter](https://twitter.com/ddialar), [LinkedIn](https://linkedin.com/in/ddialar) y [Github](https://github.com/ddialar).

## 🙏 Reconocimientos y agradecimientos

- [Jonatan Ramos](https://twitter.com/jntramosbonilla) por darme la pista del `COMPOSE_PROJECT_NAME` para crear archivos  `docker-compose.yml` único que se comparten entre diferentes entornos.