Aprimorando os testes unitários com HerbsJS!
O intuito com este artigo é trazer algumas técnicas e a minha visão referente aos testes unitários que fazemos com o Herbs, e com isso podermos evoluir nossos testes e qualidade do código no geral.
Obs.: Vale ressaltar que este código é totalmente um exemplo, com o objetivo de mostrar os testes, portanto não se atentem a detalhes de funcionamento do código em si, mas sim no fluxo dos dados e nos testes.
Preparação do contexto
Para os exemplos do artigo, eu usei o Herbs para use case e entity. Além disso utilizei a biblioteca Mocha com Chai para a escrita dos testes.
Vou começar apresentando o caso de uso criado especificamente para este artigo, nele teremos 3 passos, identificar o tipo do usuário (pessoa física ou jurídica), identificar o status do usuário e por fim salvar o usuário.
const IdentityFlow = (injection) =>
usecase("Fluxo de identificação de usuário", {
request: { usuario: Usuario },
setup: (ctx) => (ctx.di = Object.assign({}, dependency, injection)),
"Identifica se o usuário é pessoa física": step(async (ctx) => {
const { usuario } = ctx.req;
const tipoPessoa = DocumentosHelper.identificarDocumento(
usuario.documento
);
if (tipoPessoa) {
ctx.tipoPessoa = tipoPessoa;
return Ok(ctx.tipoPessoa);
}
return Err("Documento do usuário é inválido");
}),
"Identifica se o usuário será notificado": step(async (ctx) => {
const { usuario } = ctx.req;
if (usuario.status === StatusUsuarioEnum.notificar)
return Ok("Notificar usuário");
if (usuario.status === StatusUsuarioEnum.notificado) {
ctx.stop();
return Ok("Usuário já notificado");
}
return Err("Status inválido");
}),
"Atualiza status do usuario": step(async (ctx) => {
try {
const { usuario } = ctx.req;
usuario.status = StatusUsuarioEnum.notificado;
const repository = new ctx.di.UsuarioRepository()
await repository.salvarStatus(usuario);
return Ok();
} catch (error) {
return Err("Não foi possível atualizaro status do usuário");
}
}),
});
Ideia básica do teste:
Fluxo Principal
O resultado esperado no fluxo principal é que o usuário seja salvo e não tem mensagem nenhuma retornando, este portanto é o primeiro teste que faço, levando em consideração que o sucesso é o objetivo do código.
Injeção de dependência do teste:
const injection = {
UsuarioRepository: class {
async salvarStatus(user) {
return true;
}
},
};
Código do teste:
it("Sucesso: Deve salvar o usuário corretamente", async () => {
// Given
const usuario = Usuario.fromJSON({
id: 1,
nome: "Soft New SA",
documento: "62.475.970/0001-09",
status: StatusUsuarioEnum.notificar,
});
const parameters = {
usuario,
};
// When
const useCase = IdentityFlow(injection);
const respostaUseCase = await useCase.run(parameters);
// Then
expect(respostaUseCase.isOk).to.be.true;
});
Explicando
Qual a ideia?
💬Pegar um usuário com dados "perfeitos" para entender se o fluxo principal está funcionando.
O que é um usuário com dados perfeitos?
💬Neste nosso cenário é um usuário com documento válido e com status de "notificar"
Outros fluxos
A partir do momento que o fluxo principal está passando eu foco nos fluxos alternativos, eles são tão importantes quanto o fluxo principal na visão de testes e nos ajudam a lapidar possíveis falhas na lógica.
Exemplo:
Se nosso primeiro passo do use case estivesse escrito desta maneira, nós deixaríamos que um usuário inválido passasse para a próxima etapa, o que poderia gerar problemas grandes dependendo da sua regra de negócio.
"Identifica se o usuário é pessoa física": step(async (ctx) => {
const { usuario } = ctx.req;
const tipoPessoa = DocumentosHelper.identificarDocumento(
usuario.documento
);
ctx.tipoPessoa = tipoPessoa;
return Ok(ctx.tipoPessoa);
})
Portanto tendo esse cenário em vista, nós escrevemos testes que mapeiem as falhas no teste, por exemplo:
it("Falha: Deve identificar que documento informado é totalmente inválido", async () => {
// Given
const usuarioComDocumentoErrado = Usuario.fromJSON({
id: 1,
nome: "Veron Soft SA",
documento: "TESTE",
status: StatusUsuarioEnum.notificar,
});
const parameters = {
usuario: usuarioComDocumentoErrado,
};
// When
const useCase = IdentityFlow(injection);
const retornoUseCase = await useCase.run(parameters);
// Then
expect(retornoUseCase.isErr).to.be.deep.true;
expect(retornoUseCase.err).to.be.deep.equals("Documento do usuário é inválido");
});
Neste caso o teste falharia desta forma e nos obrigaria a entender o caso e corrigir:
Testes utilizando o TDD
Tendo em mente os testes unitários acima descritos como base, podemos passar para o TDD (Test Driven Development), que é o que?
Basicamente você escreve os cenários de teste, inclusive os de falha, antes mesmo do código, assim prevendo as falhas e validando em tempo de desenvolvimento.
O ganho disso é que todas as funcionalidades saem testadas, com testes mais precisos e com isso aumentando a manutenibilidade do projeto em que este código está inserido.
Como fazer um TDD usando Herbs?
O Herbs possui um contexto fechado dentro dos use cases, e isso gera algumas imprecisões nos testes se você valida apenas o resultado da execução do use case quando não conhecemos muito bem essa ferramenta, mas a ideia é descomplicar as coisas, portanto vamos ao código.
Utilizando a segunda etapa do caso de uso, vamos tentar criar os cenários de teste antes do código.
Ao meu ver, consigo pensar em 3 possibilidades de fluxo para o código:
💭 Se o usuário está com status de (notificar), neste caso prosseguimos com o fluxo normal.
💭 Se o usuário está com status de (notificado), neste caso pulamos tudo e finalizamos o processo sem salvar o usuário.
💭 E por fim se o usuário está com status diferente dos acima citados, neste caso o sistema deve retornar um erro, pois o usuário é inválido.
Tendo em vista isso, se eu gerar um erro, eu sei como pegar, é só pegar o isErr e a mensagem no err, que o próprio use case do Herbs faz como padrão... porém como validar os outros cenários? vou ter que exportar uma resposta no ctx.ret?
A ideia é que mesmo que o contexto seja fechado, não precisamos exportar os dados desta maneira, pois contamos com o auditTrail do Herbs.
Pra que serve?
💬 Como o nome sugere, é uma trilha para auditoria, com tudo que aconteceu dentro do use case.
Como isso pode ajudar nos testes?
💬 Ora, se você tem o rastro dos fluxos, podemos pegar e testar exclusivamente o que queremos, independente se deu erro ou se deu certo as etapas seguintes.
Exemplo 2
Então tendo em vista o código da segunda etapa escrito exatamente da forma que está lá no setup inicial, vamos colocar então a validação dos dois cenários que dão certo nesta etapa, visto que o erro já foi demonstrado no exemplo anterior.
O cenário de sucesso, ignorando as etapas seguintes, poderia ser escrito desta maneira:
it("Sucesso: Deve identificar o usuário que será notificado", async () => {
// Given
const usuario = Usuario.fromJSON({
id: 1,
nome: "Soft New SA",
documento: "62.475.970/0001-09",
status: StatusUsuarioEnum.notificar,
});
const parameters = {
usuario,
};
// When
const useCase = IdentityFlow(injection);
await useCase.run(parameters);
const retornoStep = await useCase.auditTrail.steps[1].return.Ok;
// Then
expect(retornoStep).to.be.deep.equals("Notificar usuário");
});
Qual o ganho de ter um cenário que é sucesso e será testado no fluxo completo novamente?
Se por ventura alterarmos o funcionamento deste use case, o fluxo principal pode continuar funcionando de outra forma, porém este step, esta etapa, pode estar sendo pulada ou ignorada pela nova lógica, gerando assim possíveis problemas para a sua regra.
Neste cenário, podemos ver que pegamos ali o retorno do auditTrail e pegamos a resposta Ok, que vem com um texto definido lá no step
Trecho do retorno do teste destacado:
useCase.auditTrail.steps[1].return.Ok;
Trecho do código do step destacado:
return Ok("Notificar usuário");
Exemplo 3
Tendo em vista o teste acima, como podemos saber se o fluxo que ignora as próximas etapas está fazendo isso corretamente? Bom, segue o código do teste neste caso:
it("Sucesso: Deve identificar que o usuário já notificado e cancelar a atualização", async () => {
// Given
const usuario = Usuario.fromJSON({
id: 1,
nome: "Soft New SA",
documento: "62.475.970/0001-09",
status: StatusUsuarioEnum.notificado,
});
const parameters = {
usuario,
};
// When
const useCase = IdentityFlow(injection);
await useCase.run(parameters);
const retornoStep = await useCase.auditTrail.steps[1];
// Then
expect(retornoStep.stopped).to.be.true;
expect(retornoStep.return.Ok).to.be.deep.equals("Usuário já notificado");
});
Como podemos ver ali, temos uma diferença no expect, não apenas o return, mas o stopped também é retornado, então podemos utilizar isso para saber se o processo foi parado antes de finalizar todos os steps.
Finalização
Bom a ideia foi mostrar alguns detalhes como o auditTrail e também a linha de raciocínio que uso durante os testes, para que o teste cubra mais cenários enquanto desenvolvemos. Este é meu primeiro artigo, então qualquer dúvida ou problema, estamos aí para ajudar.