Aprimorando os testes unitários com HerbsJS!

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.