Testando Detalhes de Implementação

Testar detalhes de implementação é uma má prática. Por quê? E o que isso significa?

Tradução livre do post “Testing Implementation Details” originalmente publicado por Kent C. Dodds em 17 de Agosto de 2020.

~

Na época em que eu estava usando enzyme (como todo mundo na época), tive cuidado com certas APIs. Eu evitei completamente shallow rendering para nunca usar APIs como instance(), state() ou find('NomeDoComponente'). E em revisões de código de pull requests de outras pessoas, expliquei repetidamente por que é importante evitar essas APIs. O motivo é que cada uma delas permite testar detalhes de implementação de seus componentes. Muitas vezes as pessoas me perguntam o que quero dizer com "detalhes de implementação". Quero dizer: é difícil testar do jeito que está! Por que temos que tornar ainda mais complicado?

Por que testar os detalhes de implementação é uma má prática?

Há dois motivos distintos pelos quais é importante evitar detalhes de implementação. Testes que testam os detalhes de implementação:

  1. Podem falhar ao refatorar o código da aplicação. Falso-negativo;

  2. Podem não falhar quando você quebrar o código da aplicação. Falso-positivo.

Para ser claro, o teste é: "o software funciona". Se o teste for bem-sucedido, isso significa que o resultado do teste foi "positivo" (o software está funcionando). Caso contrário, isso significa que o teste retorna "negativo" (não encontrou o software funcionando). O termo "falso" refere-se a quando o teste retornou com um resultado incorreto, significando que o software está realmente corrompido, mas o teste foi aprovado (falso-positivo) ou o software está realmente funcionando, mas o teste falhou (falso-negativo).

Vamos dar uma olhada em cada um deles, usando um simples componente Accordion como exemplo:

// accordion.js

import * as React from 'react'
import AccordionContents from './accordion-contents'

class Accordion extends React.Component {
  state = {openIndex: 0}
  setOpenIndex = openIndex => this.setState({openIndex})
  render() {
    const {openIndex} = this.state
    return (
      <div>
        {this.props.items.map((item, index) => (
          <>
            <button onClick={() => this.setOpenIndex(index)}>
              {item.title}
            </button>
            {index === openIndex ? (
              <AccordionContents>{item.contents}</AccordionContents>
            ) : null}
          </>
        ))}
      </div>
    )
  }
}

export default Accordion

Se você está se perguntando por que estou usando um componente antigo de classe e não um componente funcional moderno (com hooks) para esses exemplos, continue lendo, é uma revelação interessante (alguns de vocês experimentaram com enzyme, então você já deve estar esperando).

E aqui está um teste que testa os detalhes de implementação:

// __tests__/accordion.enzyme.js

import * as React from 'react'

import Enzyme, {mount} from 'enzyme'
import EnzymeAdapter from 'enzyme-adapter-react-16'
import Accordion from '../accordion'

Enzyme.configure({adapter: new EnzymeAdapter()})

test('setOpenIndex sets the open index state properly', () => {
  const wrapper = mount(<Accordion items={[]} />)
  expect(wrapper.state('openIndex')).toBe(0)
  wrapper.instance().setOpenIndex(1)
  expect(wrapper.state('openIndex')).toBe(1)
})

test('Accordion renders AccordionContents with the item contents', () => {
  const hats = {title: 'Favorite Hats', contents: 'Fedoras are classy'}
  const footware = {
    title: 'Favorite Footware',
    contents: 'Flipflops are the best',
  }
  const wrapper = mount(<Accordion items={[hats, footware]} />)
  expect(wrapper.find('AccordionContents').props().children).toBe(hats.contents)
})

Levante a mão se você viu (ou escreveu) testes como este em sua base de código (🙌). Ok, agora vamos dar uma olhada em como as coisas quebram com esses testes...

Falso-negativo ao refatorar

Um número surpreendente de pessoas acha testes desagradáveis, especialmente testes de UI. Por que? Existem várias razões para isso, mas uma grande razão que ouço repetidamente é que as pessoas passam muito tempo cuidando dos testes. "Cada vez que faço uma alteração no código, os testes quebram!" Este é um verdadeiro obstáculo à produtividade! Vamos ver como nossos testes são vítimas desse problema frustrante.

Digamos que eu esteja refatorando este accordion para prepará-lo para permitir que vários itens do accordion sejam abertos ao mesmo tempo. Uma refatoração não altera o comportamento existente, apenas altera a implementação. Portanto, vamos mudar a implementação de uma forma que não mude o comportamento.

Digamos que estamos trabalhando para adicionar a capacidade de vários elementos do accordion serem abertos de uma vez, então estamos mudando nosso estado interno de openIndex para openIndexes:

class Accordion extends React.Component {
-  state = {openIndex: 0}
-  setOpenIndex = openIndex => this.setState({openIndex})
+  state = {openIndexes: [0]}
+  setOpenIndex = openIndex => this.setState({openIndexes: [openIndex]})
   render() {
-    const {openIndex} = this.state
+    const {openIndexes} = this.state
     return (
       <div>
         {this.props.items.map((item, index) => (
           <>
             <button onClick={() => this.setOpenIndex(index)}>
               {item.title}
             </button>
-            {index === openIndex ? (
+            {openIndexes.includes(index) ? (
               <AccordionContents>{item.contents}</AccordionContents>
             ) : null}
           </>
         ))}
       </div>
     )
   }
 }

Incrível, fazemos uma verificação rápida no aplicativo e tudo ainda está funcionando corretamente, então quando chegarmos a este componente mais tarde para permitir a abertura de vários accordions, será muito fácil! Em seguida, executamos os testes e 💥kaboom💥 eles quebram. Qual deles quebrou? setOpenIndex sets the open index state properly.

Qual é a mensagem de erro?

expect(received).toBe(expected)
Expected value to be (using ===):
  0
Received:
  undefined

Essa falha no teste está nos alertando sobre um problema real? Não! O componente ainda funciona bem.

Isso é o que chamamos de falso-negativo. Isso significa que tivemos uma falha no teste, mas foi por causa de um teste quebrado, não do código da aplicação. Sinceramente, não consigo pensar em uma situação de falha de teste mais irritante. Bem, vamos prosseguir e corrigir nosso teste:

test('setOpenIndex sets the open index state properly', () => {
    const wrapper = mount(<Accordion items={[]} />)
-   expect(wrapper.state('openIndex')).toEqual(0)
+   expect(wrapper.state('openIndexes')).toEqual([0])
    wrapper.instance().setOpenIndex(1)
-   expect(wrapper.state('openIndex')).toEqual(1)
+   expect(wrapper.state('openIndexes')).toEqual([1])
})

A lição: testar detalhes de implementação pode fornecer um falso-negativo quando você refatorar seu código. Isso leva a testes frágeis e frustrantes que parecem falhar sempre que você olha o código.

Falso-positivo

Ok, agora digamos que seu colega de trabalho está trabalhando no Accordion e vê este código:

<button onClick={() => this.setOpenIndex(index)}>{item.title}</button>

Imediatamente, seus sentimentos de otimização prematura surgem e eles dizem: "Ei! As arrow functions inline no render são ruins para o desempenho, então vou corrigir isso! Acho que deve funcionar, vou mudar bem rápido e executar os testes."

<button onClick={this.setOpenIndex}>{item.title}</button>

Legal. Execute os testes e... ✅✅ incrível! Eles aceitam o código sem verificá-lo no navegador porque os testes dão confiança, certo? Esse commit vai em um PR completamente não relacionado que muda milhares de linhas de código e é compreensivelmente perdido. O accordion quebra em produção e Nancy não consegue os ingressos para ver Wicked in Salt Lake. Nancy está chorando e sua turma se sente horrível.

Então, o que deu errado? Não tínhamos um teste para verificar se o estado muda quando setOpenIndex é chamado e se o conteúdo do accordion é exibido de forma adequada!? Sim, nós tínhamos! Mas o problema é que não houve nenhum teste para verificar se o botão estava conectado a setOpenIndex corretamente.

Isso é chamado de falso-positivo. Significa que não tivemos uma falha no teste, mas deveríamos! Então, como nos precavemos para garantir que isso não aconteça novamente? Precisamos adicionar outro teste para verificar se ao clicar no botão o estado é atualizado corretamente. E então preciso adicionar um limite mínimo de 100% de cobertura de código para que não cometamos esse erro novamente. Ah, e eu deveria escrever uma dúzia ou mais de plugins ESLint para garantir que as pessoas não usem essas APIs que incentivam testar os detalhes de implementação!

...Mas não vou incomodar ... Ugh, estou tão cansado de todos esses falsos-positivos e negativos que quase prefiro não escrever testes. APAGUE TODOS OS TESTES!

Não seria bom se tivéssemos uma ferramenta que entregasse um Pit of Success mais amplo? Sim, seria! E adivinhe, nós temos essa ferramenta!

Testes sem detalhes de implementação

Portanto, poderíamos reescrever todos esses testes com enzyme, nos limitando a APIs que não contenham detalhes de implementação, mas, em vez disso, vou apenas usar a React Testing Library, o que tornará muito difícil incluir detalhes de implementação em meus testes. Vamos verificar isso agora!

// __tests__/accordion.rtl.js

import '@testing-library/jest-dom/extend-expect'
import * as React from 'react'
import {render, screen} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Accordion from '../accordion'

test('can open accordion items to see the contents', () => {
  const hats = {title: 'Favorite Hats', contents: 'Fedoras are classy'}

  const footware = {
    title: 'Favorite Footware',
    contents: 'Flipflops are the best',
  }

  render(<Accordion items={[hats, footware]} />)
  expect(screen.getByText(hats.contents)).toBeInTheDocument()
  expect(screen.queryByText(footware.contents)).not.toBeInTheDocument()

  userEvent.click(screen.getByText(footware.title))

  expect(screen.getByText(footware.contents)).toBeInTheDocument()
  expect(screen.queryByText(hats.contents)).not.toBeInTheDocument()
})

Maravilha! Um único teste que verifica muito bem todo o comportamento. E este teste passa se o meu state se chama openIndex, openIndexes ou tacosAreTasty 🌮. Legal! Livre-se daquele falso-negativo! E se eu conectar meu gerenciador de cliques incorretamente, este teste falhará. Legal, livre-se daquele falso-positivo também! E não precisei memorizar nenhuma lista de regras. Eu apenas uso a ferramenta a partir de sua semântica e recebo um teste que realmente pode me dar confiança de que meu accordion está funcionando como o usuário também deseja.

Então... Quais são os detalhes de implementação?

Esta é a definição mais simples que posso fazer:

Os detalhes de implementação são coisas que os usuários do seu código normalmente não usarão, verão ou mesmo saberão.

Portanto, a primeira pergunta que precisamos responder é: "Quem é o usuário deste código." Bem, o usuário final que estará interagindo com nosso componente no navegador é definitivamente um usuário. Eles estarão observando e interagindo com os botões e conteúdos renderizados. Mas também temos o desenvolvedor que renderizará o accordion com propriedades (no nosso caso, uma determinada lista de itens). Portanto, os componentes React normalmente têm dois usuários: usuários finais e desenvolvedores. Usuários finais e desenvolvedores são os dois "usuários" que nosso código precisa considerar.

Ótimo, então quais partes do nosso código cada um desses usuários usa, vê e conhece? O usuário final verá/interagirá com o que renderizamos no método render. O desenvolvedor verá/interagirá com as propriedades que ele passa para o componente. Portanto, nosso teste normalmente deve apenas ver/interagir com as propriedades que foram passadas e a saída renderizada.

Isso é precisamente o que o React Testing Library faz. Damos a ele nosso próprio elemento React do componente Accordion com nossas propriedades falsas, então interagimos com a saída renderizada, consultando a saída para o conteúdo que será exibido para o usuário (ou garantindo que não será exibido) e clicando nos botões que são renderizados.

Agora considere o teste com enzyme. Com enzyme, acessamos o state de openIndex. Isso não é algo com que nenhum de nossos usuários se preocupe diretamente. Eles não sabem como é chamado, não sabem se o índice aberto é armazenado como um único valor primitivo ou armazenado como um array e, francamente, eles não se importam. Eles também não sabem ou não se importam com o método setOpenIndex especificamente. E ainda, nosso teste conhece esses dois detalhes de implementação.

Isso é o que torna nosso teste em enzyme sujeito a falsos-negativos. Porque, ao fazer nosso teste usar o componente de maneira diferente dos usuários finais e desenvolvedores, criamos um terceiro usuário que o código da nossa aplicação precisa considerar: os testes! E, francamente, os testes são um usuário com o qual ninguém se importa. Não quero que o código da minha aplicação considere os testes. Que completa perda de tempo. Não quero testes escritos para seu próprio fim. Os testes automatizados devem verificar se o código da aplicação funciona para os usuários de produção.

Quanto mais seus testes se assemelham à forma como o software é usado, mais confiança eles podem lhe dar. - Kent C. Dodds

Leia mais sobre isso em Evitar o usuário Teste.

Então, e os hooks

Bem, ao que parece, o enzyme ainda tem muitos problemas com hooks. Acontece que quando você está testando os detalhes de implementação, uma mudança na implementação tem um grande impacto em seus testes. Isso é uma grande chatice porque se você estiver migrando componentes de classe para componentes funcionais com hooks, então seus testes não podem ajudá-lo a saber que você não quebrou nada no processo.

React Testing Library, por outro lado, funciona de qualquer maneira. Verifique o link codesandbox no final para vê-lo em ação. Gosto de chamar testes que você escreve com o React Testing Library de:

Livres de detalhes de implementação e fáceis de refatorar.

Conclusão

Então, como você evita testar os detalhes de implementação? Usar as ferramentas certas é um bom começo. Aqui está um processo de como saber o que testar. Seguir este processo ajuda você a ter a mentalidade certa ao testar e, naturalmente, evitará os detalhes de implementação:

  1. Qual parte de sua base de código não testada seria realmente ruim se quebrasse? (O processo de checkout);

  2. Tente restringir a uma unidade ou algumas unidades de código (ao clicar no botão "checkout", uma solicitação com os itens do carrinho é enviada para /checkout);

  3. Olhe para esse código e considere quem são os "usuários" (o desenvolvedor processando o formulário de checkout, o usuário final clicando no botão);

  4. Escreva uma lista de instruções para que o usuário teste manualmente esse código para se certificar de que não está corrompido. (renderize o formulário com alguns dados falsos no carrinho, clique no botão checkout, certifique-se de que a API mock/checkout foi chamada com os dados corretos, responda com uma resposta falsa bem-sucedida, certifique-se de que a mensagem de sucesso seja exibida);

  5. Transforme essa lista de instruções em um teste automatizado.

Espero que seja útil para você! Se você realmente deseja levar seus testes para o próximo nível, eu definitivamente recomendo que você obtenha uma licença Pro do TestingJavaScript.com 🏆

Boa sorte!

PS: Se você gostaria de brincar com tudo isso, aqui está um codesandbox.