Cómo ejecutar pruebas de código dentro de contenedores Docker

Tiempo de lectura: 9 minutos
Imagen ornamental con el logo de Docker para la entrada Cómo ejecutar pruebas de código dentro de contendores Docker

Una de las cosas que me encantan de mi trabajo es que me toca salir muy frecuentemente de mi zona de confort para solucionar las necesidades de los clientes. Este mes me ha tocado un caso del que he encontrado muy poca documentación y he tenido eso me ha llevado a varias charlas casi filosóficas con varios compañeros (y no, no ha sido sobre leer y escribir contadores de rendimiento o código superoptimizado).

Por el título de la entrada ya imaginarás sobre que han tratado esas charlas… cómo y dónde hacer las pruebas de código cuando trabajamos con contenedores Docker. Si todo el trabajo que limitase a mi equipo local no habría ningún problema al respecto y cualquier modo sería suficiente, el problema viene cuando entra en juego la integración continua.

Disclaimer: En esta entrada se da por supuesto que se conoce un mínimo de Docker y de pruebas de código en .Net. La idea es exponer el problema que me encontré y la solución que le di. Entrar en gran detalle requeriría de varias entradas para plantear los diferentes conceptos que se tocan.

Planteemos el problema

Por un lado, todo proyecto que desarrollemos debería ir acompañado de sus pruebas de código para asegurar la calidad, hasta aquí todo bien. Por otro lado, Docker nos facilita la vida al poder tener todo dentro de nuestro contenedor y no necesitar en nuestra máquina nada más que el propio Docker pero… ¿Cómo podemos juntar estas dos cosas?

Es un escenario muy habitual en proyectos donde existe una integración continua el hecho de generar un informe de cobertura, utilizar un analizador estático como SonarCloud, o simplemente recoger elementos intermedios del proceso.

Si estamos trabajando sin Docker, esto es muy sencillo ya que tenemos acceso total sobre todos esos elementos generados como pueden ser los reportes, aquí no hay fallo.

Por el contrario, si estamos trabajando con Docker y no necesitamos recolectar ninguno de estos elementos, el proceso también es sencillo, basta con añadir un stage en el Dockerfile que ejecute las pruebas de código, de modo que no se genere nuestra imagen Docker si estas no pasan.

Algo así podría bastar:

FROM mcr.microsoft.com/dotnet/core/runtime:3.1-buster-slim AS base
WORKDIR /app

FROM mcr.microsoft.com/dotnet/core/sdk:3.1-buster AS restore
WORKDIR /source
COPY ["src/Servicio/Servicio.csproj", "src/Servicio/"]
COPY ["test/UnitTests/UnitTests.csproj", "test/UnitTests/"]
COPY ["*.sln", "."]
RUN dotnet restore 

FROM restore AS build
COPY . .
RUN dotnet build  -c Release --no-restore

FROM build AS test
VOLUME ["/source"]
RUN dotnet test -c Release --no-build --logger trx

FROM build AS publish
RUN dotnet publish "src/Servicio/Servicio.csproj" -c Release --no-build -o /app/publish 

FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "Servicio.dll"]

En el proceso de creación de esta imagen Docker, se ejecutarán las pruebas de código en una etapa y no nos tenemos que preocupar de nada más.

El problema aquí es que, utilizando una imagen como esta, no podemos recoger ningún elemento propio del proceso de testing, por lo tanto, no podemos generar ningún tipo de cobertura.

En una de esas muchas conversaciones sobre el tema, una de las soluciones que me aportaba un compañero era la de cambiar el punto de vista y en vez de intentar hacer todo en Docker, hacerlo fuera. Esta solución básicamente consiste en que me olvide de que estoy trabajando con Docker para el proceso de compilación y pruebas de código, y que solamente después de que todo haya terminado bien, genere la imagen copiando directamente los propios binarios que ya he utilizado antes.

Esta aproximación, aunque simplifica mucho el proceso, pierde la potencia inherente a Docker, lo que corre en mi máquina correrá igual en cualquier sitio. Personalmente pienso que, si estamos utilizando Docker, todo el proceso debería ir dentro de este. De este modo, cualquier persona en cualquier máquina, puede replicar lo mismo que he hecho yo sin necesidad de tener nada más aparte de Docker (y el código fuente).

Ojo, esto es una opinión personal. Cada persona puede tener una opinión diferente y ser válida. El hecho de hacerlo todo utilizando Docker hace que, aunque el proceso sea muy fácilmente replicable, requiera de más trabajo y de una estructura y código extra que hay que mantener.

Cómo ejecutar pruebas de código dentro de Docker y recuperar los reportes

Llegados a este punto y asumiendo que lo vamos a hacer las pruebas de código utilizando Docker, ¿qué opciones tenemos?

  • Todo en el mismo Dockerfile
  • Un Dockerfile exclusivo para las pruebas

Como sabemos, en un Dockerfile la imagen final se construye a partir del último FROM, por tanto, podemos pensar en crear un fichero Dockerfile para las pruebas de código parecido a este:

FROM mcr.microsoft.com/dotnet/core/runtime:3.1-buster-slim AS base
WORKDIR /app

FROM mcr.microsoft.com/dotnet/core/sdk:3.1-buster AS restore
WORKDIR /source
COPY ["src/Servicio/Servicio.csproj", "src/Servicio/"]
COPY ["test/UnitTests/UnitTests.csproj", "test/UnitTests/"]
COPY ["*.sln", "."]
RUN dotnet restore 

FROM restore AS build
COPY . .
RUN dotnet build  -c Release --no-restore

FROM build AS test
VOLUME ["/source"]
ENTRYPOINT dotnet test -c Release --no-build --logger trx

Con esto ya estaría solucionado el problema porque vamos a tener una imagen sobre la que lanzar las pruebas. Basta con hacer un binding mount para asociar una ruta del contenedor con una del equipo local, y ya tendríamos los reportes. El problema de esta aproximación es que tenermos dos ficheros Dockerfile que mantener en vez de uno, por tanto, es muy probable que con el paso del tiempo cambien entre ellos. Es por eso que esta opción la descartamos directamente.

En cambio, ¿cómo podemos tener un único fichero Dockerfile y generar dos imágenes distintas? Podemos utilizar para eso el modificador --target indicándole la etapa de generación que queremos. Con eso vamos a poder generar 2 imágenes distintas.

La ventaja de esta aproximación es que solamente vamos a necesitar mantener un fichero, y vamos a aprovechar el hecho de que Docker cachea las capas de un Dockerfile que no han cambiado.

Esto lo podemos comprobar muy fácilmente ejecutando desde la carpeta que contiene el fichero .sln el comando

docker build -f .\src\Servicio\Dockerfile .

y después creamos la imagen Docker para pasar las pruebas de código con el comando

docker build --target test -f .\src\Servicio\Dockerfile .

podemos comprobar que todo el proceso lo ha recuperado desde la cache:

Sending build context to Docker daemon  23.04kB
Step 1/14 : FROM mcr.microsoft.com/dotnet/core/runtime:3.1-buster-slim AS base
 ---> e6780479db63
Step 2/14 : WORKDIR /app
 ---> Using cache
 ---> aa2fa7df8294
Step 3/14 : FROM mcr.microsoft.com/dotnet/core/sdk:3.1-buster AS restore
 ---> 4aa6a74611ff
Step 4/14 : WORKDIR /source
 ---> Using cache
 ---> c941096ae2df
Step 5/14 : COPY ["src/Servicio/Servicio.csproj", "src/Servicio/"]
 ---> Using cache
 ---> e9e1202510a2
Step 6/14 : COPY ["test/UnitTests/UnitTests.csproj", "test/UnitTests/"]
 ---> Using cache
 ---> 70d24d6b3f35
Step 7/14 : COPY ["*.sln", "."]
 ---> Using cache
 ---> 59599862587b
Step 8/14 : RUN dotnet restore
 ---> Using cache
 ---> 50a0d635033f
Step 9/14 : FROM restore AS build
 ---> 50a0d635033f
Step 10/14 : COPY . .
 ---> Using cache
 ---> ae9959c7cd9d
Step 11/14 : RUN dotnet build  -c Release --no-restore
 ---> Using cache
 ---> 423be5fb0df8
Step 12/14 : FROM build AS test
 ---> 423be5fb0df8
Step 13/14 : VOLUME ["/source"]
 ---> Using cache
 ---> bb09913d383e
Step 14/14 : RUN dotnet test -c Release --no-build --logger trx
 ---> Using cache
 ---> 32e49850ef89
Successfully built 32e49850ef89

Esta aproximación tiene un problema, y es que cuando hagamos

docker run

sobrescribiendo el ENTRYPOINT (ya que de hecho no tiene ninguno), vamos a volver a ejecutar las pruebas. Es decir, vamos a ejecutar las pruebas durante la generación de la imagen y después durante la generación de la cobertura.

Ejecutando solo una vez las pruebas de código dentro de Docker

En muchos casos, el hecho de que las pruebas se ejecuten dos veces no será un problema ya que en caso de pruebas unitarias donde se tarda unos segundos, la diferencia no es notable. Pero… ¿qué pasaría si tenemos unas pruebas de integración y funcionales que si tardan tiempo? Pues simplemente que estaríamos duplicando el tiempo de ejecución, ya que por un lado ejecutamos las pruebas al generar la imagen Docker y otra vez al generar los reportes.

Podemos solucionar esto cambiando ligeramente el Dockerfile y cambiando la etapa de test por esta otra:

FROM build AS test
VOLUME ["/source"]
ENTRYPOINT ["/bin/sh", "-c", "dotnet test -c Release --no-build"]

Con esto estamos sacando de la generación de la imagen la ejecución de las pruebas, pero hemos dejado preparado el proceso ejecutarlas en la nueva imagen.

¡OJO! Literalmente hemos sacado del proceso de generación de la imagen Docker la ejecución de las pruebas. Si optamos por esta aproximación es requisito indispensable generar la imagen de pruebas y ejecutarla como parte de la integración continua para que, en caso de dar un error, se produzca el fallo y no sigamos adelante.

Con este pequeño cambio, basta con ejecutar el contenedor con el volumen y especificar en la salida de las pruebas para tener acceso a los resultados:

docker run --rm --entrypoint dotnet -v ruta_interna:/test-results imagen  test -c Release --no-build /p:CollectCoverage=true /p:CoverletOutputFormat=\"opencover,cobertura\" /p:CoverletOutput=/test-results/

Dependiendo de que terminal utilices (cmd, powershell, bash,…) puede que necesites indicar el parámetro de escape para que reconozca las comillas.

Con estos pasos hemos conseguido ejecutar todo el proceso de compilación y ejecución de pruebas de código dentro de Docker, y además hemos conseguido recolectar todos los resultados de las pruebas para poder generar reportes.

Limitaciones de los reportes de cobertura con Coverlet

Si ya hemos ejecutado las pruebas de código dentro de Docker y hemos recolectado los reportes, ¿estamos listos para generar un informe de cobertura?

La verdad es que por desgracia no… si intentásemos utilizar ese reporte de cobertura para generar un informe vamos a obtener un error avisando de que no se puede cargar el código fuente. Este error viene de que coverlet ha utilizado las rutas internas del contendor como podemos comprobar en el informe:

<?xml version="1.0" encoding="utf-8"?>
<coverage ...>
  <sources>
    <source>/source/src/Servicio/</source>
  </sources>
  <packages>
    <package name="Servicio" ...>
      <classes>
        <class name="Servicio.Program" filename="Program.cs" ...>

Ahora mismo hay una incidencia abierta planteando si es útil que coverlet soporte el indicar las rutas y que no las detecte pero de momento, lo único que nos queda es reemplazar las rutas como buenamente podamos. Por ejemplo, con un script de powershell que reemplace las rutas internas del contenedor por las externas de la máquina.

Si tienes dudas sobre como poder hacerlo, te dejo un enlace a un repositorio donde utilizando powershell se reemplazan los valores para poder continuar adelante en un pipeline de Azure DevOps.

Una vez reemplazados los valores, ya podemos seguir adelante con el proceso normal y los siguientes pasos no sabrán si hemos ejecutado las pruebas de código dentro o fuera de un contenedor Docker.

Utilizando docker-compose

Si bien es cierto que esto es muy útil, cuando tenemos varios proyectos de pruebas la cosa se puede complicar si vamos imagen a imagen ejecutando los test… Además, puede que necesitemos alguna dependencia externa como una base de datos o una cache distribuida…

Gracias a docker-compose vamos a poder atajar de un plumazo ambas situaciones, basta con que creemos un fichero docker-compose.yml para las pruebas. En este fichero podemos declarar todas las dependencias que necesitemos como contenedores adicionales y además, ejecutar cada contenedor utilizando una etapa concreta de un Dockerfile.

version: '3.4'

services:
  unit-test:
    build:
      context: .
      dockerfile: src/Servicio/Dockerfile
      target: unittest
    entrypoint:
      - dotnet
      - test
      - --logger
      - trx;LogFileName=/test-results/unit-test-results.trx
      - --configuration
      - Release
      - --no-build
      - /p:CollectCoverage=true
      - /p:CoverletOutputFormat="opencover,cobertura"
      - /p:CoverletOutput=/test-results/
    volumes:
    - ./test-results/unit-test-results:/test-results

  integration-test:
    build:
      context: .
      dockerfile: src/Servicio/Dockerfile
      target: integrationtest
    entrypoint:
      - dotnet
      - test
      - --logger
      - trx;LogFileName=/test-results/integration-test-results.trx
      - --configuration
      - Release
      - --no-build
      - /p:CollectCoverage=true
      - /p:CoverletOutputFormat="opencover,cobertura"
      - /p:CoverletOutput=/test-results/
    volumes:
    - ./test-results/integration-test-results:/test-results

La idea detrás de este planteamiento es que podemos generar la imagen partiendo del Dockerfile de manera normal, pero a la vez ese mismo fichero contiene todo lo necesario para ejecutar las pruebas en las diferentes etapas.

Conclusión

Docker es un mundo en si mismo y en esta entrada se han dado muchas cosas por sabidas para no alargarnos indefinidamente. Aun así, ya se puede comprobar la potencia y la versatilidad que ofrece incluso durante las pruebas de código.

A la pregunta de si hacer las pruebas de código dentro o fuera de Docker, personalmente pienso que, si utilizamos Docker en el proyecto, deberíamos intentar utilizarlo para todo lo posible. Con esto vamos a conseguir reducir las dependencias para el resto del equipo. Esto es solo una opinión personal y ni siquiera compartida por todos los miembros de mi propio equipo así que tómala con cautela y reflexión.

Y tú, ¿ qué opinas al respecto? ¿conocías esta posibilidad de trabajar para hacer pruebas de código dentro de un contenedor Docker? Por otro lado, si te has quedado con ganas de conocer en detalle todo el proceso, deja un comentario y si hay interés preparo una serie de entradas entrando más a fondo en estos temas.

Para que puedas probar en primera persona, he dejado el código subido a Github para que puedas descargarlo y jugar con él.

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *