DEV Community

Cover image for Usando contratos com NestJS, facilitando seus testes unitários
Wiliam V. Joaquim
Wiliam V. Joaquim

Posted on Edited on

Usando contratos com NestJS, facilitando seus testes unitários

O que são contratos?
Contratos, é 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")
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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")
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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
      })
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
  ]
})
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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');
  });
Enter fullscreen mode Exit fullscreen mode

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);
  });
Enter fullscreen mode Exit fullscreen mode

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);
  });
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (1)

Collapse
 
raulferreirasilva profile image
Raul Ferreira

Caramba que conteúdooo!!! Não tenho tanto conhecimento sobre o assunto ainda, mas tenho bastante curiosidade sobre testes e ver esse post me deixou com mais vontade ainda, muito obrigado 🦤.