DEV Community

Walber Melo
Walber Melo

Posted on • Updated on

Testes Estáticos vs. Testes Unitários vs. Testes de Integração vs. Testes E2E para Aplicações Frontend

Este artigo é uma tradução de "Static vs Unit vs Integration vs E2E Testing for Frontend Apps" por Kent C. Dodds. Fiz esta tradução porque considero a ideia de Troféu de Teste útil, e há poucos artigos em francês sobre o assunto.

Na minha entrevista 'Práticas de Teste com J.B. Rainsberger', disponível em TestingJavaScript.com, ele me deu uma metáfora que eu realmente gostei. Ele disse:

"You can throw paint against the wall and eventually you might get most of the wall, but until you go up to the wall with a brush, you'll never get the corners🖌️."

Eu amo essa metáfora em como ela se aplica aos testes porque basicamente está dizendo que escolher a estratégia de teste certa é o mesmo tipo de escolha que você faria ao escolher um pincel para pintar uma parede. Você usaria um pincel de ponta fina para a parede inteira? Claro que não. Isso levaria muito tempo e o resultado final provavelmente não pareceria muito uniforme. Você usaria um rolo para pintar tudo, incluindo ao redor dos móveis montados que sua tataravó trouxe do outro lado do oceano há duzentos anos? De jeito nenhum. Existem diferentes pincéis para diferentes casos de uso e a mesma coisa se aplica aos testes.

Por isso criei o Troféu de Testes. Desde então, Maggie Appleton (a mente por trás da arte/design magistral do egghead.io) criou isto para o TestingJavaScript.com:

O Troféu de Teste

No Troféu de Testes, existem 4 tipos de testes. Ele mostra este texto acima, mas para o benefício daqueles que usam tecnologias assistivas (e caso a imagem falhe ao carregar para você), vou escrever o que diz aqui, de cima para baixo:

  • End to End: Um robô auxiliar que se comporta como um usuário para clicar pela aplicação e verificar se funciona corretamente. Às vezes chamado de "teste funcional" ou e2e.

  • Integração: Verifica se várias unidades funcionam juntas em harmonia.

  • Unitário: Verifica se partes individuais e isoladas funcionam conforme o esperado.

  • Estático: Captura erros de digitação e erros de tipo enquanto você escreve o código.

O tamanho dessas formas de teste no troféu é relativo à quantidade de foco que você deve dar a elas ao testar suas aplicações (em geral). Quero fazer uma análise detalhada dessas diferentes formas de teste, o que significa na prática e o que podemos fazer para otimizar o melhor retorno para nosso investimento em testes.

Tipos de Teste

Vamos dar uma olhada em alguns exemplos do que esses tipos de testes são, indo de cima para baixo:

End to End

Tipicamente, estes testes executarão a aplicação inteira (tanto o frontend quanto o backend) e seu teste interagirá com a aplicação da mesma forma que um usuário comum faria. Esses testes são escritos com Cypress.

import {generate} from 'todo-test-utils'

describe('todo app', () => {
  it('should work for a typical user', () => {
    const user = generate.user()
    const todo = generate.todo()
    // Aqui estamos passando pelo processo de registro.
    // Normalmente, terei apenas um teste E2E que faz isso.
    // O restante dos testes atingirá o mesmo endpoint
    // que o aplicativo para que possamos pular a navegação por essa experiência.
    cy.visitApp()

    cy.findByText(/register/i).click()

    cy.findByLabelText(/username/i).type(user.username)

    cy.findByLabelText(/password/i).type(user.password)

    cy.findByText(/login/i).click()

    cy.findByLabelText(/add todo/i)
      .type(todo.description)
      .type('{enter}')

    cy.findByTestId('todo-0').should('have.value', todo.description)

    cy.findByLabelText('complete').click()

    cy.findByTestId('todo-0').should('have.class', 'complete')
    // etc...
    // Meus testes E2E geralmente se comportam de forma semelhante a como um usuário faria.
    // Eles às vezes podem ser bastante longos.
  })
})
Enter fullscreen mode Exit fullscreen mode

Integração

O teste abaixo renderiza a aplicação completa. Isso NÃO é um requisito dos testes de integração e a maioria dos meus testes de integração não renderiza a aplicação completa. No entanto, eles renderizarão com todos os provedores usados na minha aplicação (isso é o que o método render do módulo imaginário "**test/app-test-utils**" faz). A ideia por trás dos testes de integração é mockar o mínimo possível. Basicamente, eu só mocko:

  1. Network requests (using MSW)
  2. Components responsible for animation (because who wants to wait for that in your tests?)
import * as React from 'react'
import {render, screen, waitForElementToBeRemoved} from 'test/app-test-utils'
import userEvent from '@testing-library/user-event'
import {build, fake} from '@jackfranklin/test-data-bot'
import {rest} from 'msw'
import {setupServer} from 'msw/node'
import {handlers} from 'test/server-handlers'
import App from '../app'

const buildLoginForm = build({
  fields: {
    username: fake(f => f.internet.userName()),
    password: fake(f => f.internet.password()),
  },
})

// Testes de integração geralmente apenas simulam requisições // HTTP via MSW (Mock Service Worker).
const server = setupServer(...handlers)

beforeAll(() => server.listen())
afterAll(() => server.close())
afterEach(() => server.resetHandlers())

test(`logging in displays the user's username`, async () => {
  // O renderizador personalizado retorna uma promessa que é 
  // resolvida quando o aplicativo
  // termina de carregar (se você estiver renderizando no    
  // servidor, talvez não precise disso).
  // O renderizador personalizado também permite que você    
  // especifique sua rota inicial. 
 await render(<App />, {route: '/login'})
  const {username, password} = buildLoginForm()

  userEvent.type(screen.getByLabelText(/username/i), username)
  userEvent.type(screen.getByLabelText(/password/i), password)
  userEvent.click(screen.getByRole('button', {name: /submit/i}))

  await waitForElementToBeRemoved(() => screen.getByLabelText(/loading/i))

// faça asserções do que for necessário para verificar se o  // usuário está logado  expect(screen.getByText(username)).toBeInTheDocument()
})
Enter fullscreen mode Exit fullscreen mode

Para estes, normalmente também tenho algumas coisas configuradas globalmente, como redefinir automaticamente todos os mocks entre os testes.

Saiba como configurar um arquivo de test-utils como o acima na documentação de configuração do React Testing Library.

Unitário

import '@testing-library/jest-dom/extend-expect'
 import * as React from 'react'
// se você tiver um módulo de utilitários de teste como no   // exemplo de teste de integração acima,
// então use isso em vez de @testing-library/react
import {render, screen} from '@testing-library/react'
import ItemList from '../item-list'

// Algumas pessoas não chamam isso de teste unitário porque estamos renderizando no DOM com o React.
// Eles diriam para você usar a renderização rasa (shallow rendering) em vez disso.
// Quando eles dizem isso, envie-os para                     // https://kcd.im/shallow
test('renders "no items" when the item list is empty', () => {
  render(<ItemList items={[]} />)
  expect(screen.getByText(/no items/i)).toBeInTheDocument()
})

test('renders the items in a list', () => {
  render(<ItemList items={['apple', 'orange', 'pear']} />)
  // nota: com algo tão simples, eu poderia considerar usar um 
  // snapshot, mas somente se:
  // 1. o snapshot for pequeno
  // 2. nós utilizarmos toMatchInlineSnapshot()
  // Leia mais: https://kcd.im/snapshots
  expect(screen.getByText(/apple/i)).toBeInTheDocument()
  expect(screen.getByText(/orange/i)).toBeInTheDocument()
  expect(screen.getByText(/pear/i)).toBeInTheDocument()
  expect(screen.queryByText(/no items/i)).not.toBeInTheDocument()
})
Enter fullscreen mode Exit fullscreen mode

Todos chamam isso de teste unitário e eles estão certos:

// funções puras são as MELHORES para testes unitários e eu 
 // ADORO usar jest-in-case para elas!
import cases from 'jest-in-case'
import fizzbuzz from '../fizzbuzz'

cases(
  'fizzbuzz',
  ({input, output}) => expect(fizzbuzz(input)).toBe(output),
  [
    [1, '1'],
    [2, '2'],
    [3, 'Fizz'],
    [5, 'Buzz'],
    [9, 'Fizz'],
    [15, 'FizzBuzz'],
    [16, '16'],
  ].map(([input, output]) => ({title: `${input} => ${output}`, input, output})),
)
Enter fullscreen mode Exit fullscreen mode

Estático

// você consegue identificar o erro?
// Aposto que a regra for-direction do ESLint
// poderia pegá-lo mais rápido do que você em uma revisão de // código 😉
for (var i = 0; i < 10; i--) {
  console.log(i)
}

const two = '2'
// ok, este é um pouco forçado,
// mas o TypeScript vai dizer que isso é ruim:
const result = add(1, two)
Enter fullscreen mode Exit fullscreen mode

Por que testamos novamente?

Acho importante lembrar por que escrevemos testes em primeiro lugar. Por que você escreve testes? É porque eu disse para fazer isso? É porque sua solicitação de pull será rejeitada a menos que inclua testes? É porque os testes melhoram seu fluxo de trabalho?

A maior e mais importante razão pela qual escrevo testes é a CONFIANÇA. Quero ter confiança de que o código que estou escrevendo para o futuro não quebrará a aplicação que tenho em produção hoje. Então, seja o que for que eu faça, quero ter certeza de que os tipos de testes que escrevo me trazem a maior confiança possível e preciso estar ciente das compensações que estou fazendo ao testar.

Vamos falar sobre compensações

Há alguns elementos importantes no troféu de testes que quero destacar nesta imagem (retirada dos meus slides):

O Troféu de Teste com setas indicando os compromissos

Custo: 💰➡💰🤑💰

Conforme você avança no troféu de testes, os testes se tornam mais caros. Isso se traduz em dinheiro real para executar os testes em um ambiente de integração contínua, mas também no tempo que os engenheiros levam para escrever e manter cada teste individual.

Quanto mais alto você vai no troféu, mais pontos de falha existem e, portanto, é mais provável que um teste quebre, exigindo mais tempo para analisar e corrigir os testes. Mantenha isso em mente porque é importante #preparação...

Velocidade: 🏎️💨 ➡️ 🐢

Conforme você avança no troféu de testes, os testes geralmente são executados mais lentamente. Isso ocorre devido ao fato de que, quanto mais alto você estiver no troféu de testes, mais código seu teste estará executando. Os testes unitários geralmente testam algo pequeno que não tem dependências ou irá simular essas dependências (efetivamente trocando o que poderia ser milhares de linhas de código por apenas algumas). Mantenha isso em mente porque é importante #preparação...

Confiança: Problemas simples 👌 ➡️ Problemas grandes 😖

Os trade-offs de custo e velocidade são geralmente referenciados quando as pessoas falam sobre a pirâmide de testes 🔺. Se esses fossem os únicos trade-offs, então eu focaria 100% dos meus esforços em testes unitários e ignoraria completamente qualquer outra forma de teste ao considerar a pirâmide de testes. Claro que não devemos fazer isso e isso se deve a um princípio super importante que você provavelmente já ouviu eu dizer antes:

Quanto mais seus testes se assemelham à forma como seu software é usado, mais confiança eles podem lhe dar.

O que isso significa? Significa que não há melhor maneira de garantir que sua tia Marie consiga fazer sua declaração de imposto de renda usando seu software de imposto de renda do que realmente tê-la fazendo isso. Mas não queremos depender de tia Marie para encontrar nossos bugs, certo? Isso levaria muito tempo e ela provavelmente perderia algumas funcionalidades que deveríamos estar testando. Some a isso o fato de que estamos regularmente lançando atualizações para nosso software, não há como qualquer quantidade de seres humanos conseguir acompanhar.

Então, o que fazemos? Fazemos compensações. E como fazemos isso? Escrevemos software que testa nosso software. E a compensação que sempre fazemos quando fazemos isso é que agora nossos testes não se assemelham à forma como nosso software é usado tão confiavelmente quanto quando tínhamos tia Marie testando nosso software. Mas fazemos isso porque resolvemos problemas reais que tínhamos com essa abordagem. E é isso que estamos fazendo em cada nível do troféu de testes.

Conforme você avança no troféu de testes, você está aumentando o que eu chamo de "coeficiente de confiança". Este é o nível de confiança relativa que cada teste pode lhe proporcionar nesse nível. Você pode imaginar que acima do troféu está o teste manual. Isso lhe daria uma grande confiança a partir desses testes, mas os testes seriam realmente caros e lentos.

Anteriormente, eu disse para você lembrar de duas coisas:

  1. Quanto mais alto você vai no troféu, mais pontos de falha existem e, portanto, é mais provável que um teste quebre.

  2. Testes unitários geralmente testam algo pequeno que não tem dependências ou irá simular essas dependências (efetivamente trocando o que poderia ser milhares de linhas de código por apenas algumas).

O que essas afirmações estão dizendo é que quanto mais baixo você estiver no troféu, menos código seus testes estarão testando. Se você estiver operando em um nível baixo, precisará de mais testes para cobrir o mesmo número de linhas de código em sua aplicação do que um único teste poderia em um nível mais alto do troféu. De fato, conforme você desce no troféu de testes, há algumas coisas que são impossíveis de testar.

Em particular, ferramentas de análise estática são incapazes de fornecer confiança na lógica de negócios. Testes unitários são incapazes de garantir que, ao chamar uma dependência, você está chamando-a adequadamente (embora você possa fazer assertivas sobre como ela está sendo chamada, você não pode garantir que está sendo chamada corretamente com um teste unitário). Testes de integração de UI são incapazes de garantir que você está passando os dados corretos para o seu backend e que responde e analisa os erros corretamente. Testes E2E são bastante capazes, mas geralmente você os executa em um ambiente não de produção (parecido com produção, mas não produção) para trocar essa confiança pela praticidade.

Vamos agora para o outro lado. No topo do troféu de testes, se você tentar usar um teste E2E para verificar que ao digitar em um campo específico e clicar no botão de envio para um caso limite na integração entre o formulário e o gerador de URL, você estará fazendo muito trabalho de configuração ao executar a aplicação inteira (incluindo o backend). Isso pode ser mais adequado para um teste de integração. Se você tentar usar um teste de integração para atingir um caso limite para o calculador de código de cupom, é provável que você esteja fazendo um bom trabalho em sua função de configuração para garantir que possa renderizar os componentes que usam o calculador de código de cupom e você poderia cobrir esse caso limite melhor em um teste unitário. Se você tentar usar um teste unitário para verificar o que acontece quando você chama sua função de adição com uma string em vez de um número, pode ser muito melhor usar uma ferramenta de verificação de tipo estático como o TypeScript.

Conclusão

Cada nível vem com seus próprios trade-offs. Um teste E2E tem mais pontos de falha, o que muitas vezes torna mais difícil rastrear qual código causou a quebra, mas também significa que seu teste está lhe dando mais confiança. Isso é especialmente útil se você não tem tanto tempo para escrever testes. Eu prefiro ter confiança e ter que rastrear por que está falhando, do que não ter identificado o problema por meio de um teste em primeiro lugar.

No final, não me importo muito com as distinções. Se você quiser chamar meus testes unitários de testes de integração ou até mesmo de testes E2E (como algumas pessoas já fizeram 🤷‍♂️), então que assim seja. O que me interessa é se estou confiante de que, quando envio minhas alterações, meu código atende aos requisitos de negócios e usarei uma mistura das diferentes estratégias de teste para alcançar esse objetivo.

Boa sorte!

Top comments (0)