Test code matters, deixe-os clean!

Test code matters, deixe-os clean!
Photo by Dan DeAlmeida / Unsplash

Vamos imaginar que começaremos um projeto do zero assumindo a seguinte premissa: o código de teste é de menor importância.

Sendo assim, vamos dedicar muito cuidado na escrita do código de produção, empenhando toda atenção por deixá-lo com bons e pesquisáveis nomes, sem comentários desnecessários, com funções fazendo apenas uma coisa, classes curtas e todo maravilhoso protocolo do Clean Code. No entanto, os nossos testes serão escritos da forma que derem e não receberão o mesmo cuidado. Basta escrevermos qualquer código que teste e vai estar tudo bem.

Qual será a consequência dessa abordagem? Teremos testes imundos, extremamente confusos, exigindo para interpretação altas cargas de energia mental, dificílimos para manutenção e que levarão o time de desenvolvimento ao terrível dilema: abandonar o código de teste ou refatorar tudo que foi escrito. Bem, é ocioso falar sobre a dor de cabeça e perda de tempo e dinheiro com refatoração; e abandonar o código de teste irá fazer do código de produção um campo minado, onde de qualquer alteração podem explodir bugs, o que não parece a ideia mais prudente.

Com isso, fica claro: o código de testes é tão importante quanto o código de produção. E quem diz isso é o próprio Uncle Bob:

"O código de teste é tão importante quando o código de produção. Não é um cidadão de segunda classe. Exige pensamento, design e cuidado. Precisa ser mantido tão clean quanto o código de produção" - Robert Martin, autor dos livros Clean Code e Clean Architeture

Quer saber como deixar o teu código de testes mais clean? Então segue com a gente.

A regra de ouro

O que torna o código de teste clean? Legibilidade. Empenhe-se por escrever um código legível e terá um código clean. O Uncle Bob chega a dizer que legibilidade é ainda mais importante no código de teste do que no código de produção. E o que realmente torna um código legível? Três coisas: claridade, simplicidade e densidade de expressão. Num teste, você deve dizer muito com o menor número de palavras.

Deixe os testes estruturados

Imagine que você entre num carro e o volante esteja no teto, o acelerador na porta e a buzina no pedal. Dirigir esse carro ficaria confuso, não ficaria? Para quem já estudou um pouco de UX, lembra também que uma das heurísticas de Nielsen são consistência e padrão, ou seja, não é uma boa ideia mudar muito o design de uma barra de rolagem ou de um ícone de salvar, por exemplo, pois isso deixará o usuário confuso.

Citamos esses exemplos para dizer que a mente humana trabalha melhor com padrões. Dessa forma, se estruturamos o nosso código de teste num padrão reconhecível, a sua leitura será mais fácil e outros desenvolvedores intuirão o que é feito com mais facilidade.

Você já deve ter se deparado com testes seguindo alguma das seguintes estruturas:

  • Given-When-Then
  • Build-Operate-Check
  • Arrange-Act-Assert
  • Operate-Check
  • Setup-Exercise-Verify

Todas são boas e trazem o seguinte conceito:

Primeiro, configure os acessórios de teste, objetos ou componentes com os quais você trabalhará. Isso inclui a criação dos stubs e mocks, a definição dos spies, a criação das instâncias do que será testado e assim segue.

Depois, peça para o código performar alguma operação, por exemplo, chamar alguma função.

Por fim, verifique se foi apresentada a conduta esperada.

Observe este exemplo:

describe('DbAddAccount Usecase', () => {
  test('Should call Hasher with correct plaintext', async () => {
    // given
    const { sut, hasherSpy } = makeSut()
    const addAccountParams = mockAddAccountParams()

    // when
    await sut.add(addAccountParams)

    // then
    expect(hasherSpy.plaintext).toBe(addAccountParams.password)
  })
})

Temos aqui um exemplo dessa estrutura, onde primeiro geramos o sut (acrônimo para subject under test, isto é, a classe DbAddAccount), o spy da dependência hasher utilizada (falaremos de spies mais para frente) e um exemplo de requisição addAccount. Em seguida, pedimos para a classe executar o método add. Por fim, checamos se o hasher foi chamado com o valor correto.

Minimize o número de asserts

Há quem defenda que deva existir apenas um assert por teste. Isso é exagerado demais. As vezes um único assert não dá conta de fazer uma adequada verificação de conduta. No entanto, também é verdade que muitos assertions deixam o código difícil de ler e confuso, além de serem um sinal de que o teste de produção escrito não está clean. O certo é deixar o número suficiente de assertions capazes de garantir a verificação de conduta adequada.

Veja o seguinte exemplo:

test('Should return 200 and check if dependencies were called with correct values', 
  async () => {
    const { sut, authenticationSpy, validationSpy, addAccountSpy } = makeSut()
    const request = mockRequest()

    validationSpy.error = new MissingParamError(faker.random.word())
    const httpResponse = await sut.handle(mockRequest())

    expect(httpResponse).toEqual(ok(authenticationSpy.authenticationModel))
    expect(validationSpy.error).toBeFalsy()
    expect(validationSpy.input).toEqual(request)
    expect(authenticationSpy.authenticationParams).toEqual({
      email: request.email,
      password: request.password
    })
    expect(addAccountSpy.addAccountParams).toEqual({
      name: request.name,
      email: request.email,
      password: request.password
    })
  })

É possível intuir com facilidade o que está sendo testado? Ao batermos o olho nesse teste ele já exige mais carga de atenção e interpretação para o que está sendo feito. Tudo bem, o teste pode estar funcionando e testando o que está em produção. Porém, se tivéssemos menos assertions e mais testes para cada diferente situação, a leitura e entendimento do teste não seria mais fácil?

Teste apenas um conceito por teste

Se nos orientarmos em testar apenas um conceito em cada teste, certamente teremos um código clean. Sendo assim, não queira testar diversas coisas diferentes num mesmo teste.

Ainda no exemplo do tópico passado. Veja como aplicando o princípio de um conceito por teste o nosso código fica melhor escrito.

Primeiro, podemos escrever testes para validar a correta chamada de cada dependência, um teste para cada uma:

test('Should call AddAccount with correct values', async () => {
  const { sut, addAccountSpy } = makeSut()
  const request = mockRequest()
  
  await sut.handle(request)
  
  expect(addAccountSpy.params).toEqual({
    name: request.name,
    email: request.email,
    password: request.password
  })
})

test('Should call Validation with correct value', async () => {
  const { sut, validationSpy } = makeSut()
  const request = mockRequest()
    
  await sut.handle(request)
    
  expect(validationSpy.input).toEqual(request)
})

test('Should call Authentication with correct values', async () => {
  const { sut, authenticationSpy } = makeSut()
  const request = mockRequest()
    
  await sut.handle(request)
    
  expect(authenticationSpy.params).toEqual({
    email: request.email,
    password: request.password
  })
})

Em seguida, criamos um teste para o caso de erro:

test('Should return 400 if Validation returns an error', async () => {
  const { sut, validationSpy } = makeSut()
  validationSpy.error = new MissingParamError(faker.random.word())
  
  const httpResponse = await sut.handle(mockRequest())
  
  expect(httpResponse).toEqual(badRequest(validationSpy.error))
})

Finalmente, testamos o caso de sucesso:

test('Should return 200 if valid data is provided', async () => {
  const { sut, authenticationSpy } = makeSut()
  
  const httpResponse = await sut.handle(mockRequest())
  
  expect(httpResponse).toEqual(ok(authenticationSpy.result))
})

Teste cenários de erro e exceção

Você deve testar os cenários de erro e exceção. Isso pode até parecer óbvio num primeiro instante, mas a verdade é que muitos cenários de erro e exceção são passados batidos, o que é imprudente, pois se for feita alguma modificação no código de produção cujo comportamento em casos de erro ou exceção não for testado, podemos ter de presente um bug bem desagradável.

Muitos desenvolvedores se limitam a testar cenários onde apenas existem erros provenientes da requisição vinda do cliente. Entretanto, um sistema não se limita a gerenciar erros vindo de uma requisição incorreta, não é mesmo? Se uma dependência chamada pela classe estourar uma exceção, por exemplo, você deve garantir que essa classe saberá tratar essa exceção da maneira correta. Ou ainda, se uma dependência retornar um valor incorreto, você deve se preocupar em saber se o código de teste está garantindo que a classe realiza o devido tratamento para aquele valor incorreto.

São nesses cenários que os stubs e mocks mostram todo o seu poder. Stubs são objetos com comportamento fixo e previsível. Mocks são implementações “falsas” de uma classe. Por isso, aproveite esses recursos para reproduzir cenários de exceção ou reproduzir o retorno com um valor incorreto ou que necessita de um tratamento especial. Veja na imagem, um exemplo de mock.

export const mockAuthentication = (): Authentication => {

  class AuthenticationMock implements Authentication {
    async auth (authentication: AuthenticationParams): Promise<string> {
      return Promise.resolve('any_token')
    }
  }
  
  return new AuthenticationMock()
}

No JavaScript, temos a biblioteca SinonJs com várias ótimas ferramentas para criar spies, stubs e mocks. Outra excelente opção é utilizar as ferramentas providas pelo Jest, caso esse seja o framework de testes adotado para o seu projeto.

Teste somente o importante

É muito importante sermos criteriosos nos testes, entretanto não menos importante é tomarmos cuidado para não escrevemos testes sem importância, cujo impacto não fará muito diferença na aplicação, nem trará prejuízos para o negócio. Portanto, pergunte-se quais testes vão garantir a correta aplicação das regras do produto e vão tratar aquelas situações críticas. Não escreva testes para validar peculiaridades sem gravidade.

Nesse ponto talvez você se pergunte: "Mas como vou saber se estou testando algo importante? Como avaliar?". Primeiro, devemos indagar: é extremamente importante que tudo nesse código funcione corretamente? É verdade que tudo no código deve funcionar, mas há pontos críticos na aplicação e esses pontos devem ser totalmente cobertos por testes. E sobre o que é extremamente crítico, já adiantamos: tudo que abarca regras de negócio, ou seja, toda camada de domínio da aplicação é de extrema importância e sempre deverá ser coberta por testes.

No entanto, certas classes responsáveis por intermediar dependências de infraestrutura, como por exemplo clients para chamadas de outras APIs, não precisam ser totalmente coberta por testes, uma vez que o tratamento para erros já deverá existir nas tuas classes para casos de uso, que já estarão totalmente cobertas por testes.

Tenha em mente os princípios do F.I.R.S.T

O acrônimo F.I.R.S.T define cinco princípios muito importantes na hora de escrever o seu código de teste. São eles:

Fast: testes devem rodar rápido, ou sejam, devem fazer seu arranjos, performances e validações o mais rápido possível. Imagine se durante o desenvolvimento dos testes unitários de um use case, por exemplo, o teste demorasse mais de cinco minutos? Cada ajuste no código seria cada vez mais penoso por conta da demora na execução do teste. Portanto, escreva-os de uma forma que sua performance seja rápida.

Independent: testes devem ser independentes. Nenhum teste deve depender de outro para executar. Um teste anterior não deve estabelecer a condições de um teste seguinte. Cada um dos testes deve poder ser rodados independentemente.

Repeatable: testes devem ser repetíveis em qualquer ambiente. Você deve poder executar os testes em qualquer sistema operacional, em qualquer ambiente de nuvem, em qualquer pipeline, enfim, em qualquer lugar.

Self-Validating: testes devem retornar um booleano confirmando se o teste passou ou falhou. Você não deve precisar de uma pessoa para interpretar os resultados dos testes. O resultado deve ser informado no console de execução.

Timely: teste devem ser escritos ao mesmo tempo que o código de produção. Se você é um advogado do TDD então acredita que os testes devam ser escritos antes do código de produção. Se não é, então não cometa o erro de escrever todo o código de produção primeiro para depois querer desenvolver os respectivos testes, caso o contrário você vai se deparar com um código de produção que considera muito difícil e não desejará gastar energia para desenhar seus testes.

E os testes na Vórtx, como são?

A meta para todos os desenvolvedores da Vórtx não é simplesmente sermos bons em escrevermos código clean, devemos ser excelentes! É muito importante para tudo o que desenvolvemos, inclusive nos códigos de teste (seja testes unitários, de integração, end-to-end e so on).

Para garantirmos a adequada cobertura de teste, temos aqui duas regras muito importantes:

  • Toda a camada de domínio deve ser coberta por testes
  • Todos os nossos serviços devem ter um cobertura mínima de 70% de testes unitários

Com isso evitamos que ponto críticos da aplicação falhem e gerem transtornos, que podem ser catastróficos. E para mostrar que não brincamos com isso, as nossas esteiras CI/CD avaliam o coverage de testes e os executam. Assim, se algum teste não funcionar ou o coverage não estiver dentro do percentual definido, o novo código desenvolvido não poderá ser mergeado nas branchs principais, muito menos será feito um deploy em qualquer ambiente. Além disso, a nossa regra de ter um code review é para justamente termos um outro avaliador da legibilidade do código desenvolvido.

Também, aproveitamos muito o conceito de dublês de teste. Você sempre vai encontrá-los nos nossos códigos de teste. O Herbs ajuda muito na criação desses dublês, de modo que se precisarmos criar mocks, com o esse framework conseguimos criar facilmente e reproduzir vários comportamentos.

E a ajuda do Herbs não se limita aí. Em cada retornos contamos com variáveis como o isOk e isErr, ótimos auxiliares na hora de fazer os assertions. Além do audittrail, que permite verificar os comportamento do código em cada step, o que abre grande possibilidade de validação.

Aquele livro que você não quer mais fechar

Você já abriu um livro e não conseguiu mais fechar de tão agradável e intrigante que era a sua leitura? Eu mesmo amo os romances de Dostoiévki e não me lembro de ter aberto nenhum cuja pausa não me fosse difícil de tão maravilhosa que era a narrativa feita pelo escritor. É verdade que códigos não têm essa qualidade de intrigantes, mas podem ser de leitura agradável.

Deve nos inspirar o exemplo do escritor Gustave Flaubert, que constantemente revia e reescrevia seus livros, de modo a deixar as passagens melhor escritas. E a prova do seu sucesso é que por duzentos anos Madame Bovary é lido e amado pelas pessoas.

Entre o escritor e o leitor deve haver um contrato: será escrito algo que o leitor consiga entender (na medida na alfabetização do mesmo) e ao mesmo tempo seja agradável. Isso também serve para escrita de código, por isso vale a pena todo esforço para escrevermos um código legível, fluído, agradável, enfim, clean!

E agora, pronto para aplicar todos esses princípios e escrever aqueles testes capa de revista?

Referências

Clean Code: A Handbook of Agile Software Craftsmanship. Robert Martin. Prentice Hall PTR. Illustrated edição (11 agosto 2008)