Executando verificação de segurança...
11

Testes unitários em front com Typescript?

O CONTEXTO

Já fazem quase 6 meses que o time de tecnologia da minha empresa passou por umas mudanças e decidimos adotar as práticas e métricas de DevOps do DORA como religião. E no meio desse bolo todo de métricas e práticas vem o TDD.

No começo, com a gente foi o mesmo que acontece com todo mundo que começa nessa vida de bater coverage: estresse, lágrimas e uma incessante dúvida de "isso tudo tá valendo a pena?". Agora, depois de quase 6 meses, com a prática, já estamos naquela fase onde fazer testes não é mais tão trabalhoso e complicado. Mas na nossa equipe de frontend a dúvida sempre continou: "e isso tudo tá valendo a pena?"

Quem já fez sabe: testes unitários em frontend são muito mais complexos de fazer do que no backend. Isso devido a todo o setup que é necessário preparar para se simular a DOM, o navegador e o usuário. Principalmente devido ao fator "usuário", os testes acabam tendo mais cenários possíveis para se contemplar e cada teste precisa de um ambiente mockado diferente para tentar simular os componentes certos.

A QUESTÃO

O Istambul, ferramenta de coverage usada por nós, metrifica a porcentagem de código testado apenas analisando as linhas de código executadas. Se a linha X do arquivo Y não foi executada em nenhum teste, então significa que ela não foi testada, logo não há garantias de que ela vai funcionar.

Isso faz sentido... caso a gente não usasse Typescript.

O Typescript, estritamente configurado e bem utilizado, é capaz de me informar de virtualmente todos os possíveis cenários que podem passar por cada linha. Ele me garante que nunca farei nenhuma operação que possa causar algum erro em qualquer hipótese porque ele é onisciente sobre o código.

Então por que eu preciso criar testes que apenas renderizam componentes e executam funções para checar se eles foram renderizados sem erro? O coverage do Istambul apenas me obriga a gastar tempo montando um setup de mocks para executar uma ação que eu JÁ SEI que vai dar certo. Eu não quero isso e muito menos o cara que paga meu salário quer.

Isso culmina em um cenário muito importante de se ter em mente caso sua equipe esteja entrando no mundo do TDD: seu app pode ostentar 100% de coverage, sua equipe pode se gabar de ter escrito 500 test suits e ainda assim o código não possuir nem 1 teste realmente relevante.

Ainda assim, por algum motivo, todos os lugares por onde a gente pesquisava pareciam idolatrar os testes unitários, mesmo com Typescript, como se fosse uma verdade óbvia.

A CONCLUSÃO

Tendo como premissa que a única variável real é o usuário, já que o TS já estaria testando em tempo real tudo do código pra dentro, chegamos na conclusão de que os únicos testes que realmente valem a pena escrever são os testes do tipo "Evento x Resultado". Ou seja:
Após o estímulo X, Y aconteceu?

APENAS.

Isso significa duas coisas muito importantes:

1. Não teste nada que não possua efeito observável.
2. Não teste os meios, mas apenas os fins.

Seguindo esses dois princípios você não só acaba escrevendo testes mais pragmáticos/eficientes, mas também faz com que deixem o código aberto para refatoração. De que interessa se para atualizar a URL meu código usou a função/adapter X? A URL no final foi atualizada conforme o esperado ou não? O resultado final é o que devo garantir que continue acontecendo nos meus testes.

Há ainda um terceiro ponto importante para se ter em mente: nunca teste código dos outros! Isto é, não teste componentes vindos de libs de terceiros. Primero, porque você não é pago pra isso; e segundo, porque você não quer testar o mesmo código várias vezes em diferentes lugares. Ao invés disso, procure instalar dependências que possuam badge de test coverage no repositório, além de sempre ter um SAST/DAST na sua pipe.

Isso não significa que a métrica de coverage não deva existir, porque caso contrário vira várzea. Afinal, ainda não chegamos num consenso de qual é a porcentagem ideal. Mas duas coisas bem importantes foram percebidas pela gente:

1. O coverage não deve ser o guia e símbolo de qualidade de código.
2. Os reviews de PRs devem ser mais atenciosos com os testes que são escritos ou poderiam ter sido.

Assim como os testes unitários ajudam a diminuir a carga dos testes de integração, o Typescript ajuda a diminuir a carga dos testes unitários. Usar typescript (ou qualquer linguagem fortemente tipada) é escrever testes unitários enquanto escreve código.

Não importa há quanto tempo você escreve testes, parece que sempre vai ser uma atividade de alto custo e um retorno não igualmente alto. Isso não significa que você não deve fazer, porque eles FAZEM sim a diferença e só aumentam seu valor conforme seu app aumenta. Eles nunca serão sua garantia de excelente funcionamento, mas são o que fazem você dormir bem mais tranquilo todas as noites sabendo que se algum bug repentino estourar, dificilmente vai ser algo crítico que já não foi testado infinitas vezes antes pelo mesmo teste que você escreveu meses atrás.

E você, concorda com isso?


DISCLAIMER

O texto tem como base os contextos comuns do frontend. É óbvio que vão existir casos (e muitos) que estourarão erro, mas o Typescript não consegue prever. Se você precisa fazer iterações, recursividade, acesso de index de arrays/listas, logo consegue perceber isso. Esse é o tipo de coisa que o coverage te avisa para testar e você realmente deveria estar testando em seu código, além de simplesmente ficar renderizando componentes em uma DOM mockada.
2

Gostaria de trazer um comentário sobre onde você diz "Usar typescript é escrever testes unitários enquanto escreve código.".


É interessante pensar que o TypeScript em si não vai verificar os tipos após a compilação, caso você não tenha feito isso usando o próprio JavaScript.

Então o "teste do TypeScript", muitas vezes consiste em garantir que a compilação está devidamente tipada antes de gerar o código compilado.


Por exemplo:

const sum = (a: number, b: number): number => a + b;
  • Durante o desenvolvimento (.ts): o método espera por números em todos os pontos (entrada e saída), alertando sempre que você tentar usar outro tipo.
  • Ao compilar (.js), o JavaScript aceita qualquer tipo nos parâmetros a e b, inclusive contatenando strings no resultado e retornando uma string ao invés de um número.

Tentei mostrar de uma forma bem simples, mas isso poderia inclusive trazer vulnerabilidades ao confiar que o TypeScript seria "teste" o suficiente para cobrir métodos unitários, especialmente pois normalmente a produção irá com todos os arquivos compilados para .js 🙋🏻‍♂️

2

Sim, muito importante sempre lembrar como o TS não fica no produto final e não impede nada de acontecer.

O que a gente sempre acaba tendo que fazer é garantir que os inputs vindos de fora do App acabem sendo convertidos pros tipos que a gente quer, o que é trackeado com TS e feito sempre com JS. Isso já evitaria, virtualmente, todos os casos.

Resumindo: use o TS como detector de possíveis entradas e garanta em JS que elas serão devidamente tratadas.

4

Uma possível abordagem seria verificando os tipos que nós não temos total controle (externos) e até variáveis de ambiente:

const sum = (a: unknown, b: unknown) => {
  if (typeof a !== 'number' || typeof b !== 'number') {
    throw new Error('All params must be numbers');
  }

  return a + b;
};
  • Dessa forma, mesmo após a compilação, o método sum sempre verificará os tipos.

Outra forma comum e geralmente menos verbosa, seria forçar os tipos. Por exemplo, Number(a) + Number(b).


🔐 Ao que se refere à segurança, particularmente eu sempre uso unknown para todo e qualquer dado que venha do usuário.

Vale considerar que esse exemplo considera um cenário onde não temos controle do que o usuário pode enviar nos parâmetros a e b.

Em abordagens onde temos controle de todas as pontas, nem sempre é preciso ir tão longe 🧙🏻


Ainda assim, apenas garantir que os tipos agora são de fato numéricos, não garante que a soma sempre trará os resultados corretos.

Pessoalmente falando, costumo manter um equilíbrio entre testes unitários, de integração e de ponta-a-ponta, pois não vejo um substituindo o outro, mas se somando entre si 🤝