Introdução
Neste artigo, irei demonstrar a implementação de um caso de uso de registo de utilizador com recurso à Clean Architecture e ao desenvolvimento orientado a testes (Test Driven Development - TDD). Para esta implementação, utilizei o Node.js com JavaScript e a ferramenta de testes Jest.
A Clean Architecture permite criar aplicações desacopladas de tudo o que é externo como por exemplo, base de dados, serviço de email, servidor HTTP e etc. Este padrão de projeto foca-se nas regras de negócio e adia decisões arquiteturais. Com recurso ao TDD em conjunto com este padrão, conseguimos garantir este desacoplamento e uma boa qualidade de código permitindo a criação de aplicações escaláveis e flexíveis.
Arquitetura e responsabilidades
Nesta implementação, segui as seguintes camadas segundo a Clean Architecture: Entities, UseCases, Interface Adapters e Frameworks and Drivers. Neste exemplo, a camada de entities não se aplica porque neste momento não surgiu numa regra de negócio específica que pudesse ser colocada nesta camada. Para já, os testes criados não demonstraram a necessidade de adicionar esta camada. Este artigo será focado apenas no Use Case em conjunto com os Interface Adapters mais especificamente o Repository.
Fonte: Clean Coder Blog
Implementação do RegisterUserUseCase
Este caso de uso pode ser descrito da seguinte forma:
- O sistema deve registar um utilizador na base de dados
- O sistema deve encriptar a palavra-passe antes de enviar para a base de dados
- O sistema deve validar o email e comparar as palavras-passes (palavra-passe e confirmação)
- O sistema não deve registar um utilizador se o utilizador já existir.
- O sistema não deve registar o utilizador caso os parâmetros não tenham sido providenciados ou as dependências não tenham sido injetadas.
- O sistema deve prosseguir o fluxo com os parâmetros corretos.
Em relação ao fluxo do caso de uso, ele vai validar o email (pode ser uma Regex, pode ser uma biblioteca que já implemente a validação de email) vai confirmar a password, vai verificar se o utilizador com o dado email existe e se não existir, vai chamar o repositório e devolver null.
No final, o caso de uso fica com este aspeto:
Este código ainda tem algumas coisas que podem ser melhoradas nomeadamente a criação de erros personalizados, mas para este exemplo serve.
No construtor são recebidas as interfaces para as dependências.
Testes e TDD
A pergunta agora é: "Como começar os testes?".
Não há um caso de teste em específico pelo qual devemos começar. Eu podia começar qualquer um deles. A direção que escolhi foi o caso de sucesso:
Comecei por escrever o teste como manda o TDD. Uma abordagem test first. O caso de uso vai devolver null caso tenha sido bem sucedido.
Ao implementar o teste, o teste falhou porque faltava a classe e a função.
Então criei a classe RegisterUserUseCase com a função execute.
O TDD tem um padrão chamado: Red Green Refactor. O teste falha e quando falha, deve-se fazer a mudança mínima para o teste passar. Neste caso foi assumir o valor de retorno null.
O ciclo dos testes foi praticamente o mesmo.
Criei os casos de erro por exemplo quando falta parâmetros ou quando falta dependências.
O segundo teste foi a confirmação de password:
Criei o teste, o teste falhou e adicionei o if que verifica a password.
Criei o teste, o teste falhou e adicionei o if que verifica a password.
Reparem que a cada a teste a classe foi-se compondo.
Os outros testes foi praticamente quase a mesma coisa, mas agora vou introduzir os mocks de teste.
Mocks de teste permitem simular as dependências sem necessidade de instalar a dependência real. O quê que isto quer dizer na prática? Não preciso de chutar o MongoDB, o MySQL ou o PostgreSQL neste momento. A tendência é implementar logo tudo de uma vez. Na Clean Architecture, os casos de uso não querem saber qual a base de dados que é usada, mas que existe alguém que vai implementar essa base de dados. Não precisamos da base de dados neste momento pois estas decisões ficam para o final. O foco da Clean Architecture é as regras de negócio que é o que terá menos possibilidade de mudança enquanto a base de dados o servidor HTTP pode mudar. Então, como é que eu simulo o acesso à base de dados? Uso um mock de teste. O teste de busca por email comprova isso:
Desta forma, consigo verificar que parâmetros passei na função e consigo simular o retorno da base de dados. Neste caso, consigo fazer devolver um utilizador existente ou não.
Cada teste vai decidir se vai ou não devolver o utilizador.
Para o caso de erro de email existente, no teste é mocado o valor da base de dados, já que ele tem de existir para o teste passar.
Para a validação de email, o procedimento foi semelhante. Foi criada um spy para a validação de email, o que corresponderá a uma dependência que implementa uma Regex ou uma biblioteca de validação.
Na prática, eu não instalei nada para fazer este caso de uso. Até vou mostrar-vos o meu package.json:
A única dependência que eu tenho é o Jest. Não há Express, não há ORM, não há Mongoose. Nada. O que eu quero comprovar com este ponto é que estas decisões são adiadas até serem necessárias. Quando for implementar a base de dados, aí sim a dependência vai aparecer. Nesta fase de implementação, não há necessidade de dependências externas. Isto segue dois princípios interessantes que é o KISS (Keep it simple, stupid) e YAGNI (You ain't gonna need it). Só instalo o Express, o Mongoose ou o Sequelize ou o TypeORM apenas quando for necessário. Neste momento, não faz sentido.
Pontos a melhorar
Ao colocar no ChatGPT, ele identificou as seguintes melhorias, as quais eu vou comentar se fazem sentido.
- Retornos explícitos (
return { email }
): fica, de facto, mais explícito o valor de retorno e poderia ser. Aliás é uma boa prática os métodos retornarem alguma coisa. Nem que seja um booleano. Mas os métodos podem ser void sem problema nenhum. Fica ao critério. - Erros customizados: sim, é uma refatoração possível. Inclusive poderia ser adicionado uma lógica de Either.left e Either.right.
- Validação de dependências no construtor: para poupar linhas de código, não vale a pena. A própria função já lança a exceção se não existir e alguém vai tratar essa exceção.
- Testes de concorrência: é importante lidar com condições de corrida. Pode ser interessante explorar a questão de programação concorrente. Mas normalmente, isso é barrado pela base de dados com o index unique.
- Refatoração com TypeScript ou DTOs: Depende. Não necessariamente é preciso TypeScript para usar Clean Architecture.
Conclusão
Em suma, a combinação de Clean Architecture com TDD permite a criação de aplicações desacopladas. Desta forma, é possível trocar um componente por outro com facilidade.
O normal no desenvolvimento de uma aplicação é começar pela base de dados, por criar o servidor HTTP, saber as tecnologias que vão ser aplicadas. Não está errado fazer dessa forma, mas na realidade, a aplicação será mais difícil de refatorar no futuro. Começar desde cedo com testes e com uma arquitetura desacoplada poupa retrabalho. Muitos podem argumentar: "Estou a perder tempo com os testes. Não se faz nada. Estou testar mais e a fazer menos.". Na realidade estão a evitar trabalho de manutenção no futuro. Os testes existem como uma documentação de que aquela feature funciona.
Não é preciso ter o servidor HTTP, a base de dados MySQL ou o Express para testarem o vosso software. Reparem no package.json. Não instalei dependência nenhuma a não ser a ferramenta de teste. Os testes vão vos guiando através do pattern Red Green Refactor. Primeiro ponto, o teste falha. Depois, façam o mínimo de esforço necessário para aquele teste passar. E se necessário, façam refactoring. O teste é o vosso copiloto.
Com isto, é possível criar aplicações escaláveis e flexíveis ao ponto de mudarem uma dependência por outra.