Clean Architecture applied to a TS project

Clean Architecture applied to a TS project

Software engineering is an ever-evolving art, and although more than half a century has passed since its early steps, we continue to learn and refine our techniques. In this post, we will explore the fundamental principles of Clean Architecture, adapted for TypeScript, based on the teachings of the iconic book Clean Code by Robert C. Martin.

This is not a set of strict rules, nor a style guide. Instead, it is a guide to help you write more readable, reusable, and refactorable code in TypeScript. Not all the principles we will explore here must be followed to the letter, and some may spark debate. They are guidelines, formulated over years of collective experience, that serve as a solid foundation for evaluating the quality of the code you and your team produce.

The journey toward a mature software architecture, as old as architecture itself, is still in its early stages. Hard and fast rules may come someday, but for now, these guidelines act as a compass to guide our way.

One last point to keep in mind: mastering these principles will not immediately make you a better developer. Practice and experience are essential, and making mistakes is part of the process. Remember that all code starts as a first draft, something malleable that takes shape over time. The key is to refine it with the help of your teammates. Do not punish yourself for a first imperfect attempt; instead, punish the code when necessary.

Clean Architecture in the Frontend?

In the world of frontend development, we often hear that frontends exist solely to "paint" API responses. However, this limited perspective does not take into account the growing complexity of modern applications. The frontend is not just a layer to consume data; it has become a vital element that directly affects the scalability, maintainability, and evolution of an application over time. This is where Clean Architecture comes in, an approach that, far from being exclusive to the backend, can transform the way we structure our frontend applications.

Why apply Clean Architecture in the frontend?

Popular frontend frameworks like React, Angular, and Vue often integrate everything into their own ecosystem. This can cause the code to become monolithic and difficult to maintain as the project grows. The fundamental premise of Clean Architecture is to separate concerns by creating independent layers that allow for more agile development and less dependency on the details of the framework or the UI. Although some argue that frontends simply manage API interactions, a clean and decoupled approach helps solve problems that go beyond simple data integration. Clean Architecture in the frontend promotes:

  • Framework Independence: Decouple business logic from the details of the framework used. This way, if the team decides to change frameworks in the future, the critical parts of the application can remain intact.
  • Scalability and Maintainability: By structuring the code into clearly defined layers (such as entities, services, and components), it makes it easier to scale as the project grows and prevents future modifications from becoming a nightmare.
  • Testability: By separating the UI and API logic, it makes it easier to create unit and integration tests, improving code quality and the ability to detect errors.
  • Flexibility: It allows integrating different services and solutions without coupling to a single approach or technology solution, opening the door to future optimizations or changes in project requirements without rewriting it from scratch.

This article does not aim to say that we should abandon frameworks or build frontend applications from scratch, but rather to offer a clear and flexible structure that enables developers to make informed decisions about where to use frameworks and how to manage code evolution without compromising quality. Even in an environment where frontends "only consume APIs," adopting the principles of Clean Architecture in the frontend can result in a more efficient development experience and less prone to issues as the project grows.

A summary of what Clean Architecture is

Clean Architecture applied to frontend development aims to create more scalable, maintainable, and testable applications. Frontend developers often focus solely on consuming APIs, but this view is too simplistic for complex projects. Clean Architecture in the frontend allows for decoupling different parts of the application and offering a clear structure that facilitates its evolution over time.

How to achieve decoupling in frontend applications?

While the innovative ideas of modern frameworks (like React, Vue, or Angular) are fundamental, they all revolve around a common concept: web pages. HTML, CSS, images, scripts, and other external resources are the foundation of any frontend application. Therefore, when applying Clean Architecture, we focus on abstracting and separating responsibilities within the application, allowing the UI, business logic, and interaction with APIs to be handled independently.

There are four main abstractions in a clean frontend architecture:

1. Entities

Entities represent the relevant data used to render or update the application. Their main function is to adapt data (like JSON responses from APIs) and transform them into a suitable format for the application. This approach centralizes data management and prevents the repetition of logic (such as formatting prices) across multiple components.

Example of an entity:

export const Product = ({
    id = -1,
    title = 'No title',
    description = 'No description',
    rating = -1,
    stock = -1,
    brand = 'No Brand defined',
    category = 'No category',
    image = 'No url for image',
    ...rest
}) => ({
    id,
    title,
    description,
    rating,
    stock,
    brand,
    category,
    price: { ...ProductPrice(rest) }
})

export const ProductPrice = ({
    price = -1,
    discountPercentage = 'No discount percentage'
}) => ({
    raw: price,
    discount: discountPercentage,
    formatted: price.toLocaleString('pt-br', { style: 'currency', currency: 'BRL' })
})

2. Services

Services manage external communication, such as making HTTP requests to get or send data (GET, POST, PUT, DELETE). These services should be stateless and return promises of entities. Additionally, they centralize API calls, making it easier to manage errors and changes in request behavior.

Example of a service:

import http from './http'
import { User } from '/entities/user'

export const getPersonList = async () => {
  const [users, photos, posts] = await Promise.all([
    http.get('/users'),
    http.get('/photos'),
    http.get('/posts')
  ])
  
  return users.map((user) => {
    const photo = photos.find((photo) => photo.id === user.id)
    const post = posts.find((post) => post.id === user.id)
    return User({ ...user, photo, post })
  })
}

3. Store

The Store is responsible for managing the application state and client-side data persistence. It is a centralized place to store and manage the state, facilitating communication between components. Additionally, stores can use services to make data requests and store the results.

4. Components

Components are responsible for the presentation of the user interface, user interaction, and view updates. Although each framework has its own components, the principle of Clean Architecture is to keep these components decoupled from business logic and data management.

Components should be as simple as possible, focusing on rendering and managing user interaction without worrying about business logic or API calls.

Folder Structure in a Vue Project with Clean Architecture

The folder structure is a key part of implementing Clean Architecture. It allows organizing the code in a clear and modular way. In a Vue project, a recommended structure could be as follows:

src/
├── pages/
   └── home/
       ├── services/        # Servicios específicos para la página (por ejemplo, solicitudes HTTP)
       ├── entities/        # Entidades (modelos de datos)
       ├── use-cases/       # Casos de uso o lógica de negocio específica
       └── components/      # Componentes de la interfaz de usuario
└── shared/
    ├── utils/              # Utilidades compartidas
    ├── services/           # Servicios reutilizables
    ├── entities/           # Entidades comunes
    ├── use-cases/          # Casos de uso comunes
    └── components/         # Componentes compartidos

How We "Fit" All These Pieces Together: Dependency Injection

Dependency Injection (DI) is a design pattern that helps decouple the components of an application, allowing dependencies (such as services or classes) to be provided to objects rather than being created directly by them. Instead of an object creating or managing its dependencies internally, they are "injected" from the outside, facilitating reuse, scalability, and unit testing.

In simpler terms, dependency injection allows the components of the application to not need to know how to create their dependencies, just how to use them. This is achieved through a container that manages the creation and provision of the required dependencies throughout the application.

How Does Dependency Injection Fit into Clean Architecture for the Frontend?

In Clean Architecture, the components and layers of the application are decoupled, meaning each layer only interacts with the layer directly above or below it. Dependencies between these layers must be managed efficiently to avoid tight coupling, which can make scalability and long-term code maintenance difficult.

Dependency injection fits perfectly into this approach, as it allows dependencies to be managed centrally, making it easier to distribute responsibilities and ensuring that each component receives only what it needs without worrying about how to obtain it.

Using a Dependency Container: Inversify Library

Inversify is a dependency injection (DI) library for JavaScript and TypeScript applications. It is a tool that simplifies managing an application’s dependencies and improves code modularity, flexibility, and testability. In large and complex applications, where different modules or components depend on each other, using a dependency container like Inversify can make the code much more maintainable and scalable.

What is a Dependency Container?

A dependency container is a design pattern that helps manage the creation and injection of objects or dependencies centrally. Instead of creating instances of objects directly in each class or component that needs them, a dependency container provides the necessary instances when required. This pattern helps decouple the components of the application, improving code organization and making maintenance and testing easier.

How Does a Dependency Container Work?

In simple terms, a dependency container acts as a repository where we register classes, services, or repositories along with their respective dependencies. Then, when these dependencies are needed, the container automatically injects them into the requesting classes.

Instead of manually managing dependencies in each class or component, the dependency container takes care of it, providing the necessary instances as required. This simplifies control over the lifecycle of objects and their injection into the classes that need them.

Why Do We Need a Dependency Container?

  • Decoupling Components: The main benefit of using a dependency container is decoupling between the components of the application. Without a container, classes would have to create their own dependencies or manage them explicitly, which would tightly couple their code to those dependencies. With a container, classes only deal with using the dependencies provided to them, without needing to know how to create them.
    For example, if a class needs to access an API via a service, the service will be injected into the class rather than being created by the class itself. This makes it easier to change and evolve the code because classes do not need to directly modify how dependencies are obtained.
  • Ease of Unit Testing: Dependency injection simplifies unit testing. Instead of creating dependencies inside the classes and objects, we can inject mocks or stubs of the dependencies during tests. This makes tests more isolated and precise, as we can control the dependencies without needing to interact with real services or databases.
  • Managing Dependency Lifecycle: A dependency container takes care of managing the lifecycle of instances, meaning it ensures an instance is created only when necessary and reused when appropriate. Additionally, the container can configure the lifecycle of objects, such as whether they should be singleton instances or created per request (transient). This allows more precise control over how resources are managed in the application.
  • Modularity and Scalability: By using a dependency container, we can build modular applications where dependencies can be managed and modified centrally. This facilitates scalability of the application, as components can be added or modified without making extensive changes to the existing code. Also, if we need to change the implementation of a dependency (such as swapping an API service for another), we can do so without affecting the rest of the application, as the container will handle injecting the new instance.
  • Reusability of Components: Thanks to decoupling and dependency injection, components and services are more reusable. Once we define a class or service, we can inject it anywhere in the application without worrying about its internal implementation. This increases the consistency and reuse of code.

How to Use Inversify in Practice?

In Inversify, the dependency container is created using the Container class. Once the container is created, we register the dependencies we want to manage. For example, we can register a service like this:

import { Container } from "inversify";
import { IUserRepository } from "./interfaces";
import { UserRepository } from "./repositories";

// Creamos el contenedor
const container = new Container();

// Registramos la dependencia en el contenedor
container.bind<IUserRepository>("IUserRepository").to(UserRepository);

// Luego, podemos inyectar esta dependencia en cualquier clase que la necesite

In this example, the container is responsible for creating an instance of UserRepository when needed and injecting it into any class that requests it.

Simplified Dependency Injection Example

In this simple example, we are going to create a small application where we have a service that needs to access a repository to retrieve information. We will use a dependency container to manage the creation and injection of the dependencies.

  1. Define the repository interface: Here we define the interface that our service will use.
// Definición de la interfaz del repositorio
interface IUserRepository {
  getUserById(id: number): string;
}
  1. Repository Implementation: We create the concrete implementation of the ´IUserRepository´ interface.
// Implementación concreta del repositorio
class UserRepository implements IUserRepository {
  getUserById(id: number): string {
    return `User with ID: ${id}`;
  }
}
  1. Service that uses the repository: Now we create a service that requires the repository dependency to function.
// Servicio que depende del repositorio
class UserService {
  private userRepository: IUserRepository;

  // El repositorio es inyectado a través del constructor
  constructor(userRepository: IUserRepository) {
    this.userRepository = userRepository;
  }

  public getUser(id: number): string {
    return this.userRepository.getUserById(id);
  }
}
  1. Configure the dependency container: We use a container to manage the creation and injection of dependencies.
import { Container } from "inversify";

// Creamos un contenedor de dependencias
const container = new Container();

// Registramos las dependencias en el contenedor
container.bind<IUserRepository>("IUserRepository").to(UserRepository);
container.bind<UserService>("UserService").to(UserService);

// Obtener una instancia de UserService con la dependencia inyectada
const userService = container.get<UserService>("UserService");
console.log(userService.getUser(1)); // Salida: "User with ID: 1"
  1. Result: Now, when running the code, we will see that the UserService service receives its UserRepository dependency through injection, without having to create it internally. The container manages all the instances of the dependencies.

Shared Folder

In the shared folder of a frontend project that follows the principles of Clean Architecture, we include those modules and services that are reusable and not strictly tied to a specific functionality or page, but can be used by different parts of the system. This helps maintain clean, modular, and scalable code. Here, we group various tools that facilitate communication with external services, error management, and the configuration of common utilities.

1. Axios Instance

The Axios instance is a centralized configuration for handling HTTP requests. By keeping it in the shared directory, we can reuse it across all pages or components that need to make requests, without having to configure Axios repeatedly in each place.

Example of Axios configuration:

import axios, { AxiosInstance, AxiosResponse, AxiosError } from 'axios';
import router from '@/router';

const axiosInstance: AxiosInstance = axios.create({
  // Configuración común, como baseURL o headers
});

axiosInstance.interceptors.response.use(
    (response: AxiosResponse) => {
        return response;
    },
    (error: AxiosError) => {
        if (error.response?.status === 401) {
            router.push({ name: 'Login' });
        }
        return Promise.reject(error);
    }
);

export default axiosInstance;

In this example, we create an Axios instance and set up a response interceptor that, if the server responds with a 401 error (unauthorized), redirects the user to the login page. This file can be imported anywhere in the application to make HTTP requests without having to reconfigure Axios each time.

2. HTTP Service

In shared, we can also have a centralized HTTP service that uses the Axios instance to make requests, handle errors, and return data in a standardized way to our backend.

This service can include additional logic, such as handling specific errors and validating responses, avoiding code duplication and centralizing the behavior of the requests.

Example of how I usually implement an HTTP service:

import axios, {
  AxiosResponse,
  AxiosRequestConfig,
  AxiosError,
} from 'axios';
import axiosInstance from './axiosInstance';
import { err, Result } from 'neverthrow';
import { HttpError } from './HttpError';
import { inject } from 'inversify-props';

type IHttpRequest = {
  url: string;
  config?: AxiosRequestConfig;
  data?: any;
}

type Parser<T, M> = {
  parseTo: (data: T) => Result<M, Error>
}

export class HttpService {
  private axiosService: typeof axiosInstance;

  constructor(@inject() axiosInstance: typeof axiosInstance) {
    this.axiosService = axiosInstance;
  }

  public async get<T, M>({ url, config }: IHttpRequest, parser: Parser<T, M>): Promise<Result<M, HttpError>> {
    try {
      const response = await this.axiosService.get<T>(url, config);
      return parser.parseTo(response.data);
    } catch (error: any) {
      return err(new HttpError(error.response?.status || 500, error.message));
    }
  }
  
  // Métodos POST, PUT, DELETE se pueden añadir de manera similar
}

This service encapsulates HTTP requests within a single module, allowing error handling and response transformation to be centralized. Additionally, it improves code reuse and consistency in interactions with the API.

3. HTTP Error Handling

It is important to have standardized error handling for HTTP errors throughout the application. Errors can be caused by client-side issues (4xx codes) or server-side issues (5xx codes), and they should be handled consistently.

Example of an HTTP error class:

import { HttpStatusCode } from './HttpStatusCode';

export class HttpError extends Error {
  public status: number;

  constructor(status: number, message: string) {
    super(message);
    this.status = status;
    this.name = HttpStatusCode[status] || 'Unknown Error';
  }

  public isClientError(): boolean {
    return this.status >= 400 && this.status <= 499;
  }

  public isServerError(): boolean {
    return this.status >= 500 && this.status <= 599;
  }

  public static fromStatus(status: number, message: string): HttpError {
    return new this(status, message);
  }
}

This class handles the HTTP status and allows differentiation between client and server errors, making error management easier in different parts of the application.

4. Unified Export

Instead of importing each of these modules separately in various parts of the application, you can unify them in a single file within the shared folder to make their importation easier.

// src/shared/http/index.ts
export * from './HttpStatusCode';
export * from './HttpError';
export * from './HttpService';
export * from './axiosInstance';

This simplifies dependency management and makes code reuse easier throughout the project. Now, when you need to use these functionalities, you will only need to import from a single file:

import { HttpService, HttpError } from '@/shared/http';

Full Implementation of a Use Case, Step by Step

Next, we will detail the flow of a use case in TypeScript applied to a course registration management system for an e-learning platform, following the principles of Clean Architecture. We will use the GetExternalRegistrationsByYearAndStudentType use case as an example, which retrieves external registrations for a specific year and student type. This example is divided into different layers according to the principles of Clean Architecture: Application, Domain, Infrastructure, and Presentation.

1. Use Case Interface (Application Layer)

The use case begins with the definition of its interface. This interface establishes the contract that any implementation of this use case must follow.

File: GetExternalRegistrationsByYearAndStudentType.ts

// Use case interface
export interface GetExternalRegistrationsByYearAndStudentType {
  execute(year: number, studentType: string): Promise<ExternalRegistration[] | Error>;
}

Explanation:

  • The interface GetExternalRegistrationsByYearAndStudentType defines an execute() method that should receive two parameters:
    • year: the year for which external registrations are needed.
    • studentType: the type of student (e.g., "new", "recurrent").
  • The method should return a promise that resolves with an array of external registrations or an error if something fails.

2. Use Case Implementation (Application Layer)

The concrete implementation of the use case is where the logic that coordinates interactions between different components of the system is written.

File: GetExternalRegistrationsByYearAndStudentTypeImpl.ts

import { injectable, inject } from "inversify";
import { ExternalRegistrationRepository } from "../domain/ExternalRegistrationRepository";
import { GetExternalRegistrationsByYearAndStudentType } from "./GetExternalRegistrationsByYearAndStudentType";

// Concrete implementation of the use case
@injectable()
export class GetExternalRegistrationsByYearAndStudentTypeImpl implements GetExternalRegistrationsByYearAndStudentType {
  private repository: ExternalRegistrationRepository;

  // Dependency injection through the constructor
  constructor(
    @inject("ExternalRegistrationRepository") repository: ExternalRegistrationRepository
  ) {
    this.repository = repository;
  }

  // Execute method that delegates to the repository
  public async execute(year: number, studentType: string): Promise<ExternalRegistration[] | Error> {
    try {
      const registrations = await this.repository.getRegistrationsByYearAndStudentType(year, studentType);
      return registrations;
    } catch (error) {
      return new Error("Error fetching external registrations");
    }
  }
}

Explanation:

  • The GetExternalRegistrationsByYearAndStudentTypeImpl class implements the GetExternalRegistrationsByYearAndStudentType interface.
  • Through dependency injection (using @injectable() and @inject()), it receives the repository ExternalRegistrationRepository, which is responsible for interacting with the database or API.
  • The execute() method simply delegates the responsibility to the repository by calling its getRegistrationsByYearAndStudentType() method, which handles the specific logic of data access.

3. Repository Interface (Domain Layer)

The next step is defining the repository interface, which sets the methods that any repository managing external registrations must implement.

File: ExternalRegistrationRepository.ts

export interface ExternalRegistrationRepository {
  getRegistrationsByYearAndStudentType(year: number, studentType: string): Promise<ExternalRegistration[]>;
}

Explanation:

  • This interface defines the getRegistrationsByYearAndStudentType() method, which should receive a year and a student type, and return a promise with the corresponding external registrations.

4. Repository Implementation (Infrastructure Layer)

The repository implementation is where the actual interaction with the data occurs. In this case, the repository communicates with an external API to fetch the registration data.

File: ExternalRegistrationRepositoryImpl.ts

import { injectable, inject } from "inversify-props";
import { ExternalRegistrationRepository } from "../domain/ExternalRegistrationRepository";
import { ExternalRegistration } from "../domain/ExternalRegistration";
import { HttpService, HttpError } from "@/shared/http";
import { Result } from "neverthrow";

const externalRegistrationParser = {
  parseTo: (data: any): Result<ExternalRegistration[], Error> => {
    if (!Array.isArray(data)) {
      return Result.err(new Error("Expected an array"));
    }

    try {
      const registrations: ExternalRegistration[] = data.map((item) => ({
        id: item.id,
        year: item.year,
        studentType: item.studentType,
      }));

      return Result.ok(registrations);
    } catch (e) {
      return Result.err(new Error("Failed to parse external registrations"));
    }
  }
};

@injectable()
export class ExternalRegistrationRepositoryImpl implements ExternalRegistrationRepository {
  constructor(
    @inject() private readonly httpService: HttpService
  ) {}

  public async getRegistrationsByYearAndStudentType(
    year: number,
    studentType: string
  ): Promise<ExternalRegistration[]> {
    const result = await this.httpService.get<any, ExternalRegistration[]>(
      {
        url: `/external-registrations?year=${year}&studentType=${studentType}`
      },
      externalRegistrationParser
    );

    if (result.isOk()) {
      return result.value;
    } else {
      throw result.error;
    }
  }
}

Explanation:

  • The ExternalRegistrationRepositoryImpl class implements the ExternalRegistrationRepository interface and is responsible for retrieving external registrations through an HTTP request using Axios.
  • The getRegistrationsByYearAndStudentType() method sends the request to the backend, passing the year and student type as parameters, and returns the data in JSON format.

5. Dependency Injection Configuration

For the system to work correctly, we need to configure dependency injection. This is done through a container that handles the instances of the classes.

File: di.ts

import { Container } from "inversify";
import { ExternalRegistrationRepository } from "./domain/ExternalRegistrationRepository";
import { ExternalRegistrationRepositoryImpl } from "./infrastructure/ExternalRegistrationRepositoryImpl";
import { GetExternalRegistrationsByYearAndStudentType } from "./application/GetExternalRegistrationsByYearAndStudentType";
import { GetExternalRegistrationsByYearAndStudentTypeImpl } from "./application/GetExternalRegistrationsByYearAndStudentTypeImpl";

// DI container configuration
const container = new Container();
container.bind<ExternalRegistrationRepository>("ExternalRegistrationRepository").to(ExternalRegistrationRepositoryImpl).inSingletonScope();
container.bind<GetExternalRegistrationsByYearAndStudentType>("GetExternalRegistrationsByYearAndStudentType").to(GetExternalRegistrationsByYearAndStudentTypeImpl).inSingletonScope();

export { container };

Explanation:

  • In this file, we use the Container from Inversify to register the dependencies.
  • We register ExternalRegistrationRepositoryImpl as the implementation of ExternalRegistrationRepository.
  • We register GetExternalRegistrationsByYearAndStudentTypeImpl as the implementation of the use case GetExternalRegistrationsByYearAndStudentType.

6. Usage in the User Interface (Presentation Layer)

Finally, in the user interface, which could be a Vue.js component, the use case is used to execute the logic when the user interacts with the interface.

File: CoursesCompletedInYear.vue

<template>
  <div>
    <button @click="getYearlyReport(2021, 'new')">Get list</button>
    <ul>
      <li v-for="registration in registrations" :key="registration.id">{{ registration.courseName }}</li>
    </ul>
  </div>
</template>

<script lang="ts">
import { component } from 'vue-property-decorator';
import { container } from "@/di";
import { GetExternalRegistrationsByYearAndStudentType } from "@/application/GetExternalRegistrationsByYearAndStudentType";

@component
export default class CoursesCompletedInYear extends Vue {
  private registrations: any[] = [];

  // Method that executes the use case
  private async getYearlyReport(year: number, studentType: string) {
    const getExternalRegistrations = container.get<GetExternalRegistrationsByYearAndStudentType>("GetExternalRegistrationsByYearAndStudentType");
    const result = await getExternalRegistrations.execute(year, studentType);

    if (result instanceof Error) {
      console.error("Error:", result.message);
    } else {
      this.registrations = result;
    }
  }
}
</script>

Explanation:

  • The Vue.js component CoursesCompletedInYear.vue contains a button that, when clicked, fetches the external registrations list for a specific year and student type.
  • The getYearlyReport() method retrieves the use case GetExternalRegistrationsByYearAndStudentType from the container and executes it with the provided parameters.
  • The response (success or error) is handled and displayed in the interface.

Complete Flow of the Operation

  1. View (CoursesCompletedInYear.vue): The user selects a year and student type and clicks "Get list."
  2. Controller (getYearlyReport): The use case is retrieved from the DI container and executed with the provided parameters.
  3. Use Case (GetExternalRegistrationsByYearAndStudentTypeImpl): The use case receives the parameters and delegates the request to the repository.
  4. Repository (ExternalRegistrationRepositoryImpl): The repository formats the URL, makes the HTTP request to the backend, and converts the response into an appropriate format.
  5. View (CoursesCompletedInYear.vue): The view receives the transformed data and updates the user interface.

This pattern follows a clear separation of responsibilities between layers: Domain, Use Cases, Infrastructure, and Presentation, making the system more scalable, maintainable, and testable.

How to Write Tests for All of This

One of the main goals of Clean Architecture is to make testing easier, as it promotes a decoupled and modular design for system components. This allows each component, such as use cases, repositories, or services, to be tested in isolation, without complex dependencies.

In this post, I will show how to write unit tests for different components of a Clean Architecture-based system. While the ideal approach would be to follow a Test-Driven Development (TDD) methodology, we haven't done so in this case to stay focused on the main objective of the post, which is to explain how to structure tests in a clean architecture.

Next, we will see how we could write unit tests for the following components: Use Case, Repository, Dependency Container, and View, following Clean Architecture principles.

1. Use Case Tests (GetExternalRegistrationsByYearAndStudentTypeImpl)

The use case is a key piece in the architecture and should be tested in isolation, ensuring its logic works correctly. In this case, the test's purpose is to verify that the use case correctly delegates the responsibility to the repository and handles the results properly.

Use Case Test

File: GetExternalRegistrationsByYearAndStudentTypeImpl.test.ts

import { GetExternalRegistrationsByYearAndStudentTypeImpl } from '@/application/GetExternalRegistrationsByYearAndStudentTypeImpl';
import { ExternalRegistrationRepository } from '@/domain/ExternalRegistrationRepository';
import { mock } from 'jest-mock-extended';

describe('GetExternalRegistrationsByYearAndStudentTypeImpl', () => {
  let getExternalRegistrations: GetExternalRegistrationsByYearAndStudentTypeImpl;
  let externalRegistrationRepository: ExternalRegistrationRepository;

  beforeEach(() => {
    // Create a mock repository
    externalRegistrationRepository = mock<ExternalRegistrationRepository>();
    // Inject the mock into the use case
    getExternalRegistrations = new GetExternalRegistrationsByYearAndStudentTypeImpl(externalRegistrationRepository);
  });

  it('should return registrations when valid data is provided', async () => {
    // Define expected behavior for the repository
    const expectedRegistrations = [
      { id: 1, courseName: 'Course 1' },
      { id: 2, courseName: 'Course 2' }
    ];
    externalRegistrationRepository.getRegistrationsByYearAndStudentType.mockResolvedValue(expectedRegistrations);

    // Execute the use case
    const result = await getExternalRegistrations.execute(2021, 'new');

    // Verify the result is as expected
    expect(result).toEqual(expectedRegistrations);
    expect(externalRegistrationRepository.getRegistrationsByYearAndStudentType).toHaveBeenCalledWith(2021, 'new');
  });

  it('should return an error when the repository throws', async () => {
    externalRegistrationRepository.getRegistrationsByYearAndStudentType.mockRejectedValue(new Error('Repository Error'));

    // Execute the use case
    const result = await getExternalRegistrations.execute(2021, 'new');

    // Verify that the error was handled
    expect(result).toBeInstanceOf(Error);
    expect((result as Error).message).toBe('Error fetching external registrations');
  });
});

Test Explanation:

  • beforeEach: Creates a new instance of the use case and injects a mock repository so it doesn't depend on any real implementation.
  • mockResolvedValue: Simulates a successful response from the repository.
  • mockRejectedValue: Simulates an error to check if it is handled correctly.
  • expect: Verifies that the use case returns the expected result and that the repository receives the correct parameters.

2. Repository Tests (ExternalRegistrationRepositoryImpl)

The repository is responsible for interacting with data (in this case, making an HTTP request). Below, we will create tests to verify that the repository correctly handles API requests and responses.

Repository Test

File: ExternalRegistrationRepositoryImpl.test.ts

import { ExternalRegistrationRepositoryImpl } from '@/infrastructure/ExternalRegistrationRepositoryImpl';
import axios from 'axios';
import { mock } from 'jest-mock-extended';

// Mock axios to intercept HTTP calls
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;

describe('ExternalRegistrationRepositoryImpl', () => {
  let repository: ExternalRegistrationRepositoryImpl;

  beforeEach(() => {
    repository = new ExternalRegistrationRepositoryImpl();
  });

  it('should fetch registrations by year and student type', async () => {
    // Define a mocked response
    const mockResponse = { data: [{ id: 1, courseName: 'Course 1' }] };
    mockedAxios.get.mockResolvedValue(mockResponse);

    const result = await repository.getRegistrationsByYearAndStudentType(2021, 'new');

    // Verify the response is as expected
    expect(result).toEqual([{ id: 1, courseName: 'Course 1' }]);
    expect(mockedAxios.get).toHaveBeenCalledWith('https://api.example.com/external-registrations?year=2021&studentType=new');
  });

  it('should throw an error when API call fails', async () => {
    mockedAxios.get.mockRejectedValue(new Error('API Error'));

    try {
      await repository.getRegistrationsByYearAndStudentType(2021, 'new');
    } catch (error) {
      // Verify an error is thrown
      expect(error).toBeInstanceOf(Error);
      expect(error.message).toBe('Error fetching data from API');
    }
  });
});

Test Explanation:

  • We use jest.mock to mock the Axios library and control its responses.
  • We verify that the constructed URL is correct and that the repository returns the data correctly or handles errors properly.

3. Dependency Container (DI) Tests

The dependency container is responsible for managing the injection of dependencies. Although it is usually not tested directly, we can write tests to verify that dependencies are correctly registered and injected.

Dependency Container Test

File: di.test.ts

import { container } from '@/di';
import { GetExternalRegistrationsByYearAndStudentType } from '@/application/GetExternalRegistrationsByYearAndStudentType';
import { ExternalRegistrationRepositoryImpl } from '@/infrastructure/ExternalRegistrationRepositoryImpl';

describe('Dependency Injection Container', () => {
  it('should resolve the GetExternalRegistrationsByYearAndStudentType use case with the correct repository', () => {
    // Retrieve the use case from the container
    const getExternalRegistrations = container.get<GetExternalRegistrationsByYearAndStudentType>("GetExternalRegistrationsByYearAndStudentType");

    // Verify that the use case has the correct dependency injected
    expect(getExternalRegistrations).toBeInstanceOf(GetExternalRegistrationsByYearAndStudentTypeImpl);
  });

  it('should resolve the repository correctly', () => {
    const repository = container.get<ExternalRegistrationRepositoryImpl>("ExternalRegistrationRepository");
    expect(repository).toBeInstanceOf(ExternalRegistrationRepositoryImpl);
  });
});

Test Explanation:

  • In this test, we verify that the DI container correctly resolves the dependencies and that the injected objects are as expected.

Summary

  • Use Case: We test that the use case correctly delegates the responsibility to the repository and handles the results properly.
  • Repository: We test that the repository correctly handles HTTP requests and manages errors properly.
  • Dependency Container: We test that the DI container correctly injects dependencies.
  • View (Vue Component): We test user interaction with the component and verify that the data flow from the use case to the view works correctly.

These tests ensure that each component of our system behaves as expected, keeping the code modular, maintainable, and testable.