DEV Community

loading...
Cover image for Integrando TestContainers en el contexto de Spring en nuestros tests
Adevinta Spain

Integrando TestContainers en el contexto de Spring en nuestros tests

alextremp profile image Alex Castells ・14 min read

Aún me acuerdo de cómo hace muuuuchos años, en "la edad de los hierros", configurar un entorno local para desarrollar partes de plataformas complejas era un drama, y cómo se acababan configurando hosts in-house para replicar esa infraestructura externa y poder trabajar en equipo sobre la misma base.

Pero, a la hora de ejecutar tests en esas máquinas, a la que se necesitara algún cambio de versión mientras se seguía desarrollando sobre lo antiguo, o simplemente cambiar el tipo de dato de una columna de BBDD, drama otra vez, o replicarse esa máquina en local "temporalmente".

En este post os daré una pincelada a cómo podemos integrar TestContainers en el flujo de ejecución de tests con Junit5 para un servicio desarrollado con Spring Boot, dejando que sea Spring quien haga el trabajo por nosotros, aprovechándonos de las características del ciclo de vida del ApplicationContext durante la ejecución de los tests.

Para ello, integraré testcontainers con distintas soluciones sobre un proyecto demo base, y os expondré corner cases en los que esas soluciones pueden no ser operativas, teniendo en cuenta que para los tests, y entornos de CI, por lo general es [una mala práctica fijar puertos.], de modo que este post se focalizará en la ejecución de tests con infraestructuras externas levantadas en local en puertos dinámicos.
(https://www.testcontainers.org/features/networking/) From the host's perspective Testcontainers actually exposes this on a random free port. This is by design, to avoid port collisions that may arise with locally running software or in between parallel test runs.

Para darle un poco de gracia al uso de contenedores tanto para el desarrollo local como para los tests, y entender por qué usándolos podemos lograr reducir al máximo el riesgo de subir código a un entorno real cuando los tests pasan y "en local funciona", os propongo un servicio que no expondrá una API, sino que:

  • debe escuchar mensajes de texto emitidos por otro servicio a una cola de RabbitMQ
  • debe grabar esos mensajes a una base de datos PostgreSQL

A priori, un servicio muy sencillo, pero

  • cómo validaríamos que realmente funciona?
  • cómo automatizaríamos esas validaciones?

Como base, usaré este repositorio que implementa el servicio:

😑 Rama base, sin TestContainers, pero podremos probarlo haciendo hecho un docker-compose up previamente.

También, como referencia a las pruebas que comentaré por puntos al final del post, os dejo estas PR para poder ver cómo cambiaría el código base, o descargaros la rama para jugar.

😕 TestContainers integrados con JUnit
🚀 TestContainers singleton gestionados manualmente
😍 TestContainers gestionados por Spring

Pero antes de empezar, una breve intro a Docker Compose y TestContainers

Docker Compose

Hoy en día, Docker facilita y estandariza la forma en cómo los servicios de nuestras plataformas se despliegan en la nube, y Docker Compose nos facilita la vida en el entorno local, habilitándonos esos servicios de infraestructuras (bases de datos, sistemas de mensajería, ...) externas de un modo fiable.

Seguramente habréis visto en la raíz de gran cantidad de repositorios un fichero docker-compose.yml, por lo general muy fáciles de entender a primera vista, como por ejemplo:

version: '3.5'

services:

  # base de datos PostgreSQL
  message-store-db:
    # https://hub.docker.com/_/postgres
    image: postgres:13.0-alpine
    # mapeo de puertos (puerto_accesible_desde_localhost:puerto_del_servicio_en_el_contenedor)
    ports:
      - "5432:5432"
    # configuración de la imagen de docker
    environment:
      POSTGRES_USER: "demo"
      POSTGRES_PASSWORD: "p4ssw0rd"
      POSTGRES_DB: "messagestore"
    # inicialización de la base de datos 
    volumes:
      - ./.init/message-store-db/init.sql:/docker-entrypoint-initdb.d/init.sql
Enter fullscreen mode Exit fullscreen mode

Con instrucciones para levantar el repositorio en local, y que con un simple comando:

docker-compose up
Enter fullscreen mode Exit fullscreen mode

Tenemos la infraestructura externa, en este caso una base de datos, lista en nuestra máquina para poder operar con ella desde nuestro servicio levantado en local, o incluso en este ejemplo, conectándonos directamente a PostgreSQL con algún visor de bases de datos.

Esto nos permite validar que nuestro proyecto, que usará un driver específico para la base de datos, ejecutando sentencias o configurando opciones que quizá no están disponibles para todas las versiones de ese sistema de base de datos, funciona.

Y como podremos levantar en local la misma versión de base de datos que tengamos en entorno real, el riesgo de que no funcione ahí se reduce a mínimos.

Esto no pasaría implementando los accesos a base de datos de los tests con por ejemplo, una base de datos en memoria como H2, que por defecto no soporta PL/SQL.

Pero, si desarrollamos en local con esos servicios, mirando que nuestro servicio opera correctamente con ellos, ¿por qué no automatizar los tests de integración usando el mismo sistema y así poder ejecutar los tests tanto en nuestra máquina como en nuestro sistema de CI?

TestContainers

TestContainers es una librería Java diseñada para controlar las instancias de servicios dockerizados, enfocada a ser integrada con JUnit/Spock/... para nuestros tests de integración.

Como ejemplo de uso, la misma PostgreSQL creada con la API de TestContainers quedaría así:

PostgreSQLContainer messageStoreDbContainer =
      new PostgreSQLContainer<>("postgres:13.0-alpine")
        .withDatabaseName("message-store-db")
        .withUsername("demo")
        .withPassword("p4ssw0rd")
        .withInitScript("/.init/message-store-db/init.sql");
messageStoreDbContainer.setPortBindings(Arrays.asList("5432:5432"));

// podemos arrancar el servicio con
messageStoreDbContainer.start();

// y pararlo con
messageStoreDbContainer.stop();
Enter fullscreen mode Exit fullscreen mode

Otro componente que nos ofrece, para permitirnos simular los tests con exactamente los mismos servicios que levantaríamos con el docker-compose.yml, es DockerComposeContainer

Un ejemplo de uso:

WaitStrategy postgresWaitStrategy = new WaitAllStrategy(WITH_INDIVIDUAL_TIMEOUTS_ONLY)
  .withStrategy(Wait.forListeningPort())
  .withStrategy(Wait.forLogMessage(".*database system is ready to accept connections.*", 1));

DockerComposeContainer dockerComposeContainer =
  new DockerComposeContainer(new File("docker-compose.yml"))
    .withLocalCompose(true)
    .waitingFor("message-store-db", postgresWaitStrategy);

dockerComposeContainer.start();
Enter fullscreen mode Exit fullscreen mode

Veréis que aquí se usa una funcionaliad opcional de la API de DockerComposeContainer para poder definir cómo debe esperarse el componente para decir que está ready hasta que el servicio "message-store-db" del "docker-compose.yml" esté ready.

Por defecto, se esperaría 60 segundos a que el servicio tuviera el puerto expuesto y listo para aceptar conexiones.

Ahora le estamos indicando que a parte de esperar al puerto, se espere también a que PostgreSQL, en su proceso de inicialización, haya sacado esa traza de log indicando que está completamente ready

Para el caso propuesto, dado que los tests deben validar la integración con los servicios definidos en el docker-composer, usaré DockerComposeContainer.

Y en realidad, para la mayoría de casos es el único que vamos a necesitar.

Así que al lío :)

¿Cómo lo integramos en nuestros tests?

Nos tenemos que preguntar: ¿Los servicios de infraestructura externa deben cambiar para distintos escenarios de test, independientemente del contexto de Spring?

O por lo contrario, ¿Los servicios de infraestructura externa deben tener el mismo ciclo de vida que el contexto de Spring?

Para ejemplificar las pruebas de integración de TestContainers en JUnit y SpringBoot, encontraréis este test sencillo en la rama master del proyecto demo.

@SpringBootTest
@Sql("/fixtures/message-store-db/clean.sql")
class ApplicationTest {

  @Autowired
  private RabbitTemplate rabbitTemplate;

  @Autowired
  private MessageMapper messageMapper;

  @Value("${mq-server.routing.exchange}")
  private String demoExchange;

  @Value("${mq-server.routing.message.queue.dispatched}")
  private String messageDispatchedQueue;

  @Test
  void shouldSaveEmittedMessage() {
    String givenText = "hello world";
    rabbitTemplate.convertAndSend(demoExchange, messageDispatchedQueue, givenText);

    await().atMost(Duration.ONE_SECOND)
          .alias(format("message [%s] has been saved", givenText))
          .until(() -> messageMapper.selectOneByText(givenText).isPresent());
  }

}
Enter fullscreen mode Exit fullscreen mode

Nota: para los que no la conozcáis, await es un operador de awaitility, muy útil para validación de resultados en procesos asíncronos.

Ciclo de vida controlado por JUnit

Empecemos con la chicha :)

código de referencia de este punto

Si miramos la documentación para integrar TestContainers con JUnit, la integración básica es muy sencilla: mediante anotaciones de la propia librería, se habilita la gestión de arranque y parada de los servicios dockerizados, y éstos pueden exponer los puertos que ha podido coger por estar libres.

⚠️ Pero ojo, nosotros necesitamos ciertas propiedades en el contexto de Spring, o dicho de otra manera, ¿cómo asignaremos los puertos designados a nuestras conexiones de base de datos y MQ?

En este caso, usaremos la capacidad de Spring de recoger propiedades de System, pudiendo configurar nuestros tests así:

@SpringBootTest
@Testcontainers
abstract class AbstractIntegrationTest {

  private static final String MESSAGE_STORE_DB_SERVICE = "message-store-db";
  private static final Integer MESSAGE_STORE_DB_PORT = 5432;
  private static final String SPRING_MESSAGE_STORE_DB_PORT = "message-store-db.port";

  private static final String MQ_SERVER_SERVICE = "mq-server";
  private static final Integer MQ_SERVER_PORT = 5672;
  private static final String SPRING_MQ_SERVER_PORT = "mq-server.port";


  @Container
  private static DockerComposeContainer dockerServices =
        new DockerComposeContainer<>(new File("docker-compose.yml"))
              .withLocalCompose(true)
              .withExposedService(MESSAGE_STORE_DB_SERVICE, MESSAGE_STORE_DB_PORT)
              .withExposedService(MQ_SERVER_SERVICE, MQ_SERVER_PORT);

  @BeforeAll
  public static void beforeAll() {
    setProperty(SPRING_MESSAGE_STORE_DB_PORT, valueOf(dockerServices.getServicePort(MESSAGE_STORE_DB_SERVICE, MESSAGE_STORE_DB_PORT)));
    setProperty(SPRING_MQ_SERVER_PORT, valueOf(dockerServices.getServicePort(MQ_SERVER_SERVICE, MQ_SERVER_PORT)));
  }
}
Enter fullscreen mode Exit fullscreen mode

En este caso, sólo que nuestro ApplicationTest extienda esta abstracción, al ejecutarse, dispondrá de todo lo necesario para funcionar porque:

  • Antes de la ejecución de los tests, TestContainers mediante los eventos de JUnit habrá levantado el DockerComposeContainer.
  • Después, JUnit procesará el @BeforeAll, donde hemos seteado las propiedades necesarias para el contexto de Spring, como message-store-db.port, con el valor que dispondrá cada servicio ya esperando conexiones.

Y el post terminaría aquí, si no fuera porque:
¿Funcionaría este approach si AbstractIntegrationTest fuera extendida por más de una implementación?
Correcto, en lugar de usar el ejemplo de TestContainers lo he puesto en una abstracción, porque como deduciréis, la respuesta es NO.

Si ejecutáramos sólo 1 clase de tests extendiendo AbstractIntegrationTest, ya veríamos una cosa que nos debería chirriar en los logs:

docker-compose-down

El test pasa, y al terminar, TestContainers intercepta el final de ejecución de los tests de la clase mediante JUnit y detiene el contenedor, antes de que se cierre el contexto de Spring.

¿Por qué y por qué puede ser un problema?

Eso pasa porque Spring, va a mantener el mismo ApplicationContext para la ejecución de todos los tests de todas las clases que no estén marcadas con @DirtiesContext, para evitar el coste de levantar el contexto para cada clase anotada con @SpringBootTest.

Y eso puede ser un problema si nos interesa hacer grupos de tests (por ejemplo, una clase de test para cada @RestController si estamos desarrollando APIs), ya que

  • los servicios dockerizados, al volverse a levantar, van a tener otro puerto libre designado para conectarnos a ellos.
  • aunque se ejecute el @BeforeAll asignando nuevos puertos a System, el contexto de Spring no los va a usar dado que no se va a refrescar.

Y como seguramente no nos va a interesar que los servicios dockerizados se apaguen y levanten de nuevo para cada conjunto de tests, por el coste en tiempo, esto nos lleva a la gestión manual de los contenedores, ya que There is no special support for this use case provided by the Testcontainers extension tal como indica la sección Singleton containers de la documentación.

Pero no preocuparse, trabajamos con Spring por lo que los singletons no nos dan miedo, ¿no? ;)

TestContainers Singleton gestionados manualmente, con ciclo de vida controlado por JUnit

código de referencia de este punto

JUnit5 tiene una característica que nos permite anidar en una única clase, mediante @Nested, conjuntos de clases (test cases) que a su vez definen los tests a ejecutar.

Así que podemos:

  • Tener una clase única, anotada con @SpringBootTest para levantar el contexto de Spring, en la que el ciclo de de vida de JUnit (@BeforeAll -> ejecución de los tests de cada test case -> @AfterAll) va a ser el mismo que el de Spring.
  • Definir test cases independientes con su grupo de @Test a ejecutar.
  • Anidar a la clase única estos test cases con @Nested

Algo tal que así:

public class IntegrationTest {

  private static final DockerizedInfrastructure DOCKERIZED_INFRASTRUCTURE = new DockerizedInfrastructure();

  @BeforeAll
  static void dockerComposeUp() {
    DOCKERIZED_INFRASTRUCTURE.start();
  }

  @AfterAll
  static void dockerComposeDown() {
    DOCKERIZED_INFRASTRUCTURE.stop();
  }

  @Nested
  class Application extends ApplicationTestCase {
  }
}
Enter fullscreen mode Exit fullscreen mode

Sólo necesitaremos que en este caso, ApplicationTest de la rama master, sea abstracta (y para este ejemplo, renombrada a ApplicationTestCase que define mejor su intención).

A parte, para hacer legible el ejemplo y evitar una mezcla de responsabilidades entre la coordinación de los tests que hará IntegrationTest y la coordinación de los servicios dockerizados, aquí sólo queda el singleton DOCKERIZED_INFRASTRUCTURE para poderlo gestionar al inicio y final de la ejecución de test cases, pero su implementación se ha separado a:

DockerizedInfrastructure.java

public class DockerizedInfrastructure {

  private static final String MESSAGE_STORE_DB_SERVICE = "message-store-db";
  private static final Integer MESSAGE_STORE_DB_PORT = 5432;
  private static final String SPRING_MESSAGE_STORE_DB_PORT = "message-store-db.port";

  private static final String MQ_SERVER_SERVICE = "mq-server";
  private static final Integer MQ_SERVER_PORT = 5672;
  private static final String SPRING_MQ_SERVER_PORT = "mq-server.port";

  private final DockerComposeContainer dockerServices;

  public DockerizedInfrastructure() {
    // Inicializamos los servicios de docker-compose
    dockerServices = new DockerComposeContainer<>(new File("docker-compose.yml"))
          .withLocalCompose(true)
          .withExposedService(MESSAGE_STORE_DB_SERVICE, MESSAGE_STORE_DB_PORT)
          .withExposedService(MQ_SERVER_SERVICE, MQ_SERVER_PORT);
  }

  public void start() {
    // Arrancamos los servicios dockerizados
    dockerServices.start();

    // Seteamos System properties para propiedades del contexto de Spring
    setProperty(SPRING_MESSAGE_STORE_DB_PORT, valueOf(dockerServices.getServicePort(MESSAGE_STORE_DB_SERVICE, MESSAGE_STORE_DB_PORT)));
    setProperty(SPRING_MQ_SERVER_PORT, valueOf(dockerServices.getServicePort(MQ_SERVER_SERVICE, MQ_SERVER_PORT)));
  }

  public void stop() {
    // Apagamos los servicios dockerizados
    dockerServices.stop();
  }
}
Enter fullscreen mode Exit fullscreen mode

Y llegados a este punto, que ahora sí funcionará tal como esperamos a medida que el proyecto vaya creciendo y necesitemos agrupar los tests en distintos test cases, otra vez daría el post por cerrado, si no fuera que ¿realmente es necesario que gestionemos manualmente un singleton y su ciclo de vida con JUnit para coordinarlo con el ciclo de vida del ApplicationContext de Spring?

Y aunque no suponga un problema hacerlo, otra vez la respuesta es NO, dado que Spring, como ya comenté, va a mantener el ApplicationContext durante la ejecución de los tests y dispone de una serie de características que nos permitirán jugar con la gestión de los servicios dockerizados, las propiedades para el ApplicationContext (en este caso los puertos de los servicios),...

Además, con esta gestión manual, nos podríamos encontrar con los siguientes inconvenientes:

  1. Añadir un nuevo test case requiere modificación en la base IntegrationTest.

  2. Ya sea por reagrupación de test cases o para configuraciones distintas por grupos de test cases, no podríamos tener 2 IntegrationTest distintos anidando distintos test cases, sin más. Por ejemplo:

Supongamos que replicamos el ApplicationTestCase varias veces y los anidamos en también una réplica de IntegrationTest:

breaking-in-junit

Al ser réplicas, en los IntegrationTestN, @BeforeAll y @AfterAll reinician los contenedores pero de nuevo, nos encontramos que el ApplicationContext para los ApplicationTestCaseN... es el mismo y por tanto, sólo iniciará la conexión a las infraestructuras la primera vez que además, JUnit no va a ejecutar en un mismo orden, por lo que el segundo bloque de tests ejecutado va a fallar:

tests-failed

Para resolver este problema, tendríamos que limpiar el contexto de Spring para cada ApplicationTestN, de manera que cada test case creara un nuevo ApplicationContext con configuración fresca de los puertos activados, marcándolos todos ellos con dirties context:

@SpringBootTest
@DirtiesContext
Enter fullscreen mode Exit fullscreen mode

Esto de nuevo, aunque lógicamente es un corner case forzado para validar posibles flaquezas de la solución, nos lleva a pensar que realmente, lo ideal sería que la gestión del ciclo de vida de los servicios dockerizados fueran gestionados directamente con Spring.

Así, que como guindilla del pastel, continuamos :)

TestContainers sólo gestionados por Spring

código de referencia de este punto

Llegados a este punto, sabemos que:

  • Los test de integración marcados con @SpringBootTest comparten el ApplicationContext a no ser que sean marcados con @DirtiesContext
  • DockerizedInfrastructure debería ser tratado como un singleton (@Component) dentro del contexto de Spring para que en todo momento, el ApplicationContext pueda accederlo o regenerarlo en dirties.
  • DockerizedInfrastructure debe reconfigurar las propiedades de conexión de la infraestructura.

⚠️ pero, si hacemos un @Component, que Spring inicializa, ya no podemos setear las propiedades en System, dado que la lectura de beans (y propiedades) es anterior a la instanciación.

💡 pero por otro lado es Spring, y siempre tiene una solución a cómo gestionar estos casos: podemos usar el ConfigurableApplicationContext para refrescar el contexto con las propiedades iniciales tal como nos va a interesar.

En definitiva, para que sea gestionado completamente por Spring, podríamos modificar DockerizedInfrastructure.java así:

@Component
public class DockerizedInfrastructure {

  private static final String MESSAGE_STORE_DB_SERVICE = "message-store-db";
  private static final Integer MESSAGE_STORE_DB_PORT = 5432;
  private static final String SPRING_MESSAGE_STORE_DB_PORT = "message-store-db.port";

  private static final String MQ_SERVER_SERVICE = "mq-server";
  private static final Integer MQ_SERVER_PORT = 5672;
  private static final String SPRING_MQ_SERVER_PORT = "mq-server.port";

  private final DockerComposeContainer dockerServices;

  DockerizedInfrastructure(ConfigurableApplicationContext configurableApplicationContext) {
    // Inicializamos los servicios de docker-compose
    dockerServices = new DockerComposeContainer<>(new File("docker-compose.yml"))
          .withLocalCompose(true)
          .withExposedService(MESSAGE_STORE_DB_SERVICE, MESSAGE_STORE_DB_PORT)
          .withExposedService(MQ_SERVER_SERVICE, MQ_SERVER_PORT);

    // Arrancamos los servicios dockerizados
    dockerServices.start();

    // Refrescamos el contexto de Spring con los puertos designados
    TestPropertyValues.of(
          format("%s=%s",
                SPRING_MESSAGE_STORE_DB_PORT,
                dockerServices.getServicePort(MESSAGE_STORE_DB_SERVICE, MESSAGE_STORE_DB_PORT)),
          format("%s=%s",
                SPRING_MQ_SERVER_PORT,
                dockerServices.getServicePort(MQ_SERVER_SERVICE, MQ_SERVER_PORT))
    ).applyTo(configurableApplicationContext.getEnvironment());
  }

  @PreDestroy
  void preDestroy() {
    // Apagamos los servicios dockerizados
    dockerServices.stop();
  }
}
Enter fullscreen mode Exit fullscreen mode

Fijaros que aquí, DockerizedInfrastructure no tiene ni el constructor ni ningún método público, dado que ahora no es necesario que ninguna clase de test acceda a ella para manipular su estado. Sí lo es la declaración de la clase, luego veremos por qué :)

Con el @PreDestroy nos aseguramos de apagar los servicios dockerizados cuando el ApplicationContext emita la señal de que está siendo destruido, que será o cuando hayan terminado los tests, o cuando un test esté marcado con @DirtiesContext, antes de la recreación del contexto.

Ésta sería una manera muy elegante de integrar esta DockerizedInfrastructure dado que no haría falta modificar nada en nuestra configuración de los tests, pero ¿qué pasaría si algún componente del servicio necesitara acceder a la base de datos durante la creación del contexto de Spring?

Para forzar el caso, os pongo este ejemplo:

@Component
public class InventBreaker {

  private final MessageMapper messageMapper;

  public InventBreaker(MessageMapper messageMapper) {
    this.messageMapper = messageMapper;
  }

  @PostConstruct
  void tryToBreakTheTestContext() {
    messageMapper.selectOneByText("applicationContext lanzará excepción si no hay conectividad a la BBDD");
  }
}
Enter fullscreen mode Exit fullscreen mode

Supongamos que InventBreaker, por lo que sea, necesita inicializarse con un valor de la BBDD, y si no hacemos nada, va a pasar esto:

breaking-the-invent

Spring, la única instrucción a la que va a hacer caso para que InventBreaker se espere a que DockerizedInfrastructure haya hecho la magia es con @DependsOn("dockerizedInfrastructure"), pero wait! Si InventBreaker forma parte del código del servicio en lugar del código de test, no podemos hacer esto.

💡 pero de nuevo, Spring ya contempla casi todos los corner cases que nos podamos imaginar, y nos va a permitir sincronizar la creación del contexto requerido para test con el contexto requerido para la aplicación modificando sólo una línea en la declaración de @SpringBootTest, dejando nuestra abstracción de tests de integración así:

@SpringBootTest(classes = {DockerizedInfrastructure.class, Application.class})
Enter fullscreen mode Exit fullscreen mode

Y listo! Ahora todo se inicializará y se apagará según el orden esperado.

Además, podríamos abstraer esta definición para ser usada en cualquier otra clase de test así:

AbstractIntegrationTest.java

@SpringBootTest(classes = {DockerizedInfrastructure.class, Application.class})
abstract class AbstractIntegrationTest {
}
Enter fullscreen mode Exit fullscreen mode

Y todas las clases de test con un extends AbstractIntegrationTest dispondrán de los servicios dockerizados para operar con ellos, sin necesidad de que ninguno esté marcado con @DirtiesContext, aunque marcándolo también funcionaría (pagando el coste de reiniciar el contexto, junto con los servicios dockerizados).

all-green

🚀

Conclusión

TestContainers es una herramienta muy potente para evitar que perdamos tiempo simulando / mockeando lo que debería hacer nuestra capa de integración con infraestructura a la hora de desarrollar los tests y es bueno aprovecharse de ello!

Aún así, no siempre hay una manera de implementar las cosas y es mejor adaptarse a la que mejor nos convenga en cada caso con conocimiento de las herramientas que estamos usando, y cuando usamos un framework como Spring, éste dispone para nosotros muchas facilidades para agilizar los desarrollos, tanto de la aplicación, como en este caso de los tests.

Y llegados a este punto, no queda mucho más que decir por mi parte, espero que os haya gustado este viaje a las entrañas de Spring en el contexto de testing, así que si es el caso o si gestionáis el ciclo de vida de los contenedores de test de alguna otra forma y queréis compartirla dejad un comentario, que me gustará leeros.

Y ahora sí, fin :)

Discussion (0)

pic
Editor guide