LogoCésar Alberca

Reemplaza tus dependencias automágicamente en los tests

2026-03-04T15:00:00.000Z

¿No sería genial cambiar automágicamente todas tus llamadas a la API por mocks sin necesidad de mockear URLs? Cuando tenemos pruebas, es tremendamente útil disponer de esta funcionalidad, que puede aprovecharse para pruebas de integración o pruebas E2E.

Para implementar este requisito, podemos extender lo que construimos en el número anterior del boletín: el contenedor de inyección de dependencias.

#El Contenedor de Inyección de Dependencias

Con un contenedor resolvemos el problema de crear instancias de nuestras clases gestionando la instanciación en un único punto.

Si este archivo creciera demasiado, podríamos crear un inyector jerárquico basado en módulos, donde cada módulo de la aplicación gestionaría la creación de las clases relacionadas con ese módulo. Angular hace esto, por ejemploSe abre en una nueva pestaña.

Este es un "problema" que vale la pena tener, ya que ahora podemos hacer cosas interesantes, y me refiero a cosas muy interesantes como crear diferentes contenedores de forma dinámica dependiendo de variables de entorno. Veamos nuestra implementación actual del AppContainer:

export class AppContainer implements Container { private constructor() { this.registerArtifacts() this.registerRepositories() this.registerUseCases() } private registerArtifacts() { const eventEmitter = new EventEmitter() const emptyMiddleware = new EmptyMiddleware() const loggerMiddleware = new LoggerMiddleware() const errorMiddleware = new ErrorMiddleware(eventEmitter) const successMiddleware = new SuccessMiddleware(eventEmitter) const confirmMiddleware = new ConfirmMiddleware(eventEmitter) const middlewares = [ confirmMiddleware, errorMiddleware, loggerMiddleware, successMiddleware ] const useCaseService = new UseCaseService(middlewares, this) this.register(eventEmitter) this.register(emptyMiddleware) this.register(loggerMiddleware) this.register(errorMiddleware) this.register(confirmMiddleware) this.register(successMiddleware) this.register(useCaseService) } private registerRepositories() { const httpClient = this.get(HttpClient) const dateTransformer = this.get(DateTransformer) const destinationApiRepository = new DestinationApiRepository(httpClient, dateTransformer) this.register(destinationApiRepository) } private registerUseCases() { const destinationOrderer = new DestinationOrderer() const destinationApiRepository = this.get(DestinationApiRepository) const getDestinationsQry = new GetDestinationsQry(destinationApiRepository, destinationOrderer) this.register(destinationOrderer) this.register(getDestinationsQry) } }

Si nos centramos en la tarea en cuestión, que es reemplazar las dependencias relacionadas con las llamadas a la API, deberíamos centrarnos en los Repositories.

El patrón repository es un patrón bastante interesante, ya que nos permite abstraer cómo interactuamos con los datos. He escrito más sobre él en este post donde detallo cómo he estado usando el patrón repository durante los últimos 5 años.

Queremos cargar dependencias diferentes según el contexto, por ejemplo, en Integración Continua (CI) no queremos realizar peticiones a la API. Habitualmente esto se ha resuelto añadiendo una pieza entre el cliente y el servidor como MSWSe abre en una nueva pestaña, que crea un Service Worker que intercepta las llamadas a la API.

Sin embargo, podemos simplificar y mantener el control sobre el código usando un contenedor de pruebas.

export class TestAppContainer extends AppContainer { private static instance: TestAppContainer private constructor() { super() } static override getInstance(): TestAppContainer { TestAppContainer.instance ??= new TestAppContainer() return TestAppContainer.instance } protected override registerRepositories(): void { const destinationInMemoryRepository = new DestinationInMemoryRepository() this.register(destinationInMemoryRepository) } }

Ahora necesitamos una forma de sobreescribir las dependencias del AppContainer. Podemos introducir una variable global para almacenar la instancia dentro de la definición del AppContainer:

declare global { var appContainer: AppContainer } export const container = AppContainer.getInstance()

Crear variables globales como esta debe reservarse para casos muy específicos. En mi opinión, usar una variable global para el Contenedor de Dependencias está justificado. ¡Y no, no podemos usar let en este caso!

Ahora, si volvemos al TestAppContainer, podemos introducir un método init:

export class TestAppContainer extends AppContainer { ... static init(): void { globalThis.appContainer = TestAppContainer.getInstance() } }

#Definiendo Variables de Entorno

Este es uno de los casos donde las variables de entorno resultan muy útiles. Para ello, en la mayoría de los Frameworks Frontend actuales basta con crear un archivo .env en el directorio raíz del proyecto. Por debajo, lo más probable es que usen dotenvSe abre en una nueva pestaña o algo similar:

USE_TEST_CONTAINER="true"

Las variables de entorno suelen escribirse en MAYÚSCULAS. Es una buena práctica crear un archivo .env.example que se commitee con ejemplos de variables:

CI="true/false"
E2E="true/false"
NEXT_PUBLIC_URL=""
NODE_ENV="development/production"
NEXT_PUBLIC_API_URL="http://localhost:8080/"
USE_TEST_CONTAINER="true/false"

Como el archivo .env no debería commitearse, documento las variables en un archivo Markdown. Últimamente también he estado usando @t3-oss/envSe abre en una nueva pestaña para añadir tipado estático, así obtengo autocompletado y puedo renombrarlas usando mi IDE de elección: WebStorm.

import { createEnv } from '@t3-oss/env-nextjs' import { z } from 'zod' const booleanSchema = z .string() .optional() .transform((value) => value === 'true') .pipe(z.boolean()) export const environment = createEnv({ server: { CI: booleanSchema, E2E: booleanSchema, NODE_ENV: z.string(), USE_TEST_CONTAINER: booleanSchema, }, client: { NEXT_PUBLIC_URL: z.string(), NEXT_PUBLIC_API_URL: z.string(), NEXT_PUBLIC_USE_TEST_CONTAINER: booleanSchema, }, })

¿Quieres saber por qué elijo WebStorm sobre otros IDEs y editores de código? Lee este blogpost para aprender cómo uso WebStorm para maximizar mi productividad.

También almaceno mi entorno usando 1PasswordSe abre en una nueva pestaña, y su nueva funcionalidad Developer EnvironmentsSe abre en una nueva pestaña, en el área de Desarrollo. Además, también firmo mis commitsSe abre en una nueva pestaña usando 1Password.

Ahora, ¡carguemos el contenedor de pruebas dinámicamente!

#Cargando el Contenedor de Pruebas

Para cargar el Contenedor de Pruebas simplemente llamamos a TestContainer.init() envolviendo la llamada en un condicional que comprueba si USE_TEST_CONTAINER está configurado como true. Como estoy usando NextJSSe abre en una nueva pestaña, puedo gestionarlo creando un instrumentation.ts e instrumentation-client.ts en el directorio raíz:

import { TestAppContainer } from '@/core/di/test-app.container' import { environment } from '@/core/environment/environment' export async function register() { if (environment.USE_TEST_CONTAINER) { TestAppContainer.init() } }

Así, cuando NextJS arranque, comprobará si USE_TEST_CONTAINER está configurado como true y sobreescribirá las implementaciones del repositorio.

#Conclusión

Como hemos visto en esta saga arquitectónica de números del boletín, hemos dado pequeños pasos hacia una arquitectura que quizás no tenía sentido al principio, pero que podemos ver cómo cobra más sentido una vez que las piezas empiezan a encajar.

P.D: Acabo de publicar la mejor y peor landing page jamás creada para mi libro Software Cafrers. Échale un vistazo aquí: https://www.softwarecafrers.com/Se abre en una nueva pestaña.