LogoCésar Alberca

Swap Dependencies Automagically in Tests

2026-03-04T15:00:00.000Z

Wouldn't it be nice to automagically switch all your API calls to mocks without needing to mock URLs? When we have tests, it's immensely useful to have this feature available, which can be leveraged for integration tests or E2E tests.

To implement this requirement, we can extend what we've built in the previous issue of the newsletter: the dependency injection container.

#The Dependency Injection Container

With a container we solve the problem of creating instances of our classes by handling the instantiation in a single point.

If this file were to grow too big, we could create a hierarchical injector based on modules, where each module of the application would handle the creation of classes related to that module. Angular does thisOpen in a new tab, for example.

This is a "problem" worth having, since now, we can do funky stuff, and I mean funky stuff like creating different containers dynamically depending on environment variables. Let's check out our current implementation of the 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) } }

If we focus on the task at hand, which is to replace the dependencies related to making calls to the API, we would need to focus on the Repositories.

The repository pattern it's quite an interesting pattern, since it allows us to abstract how we interact with data. I've written more about it here in this post detailing how I've been using the repository pattern for the last 5 years.

We want different dependencies to load depending on the context, for example, in Continuous Integration (CI) we don't want to make API requests. Usually this has been solved by adding a piece between the client and the server like MSWOpen in a new tab, which creates a Service Worker that intercepts API calls.

However, we can simplify and retain control over the code using a test container.

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) } }

Now, we need a way of overriding the AppContainer dependencies. We can introduce a global variable to hold the instance inside the definition of the AppContainer:

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

Creating global variables like this should be reserved for very specific cases. In my opinion, using a global variable for the Dependency Container is warranted. And no, we can't use let in this case!

Now, if we head back to the TestAppContainer, we can introduce an init method:

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

#Defining Environment Variables

This is one of the cases where environment variables come in handy. To do so, in most Frontend Frameworks nowadays you can just create an .env file in the root directory of your project. Underneath they most likely use dotenvOpen in a new tab or something similar:

USE_TEST_CONTAINER="true"

Environment variables are usually written in UPPERCASE. It's good practice to create an .env.example that is committed with variable examples:

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"

Since the .env file should not be committed, I document the variables in a Markdown file. I've also been using lately @t3-oss/envOpen in a new tab to add static types, so I get autocomplete and can rename them using my IDE of choice: 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, }, })

You want to find out why I choose WebStorm over countless other IDEs and Code Editors? Read this blogpost to learn how I use WebStorm to maximize my productivity.

I also store my environment using 1PasswordOpen in a new tab, and its new feature Developer EnvironmentsOpen in a new tab, under the Development area. Oh, I also sign my commitsOpen in a new tab using 1Password.

Now, let's load the test container dynamically!

#Loading the Test Container

To load the Test Container we just call TestContainer.init() wrapping the call in an if statement to check if USE_TEST_CONTAINER is set to true. Since I'm using NextJSOpen in a new tab, I can handle that by creating an instrumentation.ts and instrumentation-client.ts in the root directory:

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() } }

Now, when NextJS loads, it will check if USE_TEST_CONTAINER is set to true and override the repository implementations.

#Conclusion

As we've seen in this architectural saga of newsletter issues, we have been making little steps towards an architecture that might have not made sense in the beginning but that we can see how it makes more sense once the pieces start fitting together.

P.S: I just released the best worst landing ever made for my book Software Cafrers. Check it out here: https://www.softwarecafrers.com/Open in a new tab.