Wiliam V. Joaquim

Wiliam V. Joaquim

Usando contratos com NestJS, facilitando seus testes unitários

thumbnail

O que são contratos?

Contratos são uma forma de separar as responsabilidades entre as partes que utilizam essa implementação, contratos são muito utilizados no contexto de desenvolvimento de software seguindo o padrão DDD (Domain-Driven Design).

Digamos que temos um service chama UserService, este service precisa fazer uma chamada no banco de dados e buscar dados do usuário. Para isso temos uma camada de repository, que é responsável por se comunicar com o banco de dados. Neste exemplo, ficaria assim:

controller > service > repository

Imagine que você vai escrever um teste unitário para a camada de service, porém como se trata de um teste unitário, você não deveria chamar o repository e chamar o banco de dados, para isso entra o contrato.

Ficaria assim:

controller > service > contract > repository

Vamos ao código: No exemplo abaixo temos um service simples, que busca o usuário pelo id, chamando diretamente o nosso repository.

@Injectable()
export class UserService {
 constructor(private repository: UserRepository) {}

  async findUserById(id: string): Promise<User | null> {
    const findUser = await this.repository.findById(id)
    if !findUser throw new NotFoundException("user not found")
  }
}

Abaixo nosso repository, no exemplo utilizamos o ORM Prisma, mas poderia ser qualquer outro. Buscamos o user pelo ID, caso não encontre retornamos null, se não, retornamos o usuário.

@Injectable()
export class UserRepository {
  constructor(private prisma: PrismaService) {}

  async findById(id: string): Promise<User | null> {
    const findUser = await this.prisma.findUnique({
      where: { id },
    });

    if (!findUser) return null;

    return findUser;
  }
}

Agora, imagine fazer o teste unitário do nosso service, você precisaria fazer mocks do seu repository, porém ele depende da conexão com o Prisma, então precisaria criar um mock dessa conexão também, isso torna o teste mais demorado e cansativo. Mas pode ser resolvido com contratos.

Vamos lá, abaixo criamos um contrato usando classe abstrata:

export abstract class UserContractRepository {
  abstract findById(id: string): Promise<User | null>;
}

Agora, basta implementar essa classe, dessa forma todos que utilizarem esse contrato precisam implementar o método findById, vamos alterar o service para utilizar o contrato:

@Injectable()
export class UserService {
  constructor(private repository: UserContractRepository) {}

  async findUserById(id: string): Promise<User | null> {
    const findUser = await this.repository.findById(id);
    if (!findUser) throw new NotFoundException('user not found');
  }
}

No service quase nada é alterado, ao invés de chamar diretamente o repository, vamos chamar o contrato.

Vamos alterar o repository:

@Injectable()
export class UserRepository implements UserContractRepository {
  constructor(private prisma: PrismaService) {}

  async findById(id: string): Promise<User | null> {
    const findUser = await this.prisma.user.findUnique({
      where: { id },
    });

    if (!findUser) return null;

    return findUser;
  }
}

No repository, agora usamos o implements, com isso o editor de código já acusa erro, caso você não tenha a implementação completa do UserContractRepository, no exemplo, temos apenas o findById que recebe uma string por parâmetro e devolve um User ou null, portanto implementamos o UserContractRepository, se adicionar mais métodos no UserContractRepository, vai precisar adicionar no UserRepository.

export abstract class UserContractRepository {
  abstract findById(id: string): Promise<User | null>;
  abstract createUser(user: User): Promise<void>;
}

Agora, temos o createUser, o UserRepository já deve acusar erro, por não implementa por completo o UserContractRepository. Vamos implementar:

@Injectable()
export class UserRepository implements UserContractRepository {
  constructor(private prisma: PrismaService) {}

  async findById(id: string): Promise<User | null> {
    const findUser = await this.prisma.user.findUnique({
      where: { id },
    });

    if (!findUser) return null;

    return findUser;
  }

  async createUser(user: User): Promise<void> {
    const findUser = await this.prisma.user.create({
      data: user,
    });
  }
}

Para funcionar corretamente em NestJs, só precisamos alterar nosso module, para que ao chamar o UserContractRepository seja utilizado a classe UserRepository, é bem simples:

@Module({
  providers: [
    {
      provide: UserContractRepository,
      useClass: UserRepository
    }
  ]
})

Afinal, qual a vantagem disso?

Agora como temos uma camada entre o service e repository, para os testes unitários não precisamos mais chamar o repository diretamente, basta criar uma implementação fake que implementa o UserContractRepository e utilizar nos testes, e ainda, se precisarmos trocar o prisma por outro ORM, como TypeORM por exemplo, não precisamos alterar nosso service, basta criar um novo repository e implementar o UserContractRepository.

Vamos fazer um repository fake, normalmente chamamos de in memory:

export class UserRepositoryInMemory implements UserContractRepository {
  async findById(id: string): Promise<User | null> {
    return
  }

  async createUser(user: User): Promise<void> {
    return
}

Retornando um mock

No exemplo acima, não retornamos nada, mas você poderia retornar um mock do User, desta forma:

const userMock: User = {
  id: "id_mock",
  name: "John Doe",
  email: "john.doe@mail.com"
}

export class UserRepositoryInMemory implements UserContractRepository {
  async findById(id: string): Promise<User | null> {
    return userMock
  }

  async createUser(user: User): Promise<void> {
    return
}

Podemos simular ainda mais o comportamento do repository, verificando se o ID do user é o mesmo do userMock:

const userMock: User = {
  id: "id_mock",
  name: "John Doe",
  email: "john.doe@mail.com"
}

export class UserRepositoryInMemory implements UserContractRepository {
  async findById(id: string): Promise<User | null> {
    if(id !== userMock.id) return null

    return userMock
  }

  async createUser(user: User): Promise<void> {
    return
}

Vamos ver como fazer o teste unitário usando Jest

let sut: UserService;
let repository: UserContractRepository;

beforeEach(async () => {
  const module: TestingModule = await Test.createTestingModule({
    providers: [
      UserService,
      {
        provide: UserContractRepository,
        useClass: UserRepositoryInMemory,
      },
    ],
  }).compile();

  sut = module.get < UserService > UserService;
  repository = module.get < UserContractRepository > UserContractRepository;
});

Usamos a mesma lógica utilizada no module, mas agora chamando o repository in memory, desta forma não precisamos lidar com com a implementação do prisma ORM por exemplo.

O teste ficaria assim:

describe('findUserById', () => {
  it('should be able to return user by id', async () => {
    const result = await sut.findUserById("id_mock");

    expect(result).toBeTruthy();
    expect(result).toHaveProperty('id');
    expect(result).toHaveProperty('name');
    expect(result).toHaveProperty('email');
  });

Veja como fica simples fazer um teste com contratos, você pode simular erro também, usando o spyOn do jest, por exemplo:

describe('findUserById', () => {
  it('should not be able to return user by id', async () => {
  jest.spyOn(repository, 'findById').mockResolvedValueOnce(null);

    expect(() => {
      return sut.findUserById("id_mock");
    }).rejects.toThrow(NotFoundException);
  });

Mas como temos nosso in memory, com o tratamento para retorna null caso o ID do usuário seja diferente, bastaria passar um ID errado:

describe('findUserById', () => {
  it('should not be able to return user by id', async () => {

    expect(() => {
      return sut.findUserById("id_mock_wrong");
    }).rejects.toThrow(NotFoundException);
  });

Conclusão

Em conclusão, o uso de contratos no NestJS pode ser uma abordagem muito eficaz para facilitar os testes unitários e melhorar a manutenção do código. Ao separar a interface do repositório em um contrato, você cria uma camada intermediária entre o serviço e o repositório, permitindo que você crie implementações alternativas para fins de teste, como o UserRepositoryInMemory. Isso tem várias vantagens:

1. Facilita os testes unitários: Com o contrato em vigor, você pode criar implementações de repositório fictícias que se comportam conforme necessário para cada teste, sem a necessidade de interagir com o banco de dados real ou a camada de persistência.

2. Isolamento de testes: Isolar o serviço a ser testado de suas dependências externas, como o ORM ou outros serviços, é fundamental para testes unitários eficazes. Contratos ajudam a alcançar essa isolamento.

3. Flexibilidade: Se você decidir mudar de um ORM para outro, como de Prisma para TypeORM, os serviços não precisam ser alterados. Basta criar um novo repositório que implemente o contrato existente e fazer a troca no módulo.

4. Melhor aderência ao DDD: A abordagem de contratos se alinha bem com os princípios do Domain-Driven Design (DDD), onde a ênfase é colocada na clareza das interfaces entre as diferentes camadas da aplicação.

5. Testes de erro mais simples: Você pode facilmente simular diferentes cenários de erro, como retornar “null” ou lançar exceções, para garantir que seu serviço lida adequadamente com essas situações.

No geral, o uso de contratos no NestJS é uma prática recomendada que pode tornar seu código mais testável, mais flexível e mais robusto. Isso ajuda a manter seu código limpo e facilita a evolução e manutenção do sistema ao longo do tempo.